@1agh/maude 0.15.0 → 0.17.1

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 (53) hide show
  1. package/README.md +4 -2
  2. package/cli/commands/design.mjs +108 -2
  3. package/package.json +12 -18
  4. package/plugins/design/dev-server/annotations-context-toolbar.tsx +8 -8
  5. package/plugins/design/dev-server/annotations-layer.tsx +8 -10
  6. package/plugins/design/dev-server/api.ts +227 -3
  7. package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +40 -0
  8. package/plugins/design/dev-server/bin/_html-playwright.mjs +129 -0
  9. package/plugins/design/dev-server/bin/_pdf-playwright.mjs +105 -0
  10. package/plugins/design/dev-server/bin/_png-playwright.mjs +143 -0
  11. package/plugins/design/dev-server/bin/_pptx-playwright.mjs +98 -0
  12. package/plugins/design/dev-server/bin/_svg-playwright.mjs +141 -0
  13. package/plugins/design/dev-server/canvas-lib.tsx +12 -13
  14. package/plugins/design/dev-server/canvas-shell.tsx +111 -9
  15. package/plugins/design/dev-server/client/app.jsx +71 -143
  16. package/plugins/design/dev-server/client/comments-overlay.css +381 -0
  17. package/plugins/design/dev-server/client/styles/3-shell.css +1 -10
  18. package/plugins/design/dev-server/client/styles/4-components.css +5 -161
  19. package/plugins/design/dev-server/client/styles.css +5 -160
  20. package/plugins/design/dev-server/comments-overlay.tsx +1156 -0
  21. package/plugins/design/dev-server/context-menu.tsx +36 -9
  22. package/plugins/design/dev-server/dist/client.bundle.js +52 -211
  23. package/plugins/design/dev-server/dist/styles.css +1 -218
  24. package/plugins/design/dev-server/export-dialog.tsx +401 -0
  25. package/plugins/design/dev-server/exporters/_browser-bundles.ts +89 -0
  26. package/plugins/design/dev-server/exporters/canva-handoff-prompt.ts +74 -0
  27. package/plugins/design/dev-server/exporters/canva.ts +126 -0
  28. package/plugins/design/dev-server/exporters/html.ts +103 -0
  29. package/plugins/design/dev-server/exporters/index.ts +135 -0
  30. package/plugins/design/dev-server/exporters/pdf.ts +109 -0
  31. package/plugins/design/dev-server/exporters/png.ts +136 -0
  32. package/plugins/design/dev-server/exporters/pptx.ts +263 -0
  33. package/plugins/design/dev-server/exporters/scope.ts +196 -0
  34. package/plugins/design/dev-server/exporters/svg.ts +122 -0
  35. package/plugins/design/dev-server/exporters/zip.ts +109 -0
  36. package/plugins/design/dev-server/http.ts +109 -0
  37. package/plugins/design/dev-server/input-router.tsx +21 -0
  38. package/plugins/design/dev-server/inspect.ts +1 -1
  39. package/plugins/design/dev-server/server.mjs +1 -1
  40. package/plugins/design/dev-server/test/canvas-meta-api.test.ts +0 -10
  41. package/plugins/design/dev-server/test/comments-api.test.ts +229 -0
  42. package/plugins/design/dev-server/test/exporters/canva.test.ts +64 -0
  43. package/plugins/design/dev-server/test/exporters/endpoint.test.ts +121 -0
  44. package/plugins/design/dev-server/test/exporters/history.test.ts +79 -0
  45. package/plugins/design/dev-server/test/exporters/html.test.ts +26 -0
  46. package/plugins/design/dev-server/test/exporters/pdf.test.ts +53 -0
  47. package/plugins/design/dev-server/test/exporters/png.test.ts +32 -0
  48. package/plugins/design/dev-server/test/exporters/pptx.test.ts +31 -0
  49. package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
  50. package/plugins/design/dev-server/test/exporters/svg.test.ts +29 -0
  51. package/plugins/design/dev-server/test/exporters/zip.test.ts +105 -0
  52. package/plugins/design/dev-server/tool-palette.tsx +34 -16
  53. package/plugins/design/templates/_shell.html +33 -0
@@ -0,0 +1,1156 @@
1
+ /**
2
+ * @file comments-overlay.tsx — FigJam-style in-place comments overlay
3
+ * @scope plugins/design/dev-server/comments-overlay.tsx
4
+ * @purpose Renders DS-styled comment pins (Phase 6 Task 2), the in-place
5
+ * composer bubble (Task 3), and the thread popover (Task 4)
6
+ * inside the canvas iframe. Sibling to `annotations-layer` —
7
+ * portals into `.dc-world` so CSS zoom + translate on the world
8
+ * plane scale every pin/popover uniformly with the artboards.
9
+ *
10
+ * Data flow (Phase 6 Task 2 — pins only; composer + thread land in Task 3/4):
11
+ * 1. Shell (`client/app.jsx`) pushes `{ dgn: 'comments-set', comments }`
12
+ * into the iframe whenever its `commentsByFile[activePath]` changes.
13
+ * 2. Overlay also fetches `/_comments?file=...` on mount as a self-heal —
14
+ * lets the overlay render even if the shell hasn't broadcast yet
15
+ * (race on first iframe load).
16
+ * 3. Shell pushes `{ dgn: 'comment-focus', id }` when the user clicks a row
17
+ * in the comments panel — overlay highlights the matching pin.
18
+ * 4. Overlay posts `{ dgn: 'comment-click', id }` back to the shell when
19
+ * the user clicks a pin — same channel the legacy `dgn-pin` overlay
20
+ * used; the shell already routes it to `setFocusedCommentId`.
21
+ *
22
+ * Filter respect — Phase 6 Task 2 default is "hide resolved". The shell will
23
+ * gain a `comments-filter` channel in Task 6; until then the overlay always
24
+ * hides resolved pins. Plan-aligned.
25
+ *
26
+ * Pin position math — see `offsetWithinWorld` below. We walk offsetParent up
27
+ * to `.dc-world` to get pre-zoom world coords directly; CSS zoom on the world
28
+ * plane then renders the pin at the right scale without us doing zoom math.
29
+ *
30
+ * The legacy vanilla-JS `#dgn-pin-layer` injected by `inspect.ts` is hidden
31
+ * on mount to avoid double-pins inside TSX canvases. The legacy layer still
32
+ * renders for `.html` mocks where this React overlay never mounts.
33
+ */
34
+
35
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
36
+
37
+ import { useSelectionSetOptional } from './use-selection-set.tsx';
38
+
39
+ // ─────────────────────────────────────────────────────────────────────────────
40
+ // Types — kept in sync with `Comment` in `api.ts`. We mirror the shape rather
41
+ // than import to avoid pulling server types into the canvas runtime bundle.
42
+
43
+ interface OverlayBounds {
44
+ x: number;
45
+ y: number;
46
+ w: number;
47
+ h: number;
48
+ }
49
+
50
+ // Selection payload posted by canvas-shell's `onDropComment`. Mirrors the
51
+ // shape `hoverTargetToSelection` returns; we keep it loose so the overlay
52
+ // doesn't depend on canvas-shell types.
53
+ interface ComposeSelection {
54
+ file?: string;
55
+ id?: string;
56
+ selector: string;
57
+ artboardId?: string | null;
58
+ tag: string;
59
+ classes: string;
60
+ text: string;
61
+ dom_path: string[];
62
+ bounds: OverlayBounds | null;
63
+ html: string;
64
+ }
65
+
66
+ interface ComposerState {
67
+ selection: ComposeSelection;
68
+ clientX: number;
69
+ clientY: number;
70
+ }
71
+
72
+ interface OverlayReply {
73
+ id: string;
74
+ author: string;
75
+ body: string;
76
+ created: string;
77
+ }
78
+
79
+ export interface OverlayComment {
80
+ id: string;
81
+ file: string;
82
+ selector: string;
83
+ bounds: OverlayBounds | null;
84
+ text: string;
85
+ status: 'open' | 'resolved';
86
+ created: string;
87
+ resolved_at: string | null;
88
+ author?: string;
89
+ thread?: OverlayReply[];
90
+ mentions?: string[];
91
+ // dom_path / tag / classes / html_excerpt unused at overlay layer; kept off
92
+ // the type to keep the surface tight.
93
+ }
94
+
95
+ // ─────────────────────────────────────────────────────────────────────────────
96
+ // CSS load — sibling stylesheet, fetched once per session via a <link> tag.
97
+ // Inlining the file at build time would cost an extra bundler config; the
98
+ // overlay is internal-only so a runtime <link> is fine.
99
+
100
+ const CSS_HREF = '/_client/comments-overlay.css';
101
+
102
+ function ensureOverlayStyles(): void {
103
+ if (typeof document === 'undefined') return;
104
+ if (document.getElementById('cm-overlay-css')) return;
105
+ const link = document.createElement('link');
106
+ link.id = 'cm-overlay-css';
107
+ link.rel = 'stylesheet';
108
+ link.href = CSS_HREF;
109
+ document.head.appendChild(link);
110
+ }
111
+
112
+ // ─────────────────────────────────────────────────────────────────────────────
113
+ // File derivation — same logic as canvas-shell.tsx::deriveFile(). Duplicated
114
+ // here so the overlay can fetch its own comments on mount without importing
115
+ // from canvas-shell (which would create a cycle).
116
+
117
+ function deriveFile(): string | null {
118
+ if (typeof window === 'undefined') return null;
119
+ try {
120
+ const p = window.location.pathname;
121
+ if (p === '/_canvas-shell.html' || p === '/_canvas-shell') {
122
+ const qs = new URLSearchParams(window.location.search);
123
+ const canvas = qs.get('canvas') ?? '';
124
+ const designRel = (qs.get('designRel') ?? '.design').replace(/^\/+|\/+$/g, '');
125
+ return canvas ? `${designRel}/${canvas}` : null;
126
+ }
127
+ return decodeURIComponent(p).replace(/^\//, '');
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ // ─────────────────────────────────────────────────────────────────────────────
134
+ // Position resolvers — screen coords via getBoundingClientRect. Mirrors the
135
+ // `SelectionHalos` / `HoverHalo` pattern in canvas-shell.tsx so the comments
136
+ // layer can render as a fixed-position sibling of `.dc-canvas` (above the
137
+ // halo chrome at z-index 5) instead of being portaled into `.dc-world` where
138
+ // it would lose the stacking battle.
139
+
140
+ function screenRectFor(selector: string): {
141
+ x: number;
142
+ y: number;
143
+ w: number;
144
+ h: number;
145
+ } | null {
146
+ if (!selector) return null;
147
+ let el: HTMLElement | null = null;
148
+ try {
149
+ el = document.querySelector(selector) as HTMLElement | null;
150
+ } catch {
151
+ return null;
152
+ }
153
+ if (!el || !el.isConnected) return null;
154
+ const r = el.getBoundingClientRect();
155
+ if (r.width === 0 && r.height === 0) return null;
156
+ return { x: r.left, y: r.top, w: r.width, h: r.height };
157
+ }
158
+
159
+ // ─────────────────────────────────────────────────────────────────────────────
160
+ // Public component — mounted from canvas-shell.tsx alongside ToolPalette /
161
+ // AnnotationsLayer / SnapGuideOverlay.
162
+
163
+ export function CommentsOverlay(): React.ReactNode {
164
+ ensureOverlayStyles();
165
+
166
+ // Optional — CommentsOverlay is mounted inside SelectionSetProvider in the
167
+ // standard CanvasShell tree, but stays usable if a host ever embeds it
168
+ // outside that provider (returns null instead of throwing).
169
+ const selSet = useSelectionSetOptional();
170
+ const [comments, setComments] = useState<OverlayComment[]>([]);
171
+ const [focusedId, setFocusedId] = useState<string | null>(null);
172
+ const [composer, setComposer] = useState<ComposerState | null>(null);
173
+ const file = useMemo(() => deriveFile(), []);
174
+
175
+ // Drop the legacy `#dgn-pin-layer` so we don't render duplicate pins inside
176
+ // TSX canvases. The layer ships in every served HTML page via inspect.ts;
177
+ // for `.html` mocks (no canvas-shell mount) it still does its job.
178
+ useEffect(() => {
179
+ if (typeof document === 'undefined') return;
180
+ const legacy = document.getElementById('dgn-pin-layer') as HTMLElement | null;
181
+ if (!legacy) return;
182
+ const prev = legacy.style.display;
183
+ legacy.style.display = 'none';
184
+ return () => {
185
+ legacy.style.display = prev;
186
+ };
187
+ }, []);
188
+
189
+ // Mirror a comment into the canvas selection set so SelectionHalos paints
190
+ // the same halo Cmd-click would. Called from both the in-iframe pin click
191
+ // AND the inbound `comment-focus` postMessage so jumping from the shell's
192
+ // Comments panel produces the same visual feedback as clicking the pin.
193
+ const mirrorSelection = useCallback(
194
+ (comment: OverlayComment | undefined) => {
195
+ if (!selSet) return;
196
+ if (!comment || !comment.selector) {
197
+ selSet.clear();
198
+ return;
199
+ }
200
+ const cdMatch = comment.selector.match(/data-cd-id="([^"]+)"/);
201
+ const cdId = cdMatch ? cdMatch[1] : undefined;
202
+ let tag: string | undefined;
203
+ let classes: string | undefined;
204
+ try {
205
+ const el = document.querySelector(comment.selector) as HTMLElement | null;
206
+ if (el) {
207
+ tag = el.tagName.toLowerCase();
208
+ classes = (el.getAttribute('class') ?? '')
209
+ .split(/\s+/)
210
+ .filter((cls) => cls && !cls.startsWith('dgn-') && !cls.startsWith('dc-cv-'))
211
+ .join(' ');
212
+ }
213
+ } catch {
214
+ /* unresolvable selector — fall through with no fresh metadata */
215
+ }
216
+ selSet.replace({
217
+ file: file ?? undefined,
218
+ id: cdId,
219
+ selector: comment.selector,
220
+ tag,
221
+ classes,
222
+ bounds: comment.bounds ?? undefined,
223
+ });
224
+ },
225
+ [selSet, file]
226
+ );
227
+
228
+ // Keep the latest comments list reachable from the message handler without
229
+ // re-attaching the listener on every comments mutation.
230
+ const commentsRef = useRef<OverlayComment[]>(comments);
231
+ commentsRef.current = comments;
232
+
233
+ // Listen for the shell's broadcast channels. Schema matches the legacy
234
+ // overlay so the shell-side glue in client/app.jsx (~line 1672) keeps
235
+ // working without modification.
236
+ useEffect(() => {
237
+ if (typeof window === 'undefined') return;
238
+ const onMessage = (e: MessageEvent) => {
239
+ const m = e.data as { dgn?: string; comments?: unknown; id?: string } | null;
240
+ if (!m || typeof m !== 'object' || !m.dgn) return;
241
+ if (m.dgn === 'comments-set' && Array.isArray(m.comments)) {
242
+ setComments(m.comments as OverlayComment[]);
243
+ } else if (m.dgn === 'comment-focus') {
244
+ const id = typeof m.id === 'string' ? m.id : null;
245
+ setFocusedId(id);
246
+ const target = id ? commentsRef.current.find((c) => c.id === id) : undefined;
247
+ mirrorSelection(target);
248
+ }
249
+ };
250
+ window.addEventListener('message', onMessage);
251
+ return () => window.removeEventListener('message', onMessage);
252
+ }, [mirrorSelection]);
253
+
254
+ // canvas-shell's `onDropComment` dispatches `cm:open-composer` on the iframe
255
+ // document. Open the composer pinned to that click point.
256
+ useEffect(() => {
257
+ if (typeof document === 'undefined') return;
258
+ const onOpen = (e: Event) => {
259
+ const detail = (
260
+ e as CustomEvent<{ selection?: ComposeSelection; clientX?: number; clientY?: number }>
261
+ ).detail;
262
+ if (!detail || !detail.selection) return;
263
+ setComposer({
264
+ selection: detail.selection,
265
+ clientX: typeof detail.clientX === 'number' ? detail.clientX : 0,
266
+ clientY: typeof detail.clientY === 'number' ? detail.clientY : 0,
267
+ });
268
+ };
269
+ document.addEventListener('cm:open-composer', onOpen);
270
+ return () => document.removeEventListener('cm:open-composer', onOpen);
271
+ }, []);
272
+
273
+ const closeComposer = useCallback(() => {
274
+ setComposer(null);
275
+ if (typeof window === 'undefined') return;
276
+ try {
277
+ window.parent.postMessage({ dgn: 'force-clear' }, '*');
278
+ } catch {
279
+ /* parent detached */
280
+ }
281
+ }, []);
282
+
283
+ const submitComposer = useCallback(
284
+ (text: string) => {
285
+ if (!composer) return;
286
+ const sel = composer.selection;
287
+ const payload = {
288
+ file: sel.file,
289
+ selector: sel.selector,
290
+ dom_path: sel.dom_path,
291
+ tag: sel.tag,
292
+ classes: sel.classes,
293
+ bounds: sel.bounds,
294
+ html_excerpt: sel.html,
295
+ text,
296
+ };
297
+ if (typeof window === 'undefined') return;
298
+ // Shell relays into the WS `comments-add` channel and persists.
299
+ try {
300
+ window.parent.postMessage({ dgn: 'comment-submit', payload }, '*');
301
+ } catch {
302
+ /* parent detached */
303
+ }
304
+ closeComposer();
305
+ },
306
+ [composer, closeComposer]
307
+ );
308
+
309
+ // Self-heal fetch — covers the race where the iframe loads before the shell
310
+ // pushes `comments-set` (e.g. first hydration on cold open).
311
+ useEffect(() => {
312
+ if (!file) return;
313
+ let cancelled = false;
314
+ (async () => {
315
+ try {
316
+ const r = await fetch(`/_comments?file=${encodeURIComponent(file)}`);
317
+ if (!r.ok) return;
318
+ const data = (await r.json()) as { comments?: OverlayComment[] };
319
+ if (cancelled) return;
320
+ if (Array.isArray(data.comments)) {
321
+ // Only set when we haven't received a shell broadcast yet; the
322
+ // shell is authoritative once it kicks in.
323
+ setComments((prev) => (prev.length === 0 ? (data.comments ?? []) : prev));
324
+ }
325
+ } catch {
326
+ /* offline / dev-server restart — silently no-op */
327
+ }
328
+ })();
329
+ return () => {
330
+ cancelled = true;
331
+ };
332
+ }, [file]);
333
+
334
+ // Sorted by `created` asc so sequence numbers are stable per canvas across
335
+ // reloads. Resolved comments are hidden by default (Task 2 spec).
336
+ const visible = useMemo(() => {
337
+ const list = comments.slice().sort((a, b) => a.created.localeCompare(b.created));
338
+ return list.filter((c) => c.status !== 'resolved');
339
+ }, [comments]);
340
+
341
+ // Sequence index lookup — built off the FULL sorted list so a resolved-then-
342
+ // reopened pin keeps its original number.
343
+ const indexById = useMemo(() => {
344
+ const m = new Map<string, number>();
345
+ const all = comments.slice().sort((a, b) => a.created.localeCompare(b.created));
346
+ all.forEach((c, i) => m.set(c.id, i + 1));
347
+ return m;
348
+ }, [comments]);
349
+
350
+ const handlePinClick = useCallback(
351
+ (id: string) => {
352
+ setFocusedId(id);
353
+ mirrorSelection(comments.find((c) => c.id === id));
354
+ if (typeof window === 'undefined') return;
355
+ try {
356
+ window.parent.postMessage({ dgn: 'comment-click', id }, '*');
357
+ } catch {
358
+ /* parent detached */
359
+ }
360
+ },
361
+ [comments, mirrorSelection]
362
+ );
363
+
364
+ const handlePatch = useCallback((id: string, patch: Record<string, unknown>) => {
365
+ if (typeof window === 'undefined') return;
366
+ try {
367
+ window.parent.postMessage({ dgn: 'comment-patch', id, patch }, '*');
368
+ } catch {
369
+ /* parent detached */
370
+ }
371
+ }, []);
372
+
373
+ const handleDelete = useCallback((id: string) => {
374
+ if (typeof window === 'undefined') return;
375
+ try {
376
+ window.parent.postMessage({ dgn: 'comment-delete', id }, '*');
377
+ } catch {
378
+ /* parent detached */
379
+ }
380
+ setFocusedId((prev) => (prev === id ? null : prev));
381
+ }, []);
382
+
383
+ const handleReply = useCallback(async (id: string, body: string): Promise<boolean> => {
384
+ if (typeof fetch === 'undefined') return false;
385
+ try {
386
+ const r = await fetch(`/_api/comments/${encodeURIComponent(id)}/reply`, {
387
+ method: 'POST',
388
+ headers: { 'Content-Type': 'application/json' },
389
+ body: JSON.stringify({ body }),
390
+ });
391
+ if (!r.ok) return false;
392
+ const updated = (await r.json()) as OverlayComment;
393
+ // Optimistic local merge — the shell will broadcast `comments-set`
394
+ // shortly after as the WS fans out the change, but applying it now
395
+ // avoids the popover flickering empty between submit + broadcast.
396
+ setComments((prev) => prev.map((c) => (c.id === updated.id ? updated : c)));
397
+ return true;
398
+ } catch {
399
+ return false;
400
+ }
401
+ }, []);
402
+
403
+ return (
404
+ <div className="cm-layer" aria-hidden={false}>
405
+ {visible.map((c) => {
406
+ const n = indexById.get(c.id) ?? 0;
407
+ return (
408
+ <CommentPin
409
+ key={c.id}
410
+ comment={c}
411
+ sequence={n}
412
+ focused={focusedId === c.id}
413
+ onClick={handlePinClick}
414
+ />
415
+ );
416
+ })}
417
+ {composer ? (
418
+ <CommentComposer state={composer} onSubmit={submitComposer} onCancel={closeComposer} />
419
+ ) : null}
420
+ {(() => {
421
+ if (!focusedId) return null;
422
+ const focused = visible.find((c) => c.id === focusedId);
423
+ if (!focused) return null;
424
+ return (
425
+ <CommentThread
426
+ comment={focused}
427
+ onClose={() => {
428
+ setFocusedId(null);
429
+ // Drop the canvas halo when the thread closes — symmetric with
430
+ // `handlePinClick` which paints it on open.
431
+ selSet?.clear();
432
+ }}
433
+ onPatch={(patch) => handlePatch(focused.id, patch)}
434
+ onDelete={() => handleDelete(focused.id)}
435
+ onReply={(body) => handleReply(focused.id, body)}
436
+ />
437
+ );
438
+ })()}
439
+ </div>
440
+ );
441
+ }
442
+
443
+ // ─────────────────────────────────────────────────────────────────────────────
444
+ // MentionAwareTextarea + popup — Task 5
445
+ //
446
+ // Wraps a textarea and surfaces an autocomplete popup when the caret sits
447
+ // inside an `@<query>` token (no whitespace between `@` and cursor). The
448
+ // committer list is fetched once on first focus and cached for the session.
449
+ // Keyboard: ↑↓ move, ↵/Tab insert (`@firstname `), Esc dismiss.
450
+
451
+ interface CommitterRow {
452
+ name: string;
453
+ email: string;
454
+ commits: number;
455
+ }
456
+
457
+ let committerCache: Promise<CommitterRow[]> | null = null;
458
+ async function loadCommitters(): Promise<CommitterRow[]> {
459
+ if (!committerCache) {
460
+ committerCache = (async () => {
461
+ try {
462
+ const r = await fetch('/_api/git-committers');
463
+ if (!r.ok) return [];
464
+ const data = (await r.json()) as { committers?: CommitterRow[] };
465
+ return Array.isArray(data.committers) ? data.committers : [];
466
+ } catch {
467
+ return [];
468
+ }
469
+ })();
470
+ }
471
+ return committerCache;
472
+ }
473
+
474
+ function firstNameSlug(name: string): string {
475
+ // `@firstname` is what we insert on accept. Strip surnames + punctuation.
476
+ const first = name.trim().split(/\s+/)[0] ?? '';
477
+ // Keep alphanum + `.` `-` `_` (matches the parseMentions regex on the server).
478
+ return first.replace(/[^\w.-]/g, '').toLowerCase();
479
+ }
480
+
481
+ interface MentionToken {
482
+ start: number; // index of `@`
483
+ end: number; // exclusive — current caret
484
+ query: string; // chars between `@` and caret (excluding `@`)
485
+ }
486
+
487
+ function detectMentionToken(text: string, caret: number): MentionToken | null {
488
+ if (caret <= 0 || caret > text.length) return null;
489
+ // Walk backwards from caret; the token starts at `@` and ends at the caret.
490
+ // Aborts on whitespace, newline, or any non-mention char so a stray `@` in
491
+ // an email is ignored.
492
+ let i = caret - 1;
493
+ while (i >= 0) {
494
+ const ch = text[i] ?? '';
495
+ if (ch === '@') {
496
+ // Token must be word-leading: previous char is start-of-string or whitespace.
497
+ const prev = i > 0 ? text[i - 1] : '';
498
+ if (i === 0 || /\s/.test(prev ?? '')) {
499
+ const query = text.slice(i + 1, caret);
500
+ return { start: i, end: caret, query };
501
+ }
502
+ return null;
503
+ }
504
+ if (!/[\w.-]/.test(ch)) return null;
505
+ i -= 1;
506
+ }
507
+ return null;
508
+ }
509
+
510
+ function MentionAwareTextarea({
511
+ className,
512
+ value,
513
+ onChange,
514
+ onKeyDown,
515
+ placeholder,
516
+ rows,
517
+ disabled,
518
+ textareaRef,
519
+ ariaLabel,
520
+ }: {
521
+ className: string;
522
+ value: string;
523
+ onChange: (next: string) => void;
524
+ onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
525
+ placeholder?: string;
526
+ rows?: number;
527
+ disabled?: boolean;
528
+ textareaRef?: React.MutableRefObject<HTMLTextAreaElement | null>;
529
+ ariaLabel?: string;
530
+ }): React.ReactElement {
531
+ const internalRef = useRef<HTMLTextAreaElement | null>(null);
532
+ const setRef = useCallback(
533
+ (el: HTMLTextAreaElement | null) => {
534
+ internalRef.current = el;
535
+ if (textareaRef) textareaRef.current = el;
536
+ },
537
+ [textareaRef]
538
+ );
539
+
540
+ const [committers, setCommitters] = useState<CommitterRow[]>([]);
541
+ const [token, setToken] = useState<MentionToken | null>(null);
542
+ const [highlight, setHighlight] = useState(0);
543
+
544
+ // Lazy-load committers on first focus.
545
+ const onFocus = useCallback(() => {
546
+ if (committers.length > 0) return;
547
+ void loadCommitters().then((list) => setCommitters(list));
548
+ }, [committers.length]);
549
+
550
+ const filtered = useMemo(() => {
551
+ if (!token) return [] as CommitterRow[];
552
+ const q = token.query.toLowerCase();
553
+ const list = !q
554
+ ? committers
555
+ : committers.filter(
556
+ (c) => c.name.toLowerCase().includes(q) || c.email.toLowerCase().includes(q)
557
+ );
558
+ return list.slice(0, 8);
559
+ }, [token, committers]);
560
+
561
+ const refreshToken = useCallback((textarea: HTMLTextAreaElement) => {
562
+ const caret = textarea.selectionStart ?? textarea.value.length;
563
+ const t = detectMentionToken(textarea.value, caret);
564
+ setToken(t);
565
+ setHighlight(0);
566
+ }, []);
567
+
568
+ const handleChange = useCallback(
569
+ (e: React.ChangeEvent<HTMLTextAreaElement>) => {
570
+ onChange(e.target.value);
571
+ refreshToken(e.target);
572
+ },
573
+ [onChange, refreshToken]
574
+ );
575
+
576
+ const insertMention = useCallback(
577
+ (committer: CommitterRow) => {
578
+ if (!token) return;
579
+ const ta = internalRef.current;
580
+ if (!ta) return;
581
+ const tag = `@${firstNameSlug(committer.name)}`;
582
+ const next = `${value.slice(0, token.start)}${tag} ${value.slice(token.end)}`;
583
+ onChange(next);
584
+ setToken(null);
585
+ // Restore caret just past the inserted token + trailing space.
586
+ const newCaret = token.start + tag.length + 1;
587
+ requestAnimationFrame(() => {
588
+ ta.focus();
589
+ ta.setSelectionRange(newCaret, newCaret);
590
+ });
591
+ },
592
+ [token, value, onChange]
593
+ );
594
+
595
+ const handleKeyDown = useCallback(
596
+ (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
597
+ if (token && filtered.length > 0) {
598
+ if (e.key === 'ArrowDown') {
599
+ e.preventDefault();
600
+ setHighlight((h) => (h + 1) % filtered.length);
601
+ return;
602
+ }
603
+ if (e.key === 'ArrowUp') {
604
+ e.preventDefault();
605
+ setHighlight((h) => (h - 1 + filtered.length) % filtered.length);
606
+ return;
607
+ }
608
+ if (e.key === 'Enter' || e.key === 'Tab') {
609
+ e.preventDefault();
610
+ const pick = filtered[highlight] ?? filtered[0];
611
+ if (pick) insertMention(pick);
612
+ return;
613
+ }
614
+ if (e.key === 'Escape') {
615
+ e.preventDefault();
616
+ setToken(null);
617
+ return;
618
+ }
619
+ }
620
+ onKeyDown?.(e);
621
+ },
622
+ [token, filtered, highlight, insertMention, onKeyDown]
623
+ );
624
+
625
+ const handleSelect = useCallback(
626
+ (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
627
+ refreshToken(e.currentTarget);
628
+ },
629
+ [refreshToken]
630
+ );
631
+
632
+ return (
633
+ <div style={{ position: 'relative' }}>
634
+ <textarea
635
+ ref={setRef}
636
+ className={className}
637
+ value={value}
638
+ placeholder={placeholder}
639
+ rows={rows}
640
+ disabled={disabled}
641
+ aria-label={ariaLabel}
642
+ onChange={handleChange}
643
+ onKeyDown={handleKeyDown}
644
+ onFocus={onFocus}
645
+ onSelect={handleSelect}
646
+ onClick={handleSelect}
647
+ />
648
+ {/* Combobox pattern — `role="listbox"` + `role="option"` is the canonical
649
+ * ARIA shape for a single-select autocomplete. Keyboard navigation
650
+ * (↑ ↓ Enter Esc) lives on the parent textarea per the combobox spec,
651
+ * so the popup itself stays inert. Same pattern applies to the composer
652
+ * + thread popovers below (`role="dialog"` on a positioned <div>).
653
+ * Biome's a11y rules want semantic HTML primitives, but none match
654
+ * "non-focusable listbox under a textarea" or "anchored non-modal popover".
655
+ * The four affected rules are scoped off for this file in biome.json. */}
656
+ {token && filtered.length > 0 ? (
657
+ <ul
658
+ className="cm-mention-popup"
659
+ role="listbox"
660
+ aria-label="Mention suggestions"
661
+ style={{ left: 0, top: '100%' }}
662
+ >
663
+ {filtered.map((c, i) => {
664
+ const selected = i === highlight;
665
+ return (
666
+ <li
667
+ key={`${c.name}-${c.email}`}
668
+ role="option"
669
+ aria-selected={selected}
670
+ className="cm-mention-popup__item"
671
+ onMouseEnter={() => setHighlight(i)}
672
+ // Use mousedown so the textarea doesn't blur before the
673
+ // selection registers.
674
+ onMouseDown={(ev) => {
675
+ ev.preventDefault();
676
+ insertMention(c);
677
+ }}
678
+ >
679
+ <span className="cm-mention-popup__name">@{firstNameSlug(c.name)}</span>
680
+ <span className="cm-mention-popup__email">{c.email}</span>
681
+ </li>
682
+ );
683
+ })}
684
+ </ul>
685
+ ) : null}
686
+ </div>
687
+ );
688
+ }
689
+
690
+ // ─────────────────────────────────────────────────────────────────────────────
691
+ // CommentPin — single 24×24 badge anchored to top-right of its target element.
692
+ // Resolves target on every animation frame to track layout shifts (drag,
693
+ // reflow, font load). Falls back to the stored `bounds` when the target is
694
+ // gone from the DOM.
695
+
696
+ function CommentPin({
697
+ comment,
698
+ sequence,
699
+ focused,
700
+ onClick,
701
+ }: {
702
+ comment: OverlayComment;
703
+ sequence: number;
704
+ focused: boolean;
705
+ onClick: (id: string) => void;
706
+ }) {
707
+ const ref = useRef<HTMLButtonElement | null>(null);
708
+ const rafRef = useRef<number | null>(null);
709
+
710
+ useEffect(() => {
711
+ const tick = () => {
712
+ rafRef.current = null;
713
+ const pin = ref.current;
714
+ if (!pin) return;
715
+
716
+ // Live screen-coord lookup mirrors SelectionHalos in canvas-shell.tsx.
717
+ // Falls back to stored bounds (a screen-coord capture at create time)
718
+ // when the target element is gone — better than vanishing entirely.
719
+ let pos = screenRectFor(comment.selector);
720
+ if (!pos && comment.bounds) {
721
+ pos = {
722
+ x: comment.bounds.x,
723
+ y: comment.bounds.y,
724
+ w: comment.bounds.w,
725
+ h: comment.bounds.h,
726
+ };
727
+ }
728
+ if (!pos) {
729
+ pin.style.display = 'none';
730
+ rafRef.current = requestAnimationFrame(tick);
731
+ return;
732
+ }
733
+ pin.style.display = 'grid';
734
+ // Position the pin's center at (right - 12, top - 12) — the FigJam
735
+ // convention. 12 = half of 24 (the pin's own size).
736
+ const left = Math.round(pos.x + pos.w - 12);
737
+ const top = Math.round(pos.y - 12);
738
+ pin.style.left = `${left}px`;
739
+ pin.style.top = `${top}px`;
740
+ rafRef.current = requestAnimationFrame(tick);
741
+ };
742
+ rafRef.current = requestAnimationFrame(tick);
743
+ return () => {
744
+ if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
745
+ };
746
+ }, [comment.selector, comment.bounds]);
747
+
748
+ const author = comment.author?.trim() || 'unknown';
749
+ const label = `Comment ${sequence} by ${author}`;
750
+
751
+ return (
752
+ <button
753
+ ref={ref}
754
+ type="button"
755
+ className="cm-pin"
756
+ data-resolved={comment.status === 'resolved' ? 'true' : 'false'}
757
+ data-focused={focused ? 'true' : 'false'}
758
+ data-comment-pin={comment.id}
759
+ aria-label={label}
760
+ aria-expanded={focused}
761
+ title={comment.text.slice(0, 200)}
762
+ onClick={(e) => {
763
+ e.preventDefault();
764
+ e.stopPropagation();
765
+ onClick(comment.id);
766
+ }}
767
+ >
768
+ {sequence}
769
+ </button>
770
+ );
771
+ }
772
+
773
+ // ─────────────────────────────────────────────────────────────────────────────
774
+ // CommentComposer — DS-styled card anchored just under the clicked element
775
+ // (or at the click point if the click hit empty canvas). Edge-clamp is the
776
+ // pragmatic kind: position is computed once on open against the world layout,
777
+ // not chased on pan/zoom — the user is actively typing.
778
+
779
+ function CommentComposer({
780
+ state,
781
+ onSubmit,
782
+ onCancel,
783
+ }: {
784
+ state: ComposerState;
785
+ onSubmit: (text: string) => void;
786
+ onCancel: () => void;
787
+ }) {
788
+ const [text, setText] = useState('');
789
+ const textareaRef = useRef<HTMLTextAreaElement | null>(null);
790
+ const cardRef = useRef<HTMLDivElement | null>(null);
791
+ const rafRef = useRef<number | null>(null);
792
+
793
+ // Live anchor — composer tracks the target element via rAF so pan/zoom
794
+ // while typing keeps the card glued to its anchor. Writes directly to the
795
+ // DOM so we don't re-render every frame.
796
+ useEffect(() => {
797
+ const tick = () => {
798
+ rafRef.current = null;
799
+ const node = cardRef.current;
800
+ if (!node) return;
801
+ const anchor = computeAnchor(state);
802
+ node.style.left = `${Math.round(anchor.x)}px`;
803
+ node.style.top = `${Math.round(anchor.y)}px`;
804
+ rafRef.current = requestAnimationFrame(tick);
805
+ };
806
+ rafRef.current = requestAnimationFrame(tick);
807
+ return () => {
808
+ if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
809
+ };
810
+ }, [state]);
811
+
812
+ useEffect(() => {
813
+ textareaRef.current?.focus();
814
+ }, []);
815
+
816
+ const trySubmit = useCallback(() => {
817
+ const v = text.trim();
818
+ if (!v) return;
819
+ onSubmit(v);
820
+ }, [text, onSubmit]);
821
+
822
+ const onKeyDown = useCallback(
823
+ (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
824
+ if (e.key === 'Escape') {
825
+ e.preventDefault();
826
+ onCancel();
827
+ return;
828
+ }
829
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
830
+ e.preventDefault();
831
+ trySubmit();
832
+ }
833
+ },
834
+ [onCancel, trySubmit]
835
+ );
836
+
837
+ // Compact selector hint — strip noisy structural bits so the head stays
838
+ // tight inside the 300px card.
839
+ const selectorChip = useMemo(() => {
840
+ const s = state.selection.selector || '';
841
+ if (!s) return state.selection.tag || 'canvas';
842
+ // [data-cd-id="…"] → cd:<id> · keeps the chip readable when stable ids
843
+ // are present.
844
+ const cd = s.match(/data-cd-id="([^"]+)"/);
845
+ if (cd) return `cd:${cd[1]}`;
846
+ return s.length > 36 ? `${s.slice(0, 33)}…` : s;
847
+ }, [state.selection]);
848
+
849
+ return (
850
+ <div
851
+ ref={cardRef}
852
+ className="cm-composer"
853
+ role="dialog"
854
+ aria-label="New comment"
855
+ onClick={(e) => e.stopPropagation()}
856
+ onPointerDown={(e) => e.stopPropagation()}
857
+ >
858
+ <div className="cm-composer__head">
859
+ <span>New comment</span>
860
+ <span className="cm-composer__selector">{selectorChip}</span>
861
+ </div>
862
+ <MentionAwareTextarea
863
+ textareaRef={textareaRef}
864
+ className="cm-composer__textarea"
865
+ value={text}
866
+ placeholder="Type a comment. ⌘↵ to save · Esc to cancel · @name to tag"
867
+ onChange={setText}
868
+ onKeyDown={onKeyDown}
869
+ rows={3}
870
+ ariaLabel="Comment body"
871
+ />
872
+ <div className="cm-composer__actions">
873
+ <button type="button" className="cm-btn" onClick={onCancel}>
874
+ Cancel
875
+ </button>
876
+ <button
877
+ type="button"
878
+ className="cm-btn cm-btn--primary"
879
+ disabled={!text.trim()}
880
+ onClick={trySubmit}
881
+ >
882
+ Save
883
+ </button>
884
+ </div>
885
+ </div>
886
+ );
887
+ }
888
+
889
+ // ─────────────────────────────────────────────────────────────────────────────
890
+ // CommentThread — popover anchored to the focused pin. Shows author + relative
891
+ // time + selector chip, the original body with @mentions bolded, replies, a
892
+ // reply textarea, and resolve/reopen/delete actions. Patches + deletes route
893
+ // through the shell's existing WS channel via postMessage; replies POST
894
+ // directly to `/_api/comments/<id>/reply` because that endpoint exists only on
895
+ // Bun runtime and lives in `http.ts`.
896
+
897
+ function CommentThread({
898
+ comment,
899
+ onClose,
900
+ onPatch,
901
+ onDelete,
902
+ onReply,
903
+ }: {
904
+ comment: OverlayComment;
905
+ onClose: () => void;
906
+ onPatch: (patch: Record<string, unknown>) => void;
907
+ onDelete: () => void;
908
+ onReply: (body: string) => Promise<boolean>;
909
+ }) {
910
+ const dialogRef = useRef<HTMLDivElement | null>(null);
911
+ const replyRef = useRef<HTMLTextAreaElement | null>(null);
912
+ const rafRef = useRef<number | null>(null);
913
+ const [reply, setReply] = useState('');
914
+ const [sending, setSending] = useState(false);
915
+
916
+ // Live anchor — popover tracks the pin via rAF so it stays glued to its
917
+ // target through pan / zoom (FigJam parity). Writing to the dialog style
918
+ // directly avoids re-rendering every frame.
919
+ useEffect(() => {
920
+ const tick = () => {
921
+ rafRef.current = null;
922
+ const node = dialogRef.current;
923
+ if (!node) return;
924
+ const anchor = computeThreadAnchor(comment);
925
+ node.style.left = `${Math.round(anchor.x)}px`;
926
+ node.style.top = `${Math.round(anchor.y)}px`;
927
+ rafRef.current = requestAnimationFrame(tick);
928
+ };
929
+ rafRef.current = requestAnimationFrame(tick);
930
+ return () => {
931
+ if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
932
+ };
933
+ }, [comment]);
934
+
935
+ useEffect(() => {
936
+ // Move focus into the dialog on open. Per WCAG 2.1 the dialog should own
937
+ // initial focus; we put it on the dialog root (focusable via tabindex)
938
+ // so screen readers announce the header before the body. On close,
939
+ // return focus to the pin so keyboard users land where they started.
940
+ dialogRef.current?.focus();
941
+ const pinId = comment.id;
942
+ return () => {
943
+ const pin = document.querySelector<HTMLButtonElement>(`[data-comment-pin="${pinId}"]`);
944
+ pin?.focus();
945
+ };
946
+ }, [comment.id]);
947
+
948
+ // Esc-to-close while focus is anywhere inside the popover.
949
+ useEffect(() => {
950
+ if (typeof document === 'undefined') return;
951
+ const onKey = (e: KeyboardEvent) => {
952
+ if (e.key !== 'Escape') return;
953
+ const root = dialogRef.current;
954
+ if (!root) return;
955
+ if (root.contains(e.target as Node) || document.activeElement === root) {
956
+ e.preventDefault();
957
+ onClose();
958
+ }
959
+ };
960
+ document.addEventListener('keydown', onKey);
961
+ return () => document.removeEventListener('keydown', onKey);
962
+ }, [onClose]);
963
+
964
+ const trySendReply = useCallback(async () => {
965
+ const v = reply.trim();
966
+ if (!v || sending) return;
967
+ setSending(true);
968
+ const ok = await onReply(v);
969
+ setSending(false);
970
+ if (ok) {
971
+ setReply('');
972
+ replyRef.current?.focus();
973
+ }
974
+ }, [reply, sending, onReply]);
975
+
976
+ const onReplyKeyDown = useCallback(
977
+ (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
978
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
979
+ e.preventDefault();
980
+ void trySendReply();
981
+ }
982
+ },
983
+ [trySendReply]
984
+ );
985
+
986
+ const headId = `cm-thread-head-${comment.id}`;
987
+ const selectorChip = formatSelectorChip(comment.selector, '');
988
+
989
+ return (
990
+ <div
991
+ ref={dialogRef}
992
+ className="cm-thread"
993
+ role="dialog"
994
+ aria-labelledby={headId}
995
+ tabIndex={-1}
996
+ onClick={(e) => e.stopPropagation()}
997
+ onPointerDown={(e) => e.stopPropagation()}
998
+ >
999
+ <div className="cm-thread__head" id={headId}>
1000
+ <div className="cm-thread__head-row">
1001
+ <span className="cm-thread__author">{comment.author?.trim() || 'unknown'}</span>
1002
+ <span className="cm-thread__time">{formatRelativeTime(comment.created)}</span>
1003
+ <button
1004
+ type="button"
1005
+ className="cm-thread__close"
1006
+ aria-label="Close thread"
1007
+ title="Close · Esc"
1008
+ onClick={onClose}
1009
+ >
1010
+ ×
1011
+ </button>
1012
+ </div>
1013
+ {selectorChip ? <code className="cm-thread__selector">{selectorChip}</code> : null}
1014
+ </div>
1015
+
1016
+ <div className="cm-thread__body">{renderBodyWithMentions(comment.text)}</div>
1017
+
1018
+ {(comment.thread ?? []).map((r) => (
1019
+ <div className="cm-thread__reply" key={r.id}>
1020
+ <div className="cm-thread__reply-head">
1021
+ <span className="cm-thread__reply-author">{r.author?.trim() || 'unknown'}</span>
1022
+ <span className="cm-thread__reply-time">{formatRelativeTime(r.created)}</span>
1023
+ </div>
1024
+ <div className="cm-thread__reply-body">{renderBodyWithMentions(r.body)}</div>
1025
+ </div>
1026
+ ))}
1027
+
1028
+ <div className="cm-thread__reply-form">
1029
+ <MentionAwareTextarea
1030
+ textareaRef={replyRef}
1031
+ className="cm-thread__reply-textarea"
1032
+ value={reply}
1033
+ placeholder="Reply… ⌘↵ to send · @name to tag"
1034
+ onChange={setReply}
1035
+ onKeyDown={onReplyKeyDown}
1036
+ rows={2}
1037
+ ariaLabel="Reply"
1038
+ disabled={sending}
1039
+ />
1040
+ <div className="cm-thread__reply-actions">
1041
+ <button
1042
+ type="button"
1043
+ className="cm-btn cm-btn--primary"
1044
+ disabled={!reply.trim() || sending}
1045
+ onClick={() => void trySendReply()}
1046
+ >
1047
+ Send
1048
+ </button>
1049
+ </div>
1050
+ </div>
1051
+
1052
+ <div className="cm-thread__actions">
1053
+ {comment.status === 'resolved' ? (
1054
+ <button type="button" className="cm-btn" onClick={() => onPatch({ status: 'open' })}>
1055
+ ↺ Reopen
1056
+ </button>
1057
+ ) : (
1058
+ <button
1059
+ type="button"
1060
+ className="cm-btn cm-btn--primary"
1061
+ onClick={() => {
1062
+ onPatch({ status: 'resolved' });
1063
+ onClose();
1064
+ }}
1065
+ >
1066
+ ✓ Resolve
1067
+ </button>
1068
+ )}
1069
+ <button
1070
+ type="button"
1071
+ className="cm-btn cm-btn--danger"
1072
+ onClick={() => {
1073
+ onDelete();
1074
+ onClose();
1075
+ }}
1076
+ >
1077
+ Delete
1078
+ </button>
1079
+ </div>
1080
+ </div>
1081
+ );
1082
+ }
1083
+
1084
+ // ─────────────────────────────────────────────────────────────────────────────
1085
+ // Body renderer — splits text on @-handles, wraps each in <strong>. Anything
1086
+ // not matching the mention regex stays plain text (newlines preserved by CSS
1087
+ // `white-space: pre-wrap`).
1088
+
1089
+ function renderBodyWithMentions(text: string): React.ReactNode {
1090
+ if (!text) return null;
1091
+ const re = /(@[\w][\w.-]*)/g;
1092
+ const parts = text.split(re);
1093
+ return parts.map((part, i) => {
1094
+ // The split positions ARE the identity here — for the same `text` input,
1095
+ // index `i` always maps to the same fragment. Compose key from index +
1096
+ // content so biome's array-index-key heuristic is satisfied AND reorder
1097
+ // resistance is intact if `text` mutates mid-render.
1098
+ const key = `${i}:${part}`;
1099
+ if (i % 2 === 1) {
1100
+ // Odd parts are the captured @handles thanks to the parenthesized split.
1101
+ return (
1102
+ <strong key={key} data-mention="true">
1103
+ {part}
1104
+ </strong>
1105
+ );
1106
+ }
1107
+ return <span key={key}>{part}</span>;
1108
+ });
1109
+ }
1110
+
1111
+ function formatRelativeTime(iso: string): string {
1112
+ if (!iso) return '';
1113
+ const t = Date.parse(iso);
1114
+ if (!Number.isFinite(t)) return '';
1115
+ const diffSec = Math.round((Date.now() - t) / 1000);
1116
+ if (diffSec < 60) return `${Math.max(diffSec, 0)}s ago`;
1117
+ if (diffSec < 3600) return `${Math.round(diffSec / 60)}m ago`;
1118
+ if (diffSec < 86_400) return `${Math.round(diffSec / 3600)}h ago`;
1119
+ return `${Math.round(diffSec / 86_400)}d ago`;
1120
+ }
1121
+
1122
+ function formatSelectorChip(selector: string, fallback: string): string {
1123
+ if (!selector) return fallback;
1124
+ const cd = selector.match(/data-cd-id="([^"]+)"/);
1125
+ if (cd) return `cd:${cd[1]}`;
1126
+ return selector.length > 36 ? `${selector.slice(0, 33)}…` : selector;
1127
+ }
1128
+
1129
+ function computeThreadAnchor(comment: OverlayComment): { x: number; y: number } {
1130
+ // Resolve target's live screen rect; popover drops below the pin with small
1131
+ // breathing room. Stored bounds (capture-time screen coords) are the
1132
+ // last-resort fallback for orphaned pins.
1133
+ const rect = comment.selector ? screenRectFor(comment.selector) : null;
1134
+ if (rect) {
1135
+ // Pin sits at (rect.right - 12, rect.top - 12). Place popover at the same
1136
+ // x for visual continuity, 16px below the top so it clears the pin.
1137
+ return { x: rect.x + rect.w - 12, y: rect.y + 16 };
1138
+ }
1139
+ if (comment.bounds) {
1140
+ return { x: comment.bounds.x + comment.bounds.w - 12, y: comment.bounds.y + 16 };
1141
+ }
1142
+ return { x: 16, y: 16 };
1143
+ }
1144
+
1145
+ function computeAnchor(state: ComposerState): { x: number; y: number } {
1146
+ // Try the selected element first — its live screen rect gives the most
1147
+ // natural anchor (composer sits flush under the element the user clicked).
1148
+ if (state.selection.selector) {
1149
+ const rect = screenRectFor(state.selection.selector);
1150
+ if (rect) {
1151
+ return { x: rect.x, y: rect.y + rect.h + 8 };
1152
+ }
1153
+ }
1154
+ // Fall back to the raw click point — composer drops 8px below the cursor.
1155
+ return { x: state.clientX, y: state.clientY + 8 };
1156
+ }