@unhingged/vizu-core 0.1.15 → 0.1.17

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.
@@ -0,0 +1,2538 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/internal.ts
21
+ var internal_exports = {};
22
+ __export(internal_exports, {
23
+ Highlighter: () => Highlighter,
24
+ Pill: () => Pill,
25
+ Popover: () => Popover,
26
+ Sidebar: () => Sidebar,
27
+ injectStyles: () => injectStyles
28
+ });
29
+ module.exports = __toCommonJS(internal_exports);
30
+
31
+ // src/util.ts
32
+ function isInside(target, selector) {
33
+ return !!target.closest(selector);
34
+ }
35
+ function escapeHtml(s) {
36
+ if (s == null) return "";
37
+ return String(s).replace(/[&<>"']/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[ch]);
38
+ }
39
+ function formatTime(ts) {
40
+ const d = new Date(ts);
41
+ const now = Date.now();
42
+ const diff = now - ts;
43
+ if (diff < 6e4) return "just now";
44
+ if (diff < 36e5) return Math.floor(diff / 6e4) + "m ago";
45
+ if (diff < 864e5) return Math.floor(diff / 36e5) + "h ago";
46
+ return d.toLocaleDateString() + " " + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
47
+ }
48
+ function initials(name) {
49
+ if (!name) return "";
50
+ return String(name).split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0].toUpperCase()).join("");
51
+ }
52
+ function avatarHtml(user, className = "vz-comment-author-avatar") {
53
+ if (!user) return `<span class="${className}">\xB7</span>`;
54
+ const displayName = user.name || user.id || "User";
55
+ if (user.avatarUrl) {
56
+ return `<span class="${className}"><img src="${escapeHtml(user.avatarUrl)}" alt="${escapeHtml(displayName)}" /></span>`;
57
+ }
58
+ return `<span class="${className}">${escapeHtml(initials(displayName) || "?")}</span>`;
59
+ }
60
+ function refId() {
61
+ return Math.random().toString(36).slice(2, 10);
62
+ }
63
+ var MENTION_TOKEN_RE = /\[@([^\]]+)\]\(vizu-mention:([a-zA-Z0-9_-]+)\)/g;
64
+ var COMBINED_TOKEN_SPLIT_RE = /(\[#[^\]]+\]\(vizu-ref:[a-zA-Z0-9_-]+\)|\[@[^\]]+\]\(vizu-mention:[a-zA-Z0-9_-]+\))/g;
65
+ function renderCommentText(text, commentId) {
66
+ const parts = text.split(COMBINED_TOKEN_SPLIT_RE);
67
+ return parts.map((part) => {
68
+ const refMatch = part.match(/^\[#([^\]]+)\]\(vizu-ref:([a-zA-Z0-9_-]+)\)$/);
69
+ if (refMatch) {
70
+ return `<a class="vz-ref" data-vz="ref" data-comment-id="${escapeHtml(commentId)}" data-ref-id="${escapeHtml(refMatch[2])}" role="button" tabindex="0">${escapeHtml(refMatch[1])}</a>`;
71
+ }
72
+ const mentionMatch = part.match(/^\[@([^\]]+)\]\(vizu-mention:([a-zA-Z0-9_-]+)\)$/);
73
+ if (mentionMatch) {
74
+ return `<span class="vz-mention" data-vz="mention" data-mention-id="${escapeHtml(mentionMatch[2])}">@${escapeHtml(mentionMatch[1])}</span>`;
75
+ }
76
+ return escapeHtml(part);
77
+ }).join("");
78
+ }
79
+ function extractMentions(text) {
80
+ const out = [];
81
+ for (const m of text.matchAll(MENTION_TOKEN_RE)) {
82
+ if (!out.includes(m[2])) out.push(m[2]);
83
+ }
84
+ return out;
85
+ }
86
+ function renderAttachmentsHtml(attachments) {
87
+ if (!Array.isArray(attachments) || attachments.length === 0) return "";
88
+ const items = attachments.map((a) => {
89
+ const isImage = a.mimeType?.startsWith("image/");
90
+ const filename = a.filename ?? a.url.split("/").pop() ?? "file";
91
+ if (isImage) {
92
+ return `
93
+ <a class="vz-attachment-display" href="${escapeHtml(a.url)}" target="_blank" rel="noreferrer" title="${escapeHtml(filename)}">
94
+ <img src="${escapeHtml(a.url)}" alt="${escapeHtml(filename)}" loading="lazy" />
95
+ </a>
96
+ `;
97
+ }
98
+ return `
99
+ <a class="vz-attachment-display vz-attachment-file" href="${escapeHtml(a.url)}" target="_blank" rel="noreferrer">
100
+ <span class="vz-attachment-icon" aria-hidden="true">\u{1F4C4}</span>
101
+ <span class="vz-attachment-filename">${escapeHtml(filename)}</span>
102
+ </a>
103
+ `;
104
+ }).join("");
105
+ return `<div class="vz-attachment-grid">${items}</div>`;
106
+ }
107
+
108
+ // src/pill.ts
109
+ var Pill = class {
110
+ constructor(root, callbacks) {
111
+ this.sidebarOpen = false;
112
+ this.actions = [];
113
+ this.commentsCount = 0;
114
+ this.user = null;
115
+ this.multiSelectMode = false;
116
+ this.selectionCount = 0;
117
+ this.handleClick = (e) => {
118
+ const btn = e.target.closest("[data-vz]");
119
+ if (!btn) return;
120
+ const a = btn.getAttribute("data-vz");
121
+ if (a === "sidebar") this.callbacks.onToggleSidebar();
122
+ else if (a === "disable") this.callbacks.onDisable();
123
+ else if (a === "multi") this.callbacks.onToggleMultiSelect();
124
+ else if (a === "commit-selection") this.callbacks.onCommitSelection();
125
+ else if (a === "clear-selection") this.callbacks.onClearSelection();
126
+ else if (a === "action") {
127
+ const id = btn.getAttribute("data-id");
128
+ if (id) this.callbacks.onAction(id);
129
+ }
130
+ };
131
+ this.root = root;
132
+ this.callbacks = callbacks;
133
+ this.el = document.createElement("div");
134
+ this.el.className = "vz-pill";
135
+ this.el.style.display = "none";
136
+ this.el.addEventListener("click", this.handleClick);
137
+ this.root.appendChild(this.el);
138
+ }
139
+ setActions(actions) {
140
+ this.actions = actions;
141
+ this.render();
142
+ }
143
+ setComments(comments) {
144
+ this.commentsCount = comments.length;
145
+ this.render();
146
+ }
147
+ setUser(user) {
148
+ this.user = user;
149
+ this.render();
150
+ }
151
+ setSidebarOpen(open) {
152
+ this.sidebarOpen = open;
153
+ const btn = this.el.querySelector('[data-vz="sidebar"]');
154
+ if (btn) btn.classList.toggle("is-active", open);
155
+ }
156
+ setMultiSelectState(mode, selectionCount) {
157
+ this.multiSelectMode = mode;
158
+ this.selectionCount = selectionCount;
159
+ this.el.classList.toggle("is-selecting", mode && selectionCount > 0);
160
+ this.render();
161
+ }
162
+ show() {
163
+ this.el.style.display = "flex";
164
+ this.render();
165
+ }
166
+ hide() {
167
+ this.el.style.display = "none";
168
+ }
169
+ render() {
170
+ if (this.el.style.display === "none") return;
171
+ const visibleActions = this.actions.filter(
172
+ (a) => a.visibleWhen ? a.visibleWhen({ commentsCount: this.commentsCount }) : true
173
+ );
174
+ const userHtml = this.user ? `<div class="vz-pill-user" title="${escapeHtml(this.user.name)}">${avatarHtml(this.user, "vz-pill-user-avatar")} ${escapeHtml(this.user.name.split(" ")[0])}</div><span class="vz-pill-divider"></span>` : "";
175
+ const actionsHtml = visibleActions.length ? visibleActions.map(
176
+ (a) => `<button class="vz-pill-btn${a.variant === "primary" ? " primary" : ""}" data-vz="action" data-id="${escapeHtml(a.id)}"${a.title ? ` title="${escapeHtml(a.title)}"` : ""}>${escapeHtml(a.label)}</button>`
177
+ ).join("") + '<span class="vz-pill-divider"></span>' : "";
178
+ let middle = "";
179
+ if (this.multiSelectMode && this.selectionCount > 0) {
180
+ middle = `
181
+ <span class="vz-pill-count-btn is-active" title="Selected ${this.selectionCount} element${this.selectionCount === 1 ? "" : "s"}">${this.selectionCount} selected</span>
182
+ <button class="vz-pill-btn primary" data-vz="commit-selection">Comment on ${this.selectionCount} \u2192</button>
183
+ <button class="vz-pill-btn" data-vz="clear-selection">Cancel</button>
184
+ <span class="vz-pill-divider"></span>
185
+ `;
186
+ } else {
187
+ const count = this.commentsCount;
188
+ middle = `
189
+ <button class="vz-pill-count-btn ${this.sidebarOpen ? "is-active" : ""}" data-vz="sidebar" title="Show all comments">${count} comment${count === 1 ? "" : "s"}</button>
190
+ <span class="vz-pill-divider"></span>
191
+ ${userHtml}
192
+ ${actionsHtml}
193
+ `;
194
+ }
195
+ const toggleTitle = this.multiSelectMode ? "Exit multi-select mode" : "Multi-select: click multiple elements, then comment on the group (or hold Shift while clicking)";
196
+ this.el.innerHTML = `
197
+ <span class="vz-pill-dot" aria-hidden="true"></span>
198
+ <span class="vz-pill-label">Vizu</span>
199
+ ${middle}
200
+ <button class="vz-pill-toggle ${this.multiSelectMode ? "is-active" : ""}" data-vz="multi" title="${escapeHtml(toggleTitle)}" aria-label="${escapeHtml(toggleTitle)}">
201
+ <svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="1" y="1" width="5" height="5"/><rect x="8" y="1" width="5" height="5"/><rect x="1" y="8" width="5" height="5"/><rect x="8" y="8" width="5" height="5"/></svg>
202
+ </button>
203
+ <button class="vz-pill-icon-btn" data-vz="disable" title="Disable Vizu (toggle with shortcut)" aria-label="Disable">\xD7</button>
204
+ `;
205
+ }
206
+ toast(message) {
207
+ const t = document.createElement("div");
208
+ t.className = "vz-toast";
209
+ t.textContent = message;
210
+ this.root.appendChild(t);
211
+ setTimeout(() => t.remove(), 1800);
212
+ }
213
+ destroy() {
214
+ this.el.removeEventListener("click", this.handleClick);
215
+ this.el.remove();
216
+ }
217
+ };
218
+
219
+ // src/fingerprint.ts
220
+ function fingerprintKey(fp) {
221
+ return fp.selector + "|" + fp.textSnippet;
222
+ }
223
+ function fingerprintLabel(fp) {
224
+ const tag = fp.tagName.toLowerCase();
225
+ if (fp.textSnippet) {
226
+ const snippet = fp.textSnippet.length > 24 ? fp.textSnippet.slice(0, 24) + "\u2026" : fp.textSnippet;
227
+ return tag + ": " + snippet;
228
+ }
229
+ if (fp.attributes.id) return tag + "#" + fp.attributes.id;
230
+ if (fp.attributes.classList && fp.attributes.classList.length) return tag + "." + fp.attributes.classList[0];
231
+ return tag;
232
+ }
233
+
234
+ // src/popover.ts
235
+ function cssEscape(s) {
236
+ if (typeof CSS !== "undefined" && typeof CSS.escape === "function") return CSS.escape(s);
237
+ return s.replace(/["\\]/g, "\\$&");
238
+ }
239
+ function renderRepliesHtml(c) {
240
+ const replies = c.replies ?? [];
241
+ const items = replies.map(
242
+ (r) => `
243
+ <div class="vz-reply" data-reply-id="${escapeHtml(r.id)}">
244
+ ${r.author ? `<div class="vz-comment-author">${avatarHtml(r.author)} <span class="vz-comment-author-name">${escapeHtml(r.author.name || r.author.id || "User")}</span></div>` : ""}
245
+ <div class="vz-reply-text">${escapeHtml(r.text)}</div>
246
+ <div class="vz-comment-meta">
247
+ <span>${escapeHtml(formatTime(typeof r.createdAt === "number" ? r.createdAt : new Date(r.createdAt).getTime()))}</span>
248
+ <button class="vz-comment-delete" data-vz="delete-reply" data-comment-id="${escapeHtml(c.id)}" data-reply-id="${escapeHtml(r.id)}">delete</button>
249
+ </div>
250
+ </div>
251
+ `
252
+ ).join("");
253
+ return `
254
+ <div class="vz-replies" data-comment-id="${escapeHtml(c.id)}" hidden>
255
+ <div class="vz-reply-list">${items}</div>
256
+ <div class="vz-reply-form">
257
+ <textarea class="vz-reply-input" placeholder="Reply\u2026" rows="2" data-comment-id="${escapeHtml(c.id)}"></textarea>
258
+ <button class="vz-btn vz-btn-primary vz-btn-reply" data-vz="send-reply" data-id="${escapeHtml(c.id)}">Send</button>
259
+ </div>
260
+ </div>
261
+ `;
262
+ }
263
+ var Popover = class {
264
+ constructor(root, callbacks) {
265
+ this.currentUser = null;
266
+ /** All elements this in-progress comment is anchored to. First one positions the popover. */
267
+ this.anchorTargets = [];
268
+ this.anchorFingerprints = [];
269
+ /** Refs the user inserted via # picker during this session, keyed by refId. */
270
+ this.pendingRefs = {};
271
+ /** Attachments uploaded during this session, persisted into `comment.attachments` on save. */
272
+ this.pendingAttachments = [];
273
+ /** Open mention picker element, if any. Held so the next openMentionPicker / close calls can tear it down. */
274
+ this.mentionPicker = null;
275
+ /** Dismiss handlers wired to document for the open picker; cleared in closeMentionPicker. */
276
+ this.mentionPickerDismiss = null;
277
+ /** All mentionable users fetched for the open picker — pre-filter list. */
278
+ this.mentionPickerUsers = [];
279
+ /** Currently visible subset of `mentionPickerUsers` after the user's filter. */
280
+ this.mentionPickerFiltered = [];
281
+ /** Highlighted index in `mentionPickerFiltered`. Up/Down arrows mutate this. */
282
+ this.mentionPickerSelected = 0;
283
+ /**
284
+ * 'manual' = opened from the @ mention button (separate search input).
285
+ * 'editor' = opened by typing `@` in the editor; filter is derived
286
+ * from the text after `@` in the editor itself, no search input.
287
+ */
288
+ this.mentionPickerMode = "manual";
289
+ /**
290
+ * In editor mode: a Range covering `@<query>` in the editor's text.
291
+ * Used to replace that span with the mention chip on commit, and to
292
+ * recompute the bounding rect when re-positioning after typing.
293
+ */
294
+ this.mentionPickerEditorRange = null;
295
+ this.handleEditorKey = (e) => {
296
+ const editor = e.currentTarget;
297
+ if (this.mentionPicker && this.mentionPickerMode === "editor") {
298
+ if (e.key === "ArrowDown") {
299
+ e.preventDefault();
300
+ this.moveMentionPickerSelection(1);
301
+ return;
302
+ }
303
+ if (e.key === "ArrowUp") {
304
+ e.preventDefault();
305
+ this.moveMentionPickerSelection(-1);
306
+ return;
307
+ }
308
+ if (e.key === "Enter" || e.key === "Tab") {
309
+ if (this.mentionPickerFiltered.length > 0) {
310
+ e.preventDefault();
311
+ this.commitMentionPickerSelection();
312
+ return;
313
+ }
314
+ }
315
+ if (e.key === "Escape") {
316
+ e.preventDefault();
317
+ this.closeMentionPicker();
318
+ return;
319
+ }
320
+ }
321
+ if (e.key === "#") {
322
+ e.preventDefault();
323
+ const savedRange = this.saveRange();
324
+ this.callbacks.onStartReferencePick(
325
+ (fp) => this.insertChip(fp, savedRange),
326
+ () => {
327
+ editor.focus();
328
+ this.restoreRange(savedRange);
329
+ }
330
+ );
331
+ return;
332
+ }
333
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
334
+ e.preventDefault();
335
+ this.save();
336
+ return;
337
+ }
338
+ if (e.key === "Escape") {
339
+ e.preventDefault();
340
+ this.callbacks.onClose();
341
+ }
342
+ };
343
+ /**
344
+ * Fires after every keystroke in the editor (post-insertion). Looks
345
+ * for an `@<query>` pattern adjacent to the caret. If found, opens
346
+ * (or refreshes) the mention picker in editor mode. If the trigger
347
+ * disappears (user typed a space, backspaced over @, etc.), closes
348
+ * the picker.
349
+ */
350
+ this.handleEditorInput = () => {
351
+ const trigger = this.detectMentionTrigger();
352
+ if (trigger) {
353
+ void this.openOrUpdateEditorMentionPicker(trigger.range, trigger.query);
354
+ } else if (this.mentionPicker && this.mentionPickerMode === "editor") {
355
+ this.closeMentionPicker();
356
+ }
357
+ };
358
+ this.handleEditorPaste = (e) => {
359
+ if (this.callbacks.canUploadAttachments()) {
360
+ const items = e.clipboardData?.items ?? null;
361
+ if (items) {
362
+ const imageFiles = [];
363
+ for (let i = 0; i < items.length; i++) {
364
+ const it = items[i];
365
+ if (it.kind === "file" && it.type.startsWith("image/")) {
366
+ const f = it.getAsFile();
367
+ if (f) imageFiles.push(f);
368
+ }
369
+ }
370
+ if (imageFiles.length > 0) {
371
+ e.preventDefault();
372
+ for (const f of imageFiles) void this.uploadAndAppend(f);
373
+ return;
374
+ }
375
+ }
376
+ }
377
+ e.preventDefault();
378
+ const text = e.clipboardData?.getData("text/plain") ?? "";
379
+ const sel = window.getSelection();
380
+ if (sel && sel.rangeCount > 0) {
381
+ const r = sel.getRangeAt(0);
382
+ r.deleteContents();
383
+ r.insertNode(document.createTextNode(text));
384
+ r.collapse(false);
385
+ sel.removeAllRanges();
386
+ sel.addRange(r);
387
+ }
388
+ };
389
+ /* ─── Attachment handlers ────────────────────────────────────────── */
390
+ this.handleDragOver = (e) => {
391
+ if (!e.dataTransfer || !Array.from(e.dataTransfer.types).includes("Files")) return;
392
+ e.preventDefault();
393
+ const hint = this.el.querySelector('[data-vz="drop-hint"]');
394
+ hint?.removeAttribute("hidden");
395
+ };
396
+ this.handleDragLeave = (e) => {
397
+ const wrap = e.currentTarget;
398
+ if (e.relatedTarget instanceof Node && wrap.contains(e.relatedTarget)) return;
399
+ const hint = this.el.querySelector('[data-vz="drop-hint"]');
400
+ hint?.setAttribute("hidden", "");
401
+ };
402
+ this.handleDrop = (e) => {
403
+ e.preventDefault();
404
+ const hint = this.el.querySelector('[data-vz="drop-hint"]');
405
+ hint?.setAttribute("hidden", "");
406
+ if (!e.dataTransfer) return;
407
+ const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
408
+ for (const f of files) void this.uploadAndAppend(f);
409
+ };
410
+ this.handleFileChange = (e) => {
411
+ const input = e.currentTarget;
412
+ const files = input.files ? Array.from(input.files) : [];
413
+ for (const f of files) void this.uploadAndAppend(f);
414
+ input.value = "";
415
+ };
416
+ this.handleClick = (e) => {
417
+ const target = e.target;
418
+ const removeBtn = target.closest('[data-vz="remove-anchor"]');
419
+ if (removeBtn) {
420
+ e.stopPropagation();
421
+ const key = removeBtn.getAttribute("data-fp-key");
422
+ if (key) this.removeAnchor(key);
423
+ return;
424
+ }
425
+ const refEl = target.closest('[data-vz="ref"]');
426
+ if (refEl) {
427
+ const commentId = refEl.getAttribute("data-comment-id");
428
+ const refIdAttr = refEl.getAttribute("data-ref-id");
429
+ if (commentId && refIdAttr) {
430
+ e.stopPropagation();
431
+ this.callbacks.onJumpToReference(commentId, refIdAttr);
432
+ }
433
+ return;
434
+ }
435
+ const action = target.getAttribute("data-vz");
436
+ if (action === "close") this.callbacks.onClose();
437
+ else if (action === "save") this.save();
438
+ else if (action === "mention") {
439
+ e.preventDefault();
440
+ this.openMentionPicker(target);
441
+ } else if (action === "upload") {
442
+ e.preventDefault();
443
+ const input = this.el.querySelector('[data-vz="file-input"]');
444
+ input?.click();
445
+ } else if (action === "remove-attachment") {
446
+ e.preventDefault();
447
+ const id = target.getAttribute("data-attachment-id");
448
+ if (id) {
449
+ this.pendingAttachments = this.pendingAttachments.filter((a) => a.id !== id);
450
+ this.renderAttachmentList();
451
+ }
452
+ } else if (action === "delete") {
453
+ const id = target.getAttribute("data-id");
454
+ if (id) this.callbacks.onDelete(id);
455
+ } else if (action === "toggle-reply") {
456
+ e.preventDefault();
457
+ const id = target.getAttribute("data-id");
458
+ if (!id) return;
459
+ const block = this.el.querySelector(`.vz-replies[data-comment-id="${cssEscape(id)}"]`);
460
+ if (!block) return;
461
+ const isHidden = block.hasAttribute("hidden");
462
+ if (isHidden) {
463
+ block.removeAttribute("hidden");
464
+ const input = block.querySelector(".vz-reply-input");
465
+ input?.focus();
466
+ } else {
467
+ block.setAttribute("hidden", "");
468
+ }
469
+ } else if (action === "send-reply") {
470
+ e.preventDefault();
471
+ const id = target.getAttribute("data-id");
472
+ if (!id) return;
473
+ const input = this.el.querySelector(
474
+ `.vz-reply-input[data-comment-id="${cssEscape(id)}"]`
475
+ );
476
+ const text = input?.value.trim() ?? "";
477
+ if (!text) return;
478
+ this.callbacks.onAddReply(id, text);
479
+ if (input) input.value = "";
480
+ } else if (action === "delete-reply") {
481
+ e.preventDefault();
482
+ const commentId = target.getAttribute("data-comment-id");
483
+ const replyId = target.getAttribute("data-reply-id");
484
+ if (commentId && replyId) this.callbacks.onDeleteReply(commentId, replyId);
485
+ }
486
+ };
487
+ this.root = root;
488
+ this.callbacks = callbacks;
489
+ this.el = document.createElement("div");
490
+ this.el.className = "vz-popover";
491
+ this.el.style.display = "none";
492
+ this.el.addEventListener("click", this.handleClick);
493
+ this.root.appendChild(this.el);
494
+ }
495
+ setUser(user) {
496
+ this.currentUser = user;
497
+ }
498
+ /**
499
+ * Open the popover anchored to one OR more elements. The popover positions
500
+ * itself relative to `targets[0]` but commits with every fingerprint in `fingerprints`.
501
+ */
502
+ open(targets, fingerprints, existingComments) {
503
+ this.anchorTargets = [...targets];
504
+ this.anchorFingerprints = [...fingerprints];
505
+ this.pendingRefs = {};
506
+ this.render(existingComments);
507
+ this.position();
508
+ }
509
+ /** Refresh the rendered comment list (called by host when comments change). */
510
+ update(existingComments) {
511
+ if (!this.anchorTargets.length) return;
512
+ this.render(existingComments);
513
+ this.position();
514
+ }
515
+ /** Add another anchor to the in-progress comment (Shift+Click flow). */
516
+ addAnchor(fp, target) {
517
+ const key = fingerprintKey(fp);
518
+ if (this.anchorFingerprints.some((x) => fingerprintKey(x) === key)) return false;
519
+ this.anchorFingerprints.push(fp);
520
+ if (target) this.anchorTargets.push(target);
521
+ this.callbacks.onAnchorsChanged([...this.anchorFingerprints]);
522
+ this.renderAnchorList();
523
+ this.updateHeader();
524
+ return true;
525
+ }
526
+ /** Remove an anchor by fingerprint key. Never removes the last anchor. */
527
+ removeAnchor(key) {
528
+ if (this.anchorFingerprints.length <= 1) return false;
529
+ const idx = this.anchorFingerprints.findIndex((x) => fingerprintKey(x) === key);
530
+ if (idx < 0) return false;
531
+ this.anchorFingerprints.splice(idx, 1);
532
+ this.anchorTargets.splice(idx, 1);
533
+ this.callbacks.onAnchorsChanged([...this.anchorFingerprints]);
534
+ this.renderAnchorList();
535
+ this.updateHeader();
536
+ return true;
537
+ }
538
+ updateHeader() {
539
+ const header = this.el.querySelector(".vz-popover-header span:first-child");
540
+ if (!header) return;
541
+ header.textContent = this.anchorFingerprints.length > 1 ? `Comment on ${this.anchorFingerprints.length} elements` : "Comment";
542
+ }
543
+ /** Recompute viewport position; called by host on scroll/resize. */
544
+ reposition() {
545
+ if (!this.anchorTargets.length) return;
546
+ this.position();
547
+ }
548
+ close() {
549
+ this.closeMentionPicker();
550
+ this.el.style.display = "none";
551
+ this.anchorTargets = [];
552
+ this.anchorFingerprints = [];
553
+ this.pendingRefs = {};
554
+ }
555
+ isOpen() {
556
+ return this.anchorTargets.length > 0;
557
+ }
558
+ getAnchors() {
559
+ return [...this.anchorFingerprints];
560
+ }
561
+ getAnchorTargets() {
562
+ return [...this.anchorTargets];
563
+ }
564
+ render(comments) {
565
+ if (!this.anchorTargets.length) return;
566
+ const primary = this.anchorTargets[0];
567
+ const cls = Array.from(primary.classList).filter((c) => !c.startsWith("vz-") && !c.startsWith("vizu-")).slice(0, 2);
568
+ const label = primary.tagName.toLowerCase() + (primary.id ? "#" + primary.id : "") + (cls.length ? "." + cls.join(".") : "");
569
+ const text = (primary.innerText || "").trim();
570
+ const textPreview = text.slice(0, 60);
571
+ const authoringLine = this.currentUser ? `<div class="vz-comment-author">${avatarHtml(this.currentUser)} <span class="vz-comment-author-name">${escapeHtml(this.currentUser.name)}</span> <span>\xB7 you</span></div>` : "";
572
+ this.el.innerHTML = `
573
+ <div class="vz-popover-header">
574
+ <span>${this.anchorFingerprints.length > 1 ? `Comment on ${this.anchorFingerprints.length} elements` : "Comment"}</span>
575
+ <button class="vz-popover-close" data-vz="close" aria-label="Close">\xD7</button>
576
+ </div>
577
+ <div class="vz-target-label">${escapeHtml(label)}${textPreview ? " \xB7 &ldquo;" + escapeHtml(textPreview) + (text.length > 60 ? "\u2026" : "") + "&rdquo;" : ""}</div>
578
+ <div class="vz-anchor-list-wrap"></div>
579
+ <div class="vz-comment-list">
580
+ ${comments.length === 0 ? '<div class="vz-empty">No comments yet.</div>' : comments.map(
581
+ (c) => `
582
+ <div class="vz-comment" data-comment-id="${escapeHtml(c.id)}">
583
+ ${c.author ? `<div class="vz-comment-author">${avatarHtml(c.author)} <span class="vz-comment-author-name">${escapeHtml(c.author.name || c.author.id || "User")}</span></div>` : ""}
584
+ <div>${renderCommentText(c.text, c.id)}</div>
585
+ ${renderAttachmentsHtml(c.attachments)}
586
+ <div class="vz-comment-meta">
587
+ <span>${escapeHtml(formatTime(c.createdAt))}</span>
588
+ <span class="vz-comment-actions">
589
+ <button class="vz-comment-reply-toggle" data-vz="toggle-reply" data-id="${escapeHtml(c.id)}">${(c.replies?.length ?? 0) > 0 ? `${c.replies.length} ${c.replies.length === 1 ? "reply" : "replies"}` : "reply"}</button>
590
+ <button class="vz-comment-delete" data-vz="delete" data-id="${escapeHtml(c.id)}">delete</button>
591
+ </span>
592
+ </div>
593
+ ${renderRepliesHtml(c)}
594
+ </div>
595
+ `
596
+ ).join("")}
597
+ </div>
598
+ ${authoringLine}
599
+ <div class="vz-editor-wrap">
600
+ <div class="vz-textarea" contenteditable="true" data-placeholder="Leave a comment\u2026 # to reference. \u2318/Ctrl+Enter to save. Shift+Click another element to anchor to it too."></div>
601
+ <div class="vz-attachment-drop-hint" data-vz="drop-hint" hidden>Drop image to attach</div>
602
+ </div>
603
+ <div class="vz-attachment-list" data-vz="attachment-list"></div>
604
+ <div class="vz-popover-actions">
605
+ <button class="vz-btn vz-btn-ghost" data-vz="close">Close</button>
606
+ ${this.callbacks.canUploadAttachments() ? `<button class="vz-btn vz-btn-ghost vz-btn-upload" data-vz="upload" title="Attach an image">+ image</button>` : ""}
607
+ ${this.currentUser ? `<button class="vz-btn vz-btn-ghost vz-btn-mention" data-vz="mention" title="Mention a teammate">@ mention</button>` : ""}
608
+ <button class="vz-btn vz-btn-primary" data-vz="save">Save</button>
609
+ </div>
610
+ <input type="file" class="vz-file-input" data-vz="file-input" accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml" hidden />
611
+ `;
612
+ this.el.style.display = "flex";
613
+ this.renderAnchorList();
614
+ this.renderAttachmentList();
615
+ const editor = this.el.querySelector(".vz-textarea");
616
+ editor.focus();
617
+ editor.addEventListener("keydown", this.handleEditorKey);
618
+ editor.addEventListener("input", this.handleEditorInput);
619
+ editor.addEventListener("paste", this.handleEditorPaste);
620
+ const wrap = this.el.querySelector(".vz-editor-wrap");
621
+ if (wrap && this.callbacks.canUploadAttachments()) {
622
+ wrap.addEventListener("dragover", this.handleDragOver);
623
+ wrap.addEventListener("dragleave", this.handleDragLeave);
624
+ wrap.addEventListener("drop", this.handleDrop);
625
+ }
626
+ const fileInput = this.el.querySelector('[data-vz="file-input"]');
627
+ if (fileInput) {
628
+ fileInput.addEventListener("change", this.handleFileChange);
629
+ }
630
+ }
631
+ renderAnchorList() {
632
+ const wrap = this.el.querySelector(".vz-anchor-list-wrap");
633
+ if (!wrap) return;
634
+ if (this.anchorFingerprints.length <= 1) {
635
+ wrap.innerHTML = "";
636
+ return;
637
+ }
638
+ const chips = this.anchorFingerprints.map((fp) => {
639
+ const key = fingerprintKey(fp);
640
+ const label = fingerprintLabel(fp);
641
+ return `<span class="vz-anchor-chip" data-fp-key="${escapeHtml(key)}">${escapeHtml(label)}<button class="vz-anchor-chip-remove" data-vz="remove-anchor" data-fp-key="${escapeHtml(key)}" aria-label="Remove anchor">\xD7</button></span>`;
642
+ }).join("");
643
+ wrap.innerHTML = `
644
+ <div class="vz-anchor-hint">Anchored to ${this.anchorFingerprints.length} elements:</div>
645
+ <div class="vz-anchor-list">${chips}</div>
646
+ `;
647
+ }
648
+ /**
649
+ * Walk back from the caret looking for the most recent `@` that
650
+ * starts a mention trigger — must be at start-of-text-node or
651
+ * preceded by whitespace, and there must be no whitespace between
652
+ * `@` and the caret. Returns the Range covering `@<query>` plus
653
+ * the bare query string.
654
+ */
655
+ detectMentionTrigger() {
656
+ const sel = window.getSelection();
657
+ if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null;
658
+ const caret = sel.getRangeAt(0);
659
+ const node = caret.startContainer;
660
+ if (node.nodeType !== Node.TEXT_NODE) return null;
661
+ const text = node.textContent ?? "";
662
+ const offset = caret.startOffset;
663
+ for (let i = offset - 1; i >= 0; i--) {
664
+ const ch = text[i];
665
+ if (ch === "@") {
666
+ const prev = i > 0 ? text[i - 1] : "";
667
+ if (prev !== "" && !/\s/.test(prev)) return null;
668
+ const query = text.slice(i + 1, offset);
669
+ if (/\s/.test(query)) return null;
670
+ const range = document.createRange();
671
+ range.setStart(node, i);
672
+ range.setEnd(node, offset);
673
+ return { range, query };
674
+ }
675
+ if (/\s/.test(ch)) return null;
676
+ }
677
+ return null;
678
+ }
679
+ async uploadAndAppend(file) {
680
+ if (!this.callbacks.canUploadAttachments()) return;
681
+ const placeholderId = "upload-" + Math.random().toString(36).slice(2, 9);
682
+ const placeholder = {
683
+ id: placeholderId,
684
+ url: "",
685
+ mimeType: file.type || "application/octet-stream",
686
+ sizeBytes: file.size,
687
+ uploadedAt: Date.now(),
688
+ filename: file.name
689
+ };
690
+ this.pendingAttachments.push(placeholder);
691
+ this.renderAttachmentList(placeholderId);
692
+ try {
693
+ const att = await this.callbacks.onUploadAttachment(file);
694
+ const idx = this.pendingAttachments.findIndex((a) => a.id === placeholderId);
695
+ if (idx >= 0) this.pendingAttachments[idx] = att;
696
+ else this.pendingAttachments.push(att);
697
+ this.renderAttachmentList();
698
+ } catch (err) {
699
+ this.pendingAttachments = this.pendingAttachments.filter((a) => a.id !== placeholderId);
700
+ this.renderAttachmentList();
701
+ if (typeof console !== "undefined") {
702
+ console.warn("[vizu] attachment upload failed", err);
703
+ }
704
+ }
705
+ }
706
+ renderAttachmentList(uploadingId) {
707
+ const list = this.el.querySelector('[data-vz="attachment-list"]');
708
+ if (!list) return;
709
+ if (this.pendingAttachments.length === 0) {
710
+ list.innerHTML = "";
711
+ return;
712
+ }
713
+ list.innerHTML = this.pendingAttachments.map((a) => {
714
+ const isUploading = a.id === uploadingId || a.url === "";
715
+ const preview = isUploading ? `<div class="vz-attachment-thumb vz-attachment-thumb-loading">\u2026</div>` : a.mimeType.startsWith("image/") ? `<img class="vz-attachment-thumb" src="${escapeHtml(a.url)}" alt="${escapeHtml(a.filename ?? "")}" />` : `<div class="vz-attachment-thumb">\u{1F4C4}</div>`;
716
+ return `
717
+ <div class="vz-attachment-item" data-attachment-id="${escapeHtml(a.id)}">
718
+ ${preview}
719
+ <button class="vz-attachment-remove" data-vz="remove-attachment" data-attachment-id="${escapeHtml(a.id)}" aria-label="Remove attachment">\xD7</button>
720
+ </div>
721
+ `;
722
+ }).join("");
723
+ }
724
+ saveRange() {
725
+ const sel = window.getSelection();
726
+ if (!sel || sel.rangeCount === 0) return null;
727
+ return sel.getRangeAt(0).cloneRange();
728
+ }
729
+ restoreRange(r) {
730
+ if (!r) return;
731
+ const sel = window.getSelection();
732
+ sel?.removeAllRanges();
733
+ sel?.addRange(r);
734
+ }
735
+ insertChip(fp, range) {
736
+ const rid = refId();
737
+ this.pendingRefs[rid] = fp;
738
+ const label = fingerprintLabel(fp);
739
+ const chip = document.createElement("span");
740
+ chip.className = "vz-chip";
741
+ chip.contentEditable = "false";
742
+ chip.setAttribute("data-ref-id", rid);
743
+ chip.textContent = label;
744
+ const editor = this.el.querySelector(".vz-textarea");
745
+ editor.focus();
746
+ this.restoreRange(range);
747
+ const sel = window.getSelection();
748
+ if (!sel || sel.rangeCount === 0) {
749
+ editor.appendChild(chip);
750
+ editor.appendChild(document.createTextNode(" "));
751
+ this.moveCursorToEnd(editor);
752
+ return;
753
+ }
754
+ const r = sel.getRangeAt(0);
755
+ r.deleteContents();
756
+ r.insertNode(chip);
757
+ const space = document.createTextNode(" ");
758
+ chip.after(space);
759
+ const newRange = document.createRange();
760
+ newRange.setStartAfter(space);
761
+ newRange.collapse(true);
762
+ sel.removeAllRanges();
763
+ sel.addRange(newRange);
764
+ }
765
+ moveCursorToEnd(el) {
766
+ const range = document.createRange();
767
+ range.selectNodeContents(el);
768
+ range.collapse(false);
769
+ const sel = window.getSelection();
770
+ sel?.removeAllRanges();
771
+ sel?.addRange(range);
772
+ }
773
+ /** Walk the editor and produce the markdown-ish string with ref tokens. */
774
+ serializeEditor(editor) {
775
+ const parts = [];
776
+ const walk = (node) => {
777
+ if (node.nodeType === Node.TEXT_NODE) {
778
+ parts.push(node.textContent || "");
779
+ return;
780
+ }
781
+ if (node.nodeType !== Node.ELEMENT_NODE) return;
782
+ const el = node;
783
+ if (el.classList.contains("vz-chip")) {
784
+ const mid = el.getAttribute("data-mention-id");
785
+ if (mid) {
786
+ const raw = el.textContent || "";
787
+ const name = raw.startsWith("@") ? raw.slice(1) : raw;
788
+ parts.push(`[@${name}](vizu-mention:${mid})`);
789
+ return;
790
+ }
791
+ const rid = el.getAttribute("data-ref-id");
792
+ const label = el.textContent || "";
793
+ if (rid) parts.push(`[#${label}](vizu-ref:${rid})`);
794
+ return;
795
+ }
796
+ if (el.tagName === "BR") {
797
+ parts.push("\n");
798
+ return;
799
+ }
800
+ if (el.tagName === "DIV" && parts.length && !parts[parts.length - 1].endsWith("\n")) {
801
+ parts.push("\n");
802
+ }
803
+ for (const child of el.childNodes) walk(child);
804
+ };
805
+ for (const child of editor.childNodes) walk(child);
806
+ return parts.join("").trim();
807
+ }
808
+ /**
809
+ * Manual-mode picker: opens from the @ mention button. Renders a
810
+ * search input above the list; the user types into the input to
811
+ * filter, arrows + Enter to pick. The selection inserts a mention
812
+ * chip at the editor's current caret (no `@<query>` replacement —
813
+ * the user didn't type one).
814
+ */
815
+ async openMentionPicker(anchor) {
816
+ this.closeMentionPicker();
817
+ if (!this.callbacks.onMentionSearch) return;
818
+ this.mentionPickerMode = "manual";
819
+ this.mentionPickerEditorRange = null;
820
+ const picker = this.createMentionPickerShell();
821
+ this.positionMentionPickerByElement(picker, anchor);
822
+ const users = await this.fetchMentionables();
823
+ if (this.mentionPicker !== picker) return;
824
+ this.mentionPickerUsers = users;
825
+ this.renderMentionPickerBody("");
826
+ this.positionMentionPickerByElement(picker, anchor);
827
+ const input = picker.querySelector(".vz-mention-search");
828
+ input?.focus();
829
+ this.wireMentionPickerDismiss();
830
+ }
831
+ /**
832
+ * Editor-mode picker: opens because the user typed `@` in the
833
+ * editor. No search input — the editor IS the search; the query is
834
+ * the text after `@`. On commit, replaces the `@<query>` range with
835
+ * the chip.
836
+ */
837
+ async openOrUpdateEditorMentionPicker(range, query) {
838
+ this.mentionPickerEditorRange = range;
839
+ if (!this.mentionPicker || this.mentionPickerMode !== "editor") {
840
+ this.closeMentionPicker();
841
+ this.mentionPickerMode = "editor";
842
+ const picker = this.createMentionPickerShell({ showSearch: false });
843
+ this.positionMentionPickerByRange(picker, range);
844
+ const users = await this.fetchMentionables();
845
+ if (this.mentionPicker !== picker) return;
846
+ this.mentionPickerUsers = users;
847
+ this.renderMentionPickerBody(query);
848
+ this.positionMentionPickerByRange(picker, range);
849
+ this.wireMentionPickerDismiss();
850
+ return;
851
+ }
852
+ this.renderMentionPickerBody(query);
853
+ this.positionMentionPickerByRange(this.mentionPicker, range);
854
+ }
855
+ /**
856
+ * Build the picker DOM scaffold and attach it to the popover. Does
857
+ * NOT fetch users or render the body — caller's responsibility.
858
+ * Search input is hidden in editor mode where the user already has
859
+ * a perfectly good text-entry surface (the editor itself).
860
+ */
861
+ createMentionPickerShell({ showSearch = true } = {}) {
862
+ const picker = document.createElement("div");
863
+ picker.className = "vz-mention-picker";
864
+ picker.setAttribute("data-vz-ignore", "");
865
+ picker.innerHTML = `
866
+ ${showSearch ? `<input class="vz-mention-search" type="text" placeholder="Search teammates\u2026" autocomplete="off" spellcheck="false" />` : ""}
867
+ <div class="vz-mention-list" data-vz="mention-list">
868
+ <div class="vz-mention-loading">Loading\u2026</div>
869
+ </div>
870
+ `;
871
+ this.el.appendChild(picker);
872
+ this.mentionPicker = picker;
873
+ this.mentionPickerFiltered = [];
874
+ this.mentionPickerSelected = 0;
875
+ picker.addEventListener("mousemove", (e) => {
876
+ const btn = e.target?.closest(".vz-mention-item");
877
+ if (!btn) return;
878
+ const idx = Number(btn.getAttribute("data-mention-index") ?? -1);
879
+ if (idx >= 0 && idx !== this.mentionPickerSelected) {
880
+ this.mentionPickerSelected = idx;
881
+ this.refreshMentionPickerHighlight();
882
+ }
883
+ });
884
+ picker.addEventListener("click", (e) => {
885
+ const btn = e.target?.closest(".vz-mention-item");
886
+ if (!btn) return;
887
+ e.preventDefault();
888
+ e.stopPropagation();
889
+ const idx = Number(btn.getAttribute("data-mention-index") ?? -1);
890
+ if (idx >= 0) {
891
+ this.mentionPickerSelected = idx;
892
+ this.commitMentionPickerSelection();
893
+ }
894
+ });
895
+ if (showSearch) {
896
+ const input = picker.querySelector(".vz-mention-search");
897
+ input?.addEventListener("input", () => this.renderMentionPickerBody(input.value));
898
+ input?.addEventListener("keydown", (e) => {
899
+ if (e.key === "ArrowDown") {
900
+ e.preventDefault();
901
+ this.moveMentionPickerSelection(1);
902
+ } else if (e.key === "ArrowUp") {
903
+ e.preventDefault();
904
+ this.moveMentionPickerSelection(-1);
905
+ } else if (e.key === "Enter" || e.key === "Tab") {
906
+ if (this.mentionPickerFiltered.length > 0) {
907
+ e.preventDefault();
908
+ this.commitMentionPickerSelection();
909
+ }
910
+ } else if (e.key === "Escape") {
911
+ e.preventDefault();
912
+ this.closeMentionPicker();
913
+ }
914
+ });
915
+ }
916
+ return picker;
917
+ }
918
+ /** Fetch the mentionable list. Swallows errors → []. */
919
+ async fetchMentionables() {
920
+ if (!this.callbacks.onMentionSearch) return [];
921
+ try {
922
+ return await this.callbacks.onMentionSearch();
923
+ } catch {
924
+ return [];
925
+ }
926
+ }
927
+ /**
928
+ * Re-filter the cached users by `query`, render the list, reset
929
+ * selected to 0. Called on every search input change and on every
930
+ * editor input event while in editor mode.
931
+ */
932
+ renderMentionPickerBody(query) {
933
+ const picker = this.mentionPicker;
934
+ if (!picker) return;
935
+ const list = picker.querySelector('[data-vz="mention-list"]');
936
+ if (!list) return;
937
+ const q = query.toLowerCase().trim();
938
+ this.mentionPickerFiltered = q ? this.mentionPickerUsers.filter((u) => u.name.toLowerCase().includes(q)) : this.mentionPickerUsers.slice();
939
+ this.mentionPickerSelected = 0;
940
+ if (this.mentionPickerFiltered.length === 0) {
941
+ list.innerHTML = `<div class="vz-mention-empty">${this.mentionPickerUsers.length === 0 ? "No teammates to mention yet." : "No teammates match."}</div>`;
942
+ return;
943
+ }
944
+ list.innerHTML = this.mentionPickerFiltered.map((u, i) => {
945
+ const avatar = u.avatarUrl ? `<img class="vz-mention-avatar" src="${escapeHtml(u.avatarUrl)}" alt="" />` : `<span class="vz-mention-avatar vz-mention-avatar-initials">${escapeHtml(initials(u.name) || "?")}</span>`;
946
+ return `<button class="vz-mention-item${i === 0 ? " is-selected" : ""}" type="button" data-mention-index="${i}">${avatar}<span class="vz-mention-item-name">${escapeHtml(u.name)}</span></button>`;
947
+ }).join("");
948
+ }
949
+ moveMentionPickerSelection(delta) {
950
+ if (this.mentionPickerFiltered.length === 0) return;
951
+ const n = this.mentionPickerFiltered.length;
952
+ this.mentionPickerSelected = (this.mentionPickerSelected + delta + n) % n;
953
+ this.refreshMentionPickerHighlight();
954
+ }
955
+ refreshMentionPickerHighlight() {
956
+ const picker = this.mentionPicker;
957
+ if (!picker) return;
958
+ const items = picker.querySelectorAll(".vz-mention-item");
959
+ items.forEach((el, i) => el.classList.toggle("is-selected", i === this.mentionPickerSelected));
960
+ items[this.mentionPickerSelected]?.scrollIntoView({ block: "nearest" });
961
+ }
962
+ commitMentionPickerSelection() {
963
+ const user = this.mentionPickerFiltered[this.mentionPickerSelected];
964
+ if (!user) return;
965
+ if (this.mentionPickerMode === "editor" && this.mentionPickerEditorRange) {
966
+ this.replaceRangeWithMentionChip(this.mentionPickerEditorRange, user);
967
+ } else {
968
+ this.insertMentionChip(user);
969
+ }
970
+ this.closeMentionPicker();
971
+ }
972
+ /**
973
+ * Swap a Range covering `@<query>` for a mention chip + trailing
974
+ * space, then place the caret after the space so the user can keep
975
+ * typing. Used only in editor-trigger mode.
976
+ */
977
+ replaceRangeWithMentionChip(range, user) {
978
+ const editor = this.el.querySelector(".vz-textarea");
979
+ if (!editor) return;
980
+ const chip = document.createElement("span");
981
+ chip.className = "vz-chip vz-chip-mention";
982
+ chip.contentEditable = "false";
983
+ chip.setAttribute("data-mention-id", user.id);
984
+ chip.textContent = "@" + user.name;
985
+ range.deleteContents();
986
+ range.insertNode(chip);
987
+ const space = document.createTextNode(" ");
988
+ chip.after(space);
989
+ const sel = window.getSelection();
990
+ if (sel) {
991
+ const after = document.createRange();
992
+ after.setStartAfter(space);
993
+ after.collapse(true);
994
+ sel.removeAllRanges();
995
+ sel.addRange(after);
996
+ }
997
+ editor.focus();
998
+ }
999
+ /** Wires Esc + outside-click handlers to close the open picker. */
1000
+ wireMentionPickerDismiss() {
1001
+ if (this.mentionPickerDismiss) return;
1002
+ const onKey = (e) => {
1003
+ if (e.key === "Escape") {
1004
+ e.preventDefault();
1005
+ this.closeMentionPicker();
1006
+ }
1007
+ };
1008
+ const onOutside = (e) => {
1009
+ if (!this.mentionPicker) return;
1010
+ if (this.mentionPicker.contains(e.target)) return;
1011
+ if (this.mentionPickerMode === "editor") {
1012
+ const editor = this.el.querySelector(".vz-textarea");
1013
+ if (editor && editor.contains(e.target)) return;
1014
+ }
1015
+ this.closeMentionPicker();
1016
+ };
1017
+ document.addEventListener("keydown", onKey, true);
1018
+ document.addEventListener("mousedown", onOutside, true);
1019
+ this.mentionPickerDismiss = () => {
1020
+ document.removeEventListener("keydown", onKey, true);
1021
+ document.removeEventListener("mousedown", onOutside, true);
1022
+ };
1023
+ }
1024
+ closeMentionPicker() {
1025
+ if (this.mentionPickerDismiss) {
1026
+ this.mentionPickerDismiss();
1027
+ this.mentionPickerDismiss = null;
1028
+ }
1029
+ if (this.mentionPicker) {
1030
+ this.mentionPicker.remove();
1031
+ this.mentionPicker = null;
1032
+ }
1033
+ this.mentionPickerFiltered = [];
1034
+ this.mentionPickerSelected = 0;
1035
+ this.mentionPickerEditorRange = null;
1036
+ }
1037
+ /** Position the picker below the given anchor (the @ mention button). */
1038
+ positionMentionPickerByElement(picker, anchor) {
1039
+ const popRect = this.el.getBoundingClientRect();
1040
+ const btnRect = anchor.getBoundingClientRect();
1041
+ picker.style.left = `${btnRect.left - popRect.left}px`;
1042
+ picker.style.top = `${btnRect.bottom - popRect.top + 4}px`;
1043
+ }
1044
+ /** Position the picker below the `@<query>` text range in the editor. */
1045
+ positionMentionPickerByRange(picker, range) {
1046
+ const popRect = this.el.getBoundingClientRect();
1047
+ const rect = range.getBoundingClientRect();
1048
+ picker.style.left = `${rect.left - popRect.left}px`;
1049
+ picker.style.top = `${rect.bottom - popRect.top + 4}px`;
1050
+ }
1051
+ /**
1052
+ * Insert a mention chip for the given user into the editor at the
1053
+ * current caret position. Accepts any user-like shape with a required
1054
+ * `id` + `name` — covers both the current VizuUser (for self-mention)
1055
+ * and MentionableUser (from the workspace member picker).
1056
+ */
1057
+ insertMentionChip(user) {
1058
+ if (!user || !user.id) return;
1059
+ const editor = this.el.querySelector(".vz-textarea");
1060
+ if (!editor) return;
1061
+ const chip = document.createElement("span");
1062
+ chip.className = "vz-chip vz-chip-mention";
1063
+ chip.contentEditable = "false";
1064
+ chip.setAttribute("data-mention-id", user.id);
1065
+ chip.textContent = "@" + user.name;
1066
+ editor.focus();
1067
+ const sel = window.getSelection();
1068
+ if (!sel || sel.rangeCount === 0) {
1069
+ editor.appendChild(chip);
1070
+ editor.appendChild(document.createTextNode(" "));
1071
+ this.moveCursorToEnd(editor);
1072
+ return;
1073
+ }
1074
+ const r = sel.getRangeAt(0);
1075
+ r.deleteContents();
1076
+ r.insertNode(chip);
1077
+ const space = document.createTextNode(" ");
1078
+ chip.after(space);
1079
+ const newRange = document.createRange();
1080
+ newRange.setStartAfter(space);
1081
+ newRange.collapse(true);
1082
+ sel.removeAllRanges();
1083
+ sel.addRange(newRange);
1084
+ }
1085
+ save() {
1086
+ const editor = this.el.querySelector(".vz-textarea");
1087
+ if (!editor) return;
1088
+ const text = this.serializeEditor(editor);
1089
+ const finishedCount = this.pendingAttachments.filter((a) => a.url).length;
1090
+ if (!text && finishedCount === 0) return;
1091
+ const usedRefs = {};
1092
+ const re = /\[#[^\]]+\]\(vizu-ref:([a-zA-Z0-9_-]+)\)/g;
1093
+ let m;
1094
+ while ((m = re.exec(text)) !== null) {
1095
+ const rid = m[1];
1096
+ if (this.pendingRefs[rid]) usedRefs[rid] = this.pendingRefs[rid];
1097
+ }
1098
+ const mentions = extractMentions(text);
1099
+ const finishedAttachments = this.pendingAttachments.filter((a) => a.url);
1100
+ this.callbacks.onAdd(
1101
+ text,
1102
+ [...this.anchorFingerprints],
1103
+ Object.keys(usedRefs).length ? usedRefs : void 0,
1104
+ mentions.length ? mentions : void 0,
1105
+ finishedAttachments.length ? finishedAttachments : void 0
1106
+ );
1107
+ editor.innerHTML = "";
1108
+ this.pendingRefs = {};
1109
+ this.pendingAttachments = [];
1110
+ this.renderAttachmentList();
1111
+ }
1112
+ position() {
1113
+ const primary = this.anchorTargets[0];
1114
+ if (!primary) return;
1115
+ const rect = primary.getBoundingClientRect();
1116
+ const popRect = this.el.getBoundingClientRect();
1117
+ const pad = 8;
1118
+ let top = rect.bottom + pad;
1119
+ let left = rect.left;
1120
+ if (top + popRect.height > window.innerHeight - pad) {
1121
+ top = rect.top - popRect.height - pad;
1122
+ if (top < pad) top = pad;
1123
+ }
1124
+ if (left + popRect.width > window.innerWidth - pad) {
1125
+ left = window.innerWidth - popRect.width - pad;
1126
+ }
1127
+ if (left < pad) left = pad;
1128
+ this.el.style.top = top + "px";
1129
+ this.el.style.left = left + "px";
1130
+ }
1131
+ destroy() {
1132
+ this.closeMentionPicker();
1133
+ this.el.removeEventListener("click", this.handleClick);
1134
+ this.el.remove();
1135
+ }
1136
+ };
1137
+
1138
+ // src/highlighter.ts
1139
+ var IGNORE_SELECTOR = "[data-vizu-root], [data-vizu-ignore], script, style, link, meta, head";
1140
+ var Highlighter = class {
1141
+ constructor(root, extraIgnore, onClick) {
1142
+ this.current = null;
1143
+ this.active = false;
1144
+ this.paused = false;
1145
+ this.handleMove = (e) => {
1146
+ if (this.paused) {
1147
+ this.setCurrent(null);
1148
+ this.overlay.style.display = "none";
1149
+ return;
1150
+ }
1151
+ const target = document.elementFromPoint(e.clientX, e.clientY);
1152
+ if (!target || target === this.current) return;
1153
+ if (this.shouldIgnore(target)) {
1154
+ this.setCurrent(null);
1155
+ this.overlay.style.display = "none";
1156
+ return;
1157
+ }
1158
+ this.setCurrent(target);
1159
+ this.updateOverlay();
1160
+ };
1161
+ this.handleClick = (e) => {
1162
+ const target = document.elementFromPoint(e.clientX, e.clientY);
1163
+ if (!target) return;
1164
+ if (this.shouldIgnore(target)) return;
1165
+ e.preventDefault();
1166
+ e.stopPropagation();
1167
+ this.onClick(target, e);
1168
+ };
1169
+ this.handleScroll = () => {
1170
+ if (!this.current) return;
1171
+ this.updateOverlay();
1172
+ };
1173
+ this.root = root;
1174
+ this.extraIgnore = extraIgnore;
1175
+ this.onClick = onClick;
1176
+ this.overlay = document.createElement("div");
1177
+ this.overlay.className = "vz-highlight";
1178
+ this.overlay.style.display = "none";
1179
+ this.root.appendChild(this.overlay);
1180
+ }
1181
+ setCurrent(el) {
1182
+ if (this.current === el) return;
1183
+ this.current = el;
1184
+ }
1185
+ start() {
1186
+ if (this.active) return;
1187
+ this.active = true;
1188
+ document.addEventListener("mousemove", this.handleMove, true);
1189
+ document.addEventListener("click", this.handleClick, true);
1190
+ document.addEventListener("scroll", this.handleScroll, true);
1191
+ window.addEventListener("resize", this.handleScroll);
1192
+ }
1193
+ stop() {
1194
+ if (!this.active) return;
1195
+ this.active = false;
1196
+ document.removeEventListener("mousemove", this.handleMove, true);
1197
+ document.removeEventListener("click", this.handleClick, true);
1198
+ document.removeEventListener("scroll", this.handleScroll, true);
1199
+ window.removeEventListener("resize", this.handleScroll);
1200
+ this.overlay.style.display = "none";
1201
+ this.setCurrent(null);
1202
+ }
1203
+ shouldIgnore(el) {
1204
+ if (el === document.documentElement || el === document.body) return true;
1205
+ if (isInside(el, IGNORE_SELECTOR)) return true;
1206
+ for (const sel of this.extraIgnore) {
1207
+ try {
1208
+ if (isInside(el, sel)) return true;
1209
+ } catch {
1210
+ }
1211
+ }
1212
+ return false;
1213
+ }
1214
+ setPaused(paused) {
1215
+ this.paused = paused;
1216
+ if (paused) {
1217
+ this.setCurrent(null);
1218
+ this.overlay.style.display = "none";
1219
+ }
1220
+ }
1221
+ isPaused() {
1222
+ return this.paused;
1223
+ }
1224
+ updateOverlay() {
1225
+ if (!this.current) return;
1226
+ const rect = this.current.getBoundingClientRect();
1227
+ if (rect.width === 0 || rect.height === 0) {
1228
+ this.overlay.style.display = "none";
1229
+ return;
1230
+ }
1231
+ this.overlay.style.display = "block";
1232
+ this.overlay.style.left = rect.left + "px";
1233
+ this.overlay.style.top = rect.top + "px";
1234
+ this.overlay.style.width = rect.width + "px";
1235
+ this.overlay.style.height = rect.height + "px";
1236
+ }
1237
+ destroy() {
1238
+ this.stop();
1239
+ this.overlay.remove();
1240
+ }
1241
+ };
1242
+
1243
+ // src/sidebar.ts
1244
+ var Sidebar = class {
1245
+ constructor(root, callbacks) {
1246
+ this.open = false;
1247
+ this.filter = "open";
1248
+ this.lastComments = [];
1249
+ this.lastConfidence = /* @__PURE__ */ new Map();
1250
+ this.handleClick = (e) => {
1251
+ const target = e.target;
1252
+ const refEl = target.closest('[data-vz="ref"]');
1253
+ if (refEl) {
1254
+ e.stopPropagation();
1255
+ const commentId = refEl.getAttribute("data-comment-id");
1256
+ const refIdAttr = refEl.getAttribute("data-ref-id");
1257
+ if (commentId && refIdAttr) this.callbacks.onJumpToReference(commentId, refIdAttr);
1258
+ return;
1259
+ }
1260
+ const filterEl = target.closest('[data-vz="filter"]');
1261
+ if (filterEl) {
1262
+ e.stopPropagation();
1263
+ const f = filterEl.getAttribute("data-filter");
1264
+ if (f && f !== this.filter) {
1265
+ this.filter = f;
1266
+ this.render(this.lastComments);
1267
+ }
1268
+ return;
1269
+ }
1270
+ const statusEl = target.closest('[data-vz="status"]');
1271
+ if (statusEl) {
1272
+ e.stopPropagation();
1273
+ const id = statusEl.getAttribute("data-id");
1274
+ const status = statusEl.getAttribute("data-status");
1275
+ if (id && status) this.callbacks.onUpdateStatus(id, status);
1276
+ return;
1277
+ }
1278
+ const deleteEl = target.closest('[data-vz="delete"]');
1279
+ if (deleteEl) {
1280
+ e.stopPropagation();
1281
+ const id = deleteEl.getAttribute("data-id");
1282
+ if (id) this.callbacks.onDelete(id);
1283
+ return;
1284
+ }
1285
+ if (target.closest('[data-vz="close"]')) {
1286
+ this.callbacks.onClose();
1287
+ return;
1288
+ }
1289
+ const fpChip = target.closest('[data-vz="jump-fp"]');
1290
+ if (fpChip && fpChip.__fp) {
1291
+ e.stopPropagation();
1292
+ this.callbacks.onJumpTo(fpChip.__fp);
1293
+ return;
1294
+ }
1295
+ const item = target.closest('[data-vz="jump"]');
1296
+ if (item && item.__group) this.callbacks.onJumpTo(item.__group.fp);
1297
+ };
1298
+ this.root = root;
1299
+ this.callbacks = callbacks;
1300
+ this.el = document.createElement("div");
1301
+ this.el.className = "vz-sidebar";
1302
+ this.el.addEventListener("click", this.handleClick);
1303
+ this.root.appendChild(this.el);
1304
+ }
1305
+ toggle(comments, confidence) {
1306
+ if (this.open) this.close();
1307
+ else this.show(comments, confidence);
1308
+ }
1309
+ show(comments, confidence) {
1310
+ this.render(comments, confidence);
1311
+ requestAnimationFrame(() => this.el.classList.add("is-open"));
1312
+ this.open = true;
1313
+ }
1314
+ update(comments, confidence) {
1315
+ if (this.open) this.render(comments, confidence);
1316
+ }
1317
+ close() {
1318
+ this.el.classList.remove("is-open");
1319
+ this.open = false;
1320
+ }
1321
+ isOpen() {
1322
+ return this.open;
1323
+ }
1324
+ render(comments, confidence) {
1325
+ this.lastComments = comments;
1326
+ if (confidence) this.lastConfidence = new Map(confidence);
1327
+ const conf = this.lastConfidence;
1328
+ const orphans = comments.filter((c) => conf.get(c.id) === "orphaned");
1329
+ const anchored = comments.filter((c) => conf.get(c.id) !== "orphaned");
1330
+ const visible = this.filter === "open" ? anchored.filter((c) => (c.status ?? "open") === "open") : anchored;
1331
+ const visibleOrphans = this.filter === "open" ? [] : orphans;
1332
+ const counts = countByStatus(comments);
1333
+ if (visible.length === 0 && visibleOrphans.length === 0) {
1334
+ const emptyCopy = this.filter === "open" && comments.length > 0 ? `<strong>Nothing open.</strong>${counts.resolved + counts.wontfix} closed comment${counts.resolved + counts.wontfix === 1 ? "" : "s"} hidden. Switch to <em>All</em> to see them.` : "<strong>No comments yet</strong>Click any element on the page to leave one.";
1335
+ this.el.innerHTML = `
1336
+ ${this.headerHtml(comments.length, counts)}
1337
+ <div class="vz-sidebar-body">
1338
+ <div class="vz-sidebar-empty">${emptyCopy}</div>
1339
+ </div>
1340
+ `;
1341
+ return;
1342
+ }
1343
+ const groups = /* @__PURE__ */ new Map();
1344
+ for (const c of visible) {
1345
+ const fps = c.fingerprints || [];
1346
+ const primary = fps[0];
1347
+ if (!primary) continue;
1348
+ const key = fingerprintKey(primary);
1349
+ const g = groups.get(key);
1350
+ if (g) g.comments.push(c);
1351
+ else groups.set(key, { fp: primary, comments: [c] });
1352
+ }
1353
+ const groupHtml = [];
1354
+ let gi = 0;
1355
+ for (const g of groups.values()) {
1356
+ const elLabel = g.fp.tagName.toLowerCase() + (g.fp.attributes.id ? "#" + g.fp.attributes.id : "");
1357
+ const textPreview = g.fp.textSnippet.slice(0, 60);
1358
+ groupHtml.push(`
1359
+ <div class="vz-sidebar-group" data-group="${gi}">
1360
+ <div class="vz-sidebar-group-head">
1361
+ ${escapeHtml(elLabel)}
1362
+ ${textPreview ? `<div class="vz-sidebar-group-text">&ldquo;${escapeHtml(textPreview)}${g.fp.textSnippet.length > 60 ? "\u2026" : ""}&rdquo;</div>` : ""}
1363
+ </div>
1364
+ ${g.comments.map((c) => {
1365
+ const status = c.status ?? "open";
1366
+ const others = (c.fingerprints || []).slice(1);
1367
+ const otherChips = others.length ? `<div class="vz-anchor-list" style="margin-top:6px">${others.map(
1368
+ (fp, i) => `<span class="vz-anchor-chip" data-vz="jump-fp" data-fp-key="${escapeHtml(fingerprintKey(fp))}" title="Jump to this element">+${i + 2}/${(c.fingerprints || []).length} ${escapeHtml(fingerprintLabel(fp))}</span>`
1369
+ ).join("")}</div>` : "";
1370
+ const cConf = conf.get(c.id);
1371
+ const driftedBadge = cConf === "drifted" ? `<span class="vz-status-pill vz-status-drifted" title="Anchored via a fallback match \u2014 the original element may have moved or changed.">drifted</span>` : "";
1372
+ return `
1373
+ <div class="vz-sidebar-item ${status !== "open" ? "vz-sidebar-item-dim" : ""}" data-comment-id="${escapeHtml(c.id)}" data-vz="jump" data-group="${gi}" role="button" tabindex="0">
1374
+ <div class="vz-sidebar-item-row">
1375
+ ${c.author ? `<div class="vz-sidebar-item-author">${avatarHtml(c.author)} <span class="vz-sidebar-item-author-name">${escapeHtml(c.author.name)}</span></div>` : ""}
1376
+ ${driftedBadge}
1377
+ ${status !== "open" ? `<span class="vz-status-pill vz-status-${status}">${status}</span>` : ""}
1378
+ </div>
1379
+ <span class="vz-sidebar-item-text">${renderCommentText(c.text, c.id)}</span>
1380
+ ${renderAttachmentsHtml(c.attachments)}
1381
+ ${otherChips}
1382
+ <div class="vz-sidebar-item-meta">
1383
+ <span>${escapeHtml(formatTime(c.createdAt))}${(c.fingerprints || []).length > 1 ? ` \xB7 grouped (${(c.fingerprints || []).length})` : ""}${(c.replies?.length ?? 0) > 0 ? ` \xB7 ${c.replies.length} ${c.replies.length === 1 ? "reply" : "replies"}` : ""}</span>
1384
+ <span class="vz-sidebar-item-actions">
1385
+ ${statusActionsHtml(c.id, status)}
1386
+ <button class="vz-sidebar-item-delete" data-vz="delete" data-id="${escapeHtml(c.id)}" title="Delete comment">delete</button>
1387
+ </span>
1388
+ </div>
1389
+ </div>
1390
+ `;
1391
+ }).join("")}
1392
+ </div>
1393
+ `);
1394
+ gi++;
1395
+ }
1396
+ const orphanHtml = visibleOrphans.length > 0 ? renderOrphansHtml(visibleOrphans) : "";
1397
+ this.el.innerHTML = `
1398
+ ${this.headerHtml(comments.length, counts)}
1399
+ <div class="vz-sidebar-body">
1400
+ ${groupHtml.join("")}
1401
+ ${orphanHtml}
1402
+ </div>
1403
+ `;
1404
+ const items = this.el.querySelectorAll('[data-vz="jump"]');
1405
+ const groupArr = [...groups.values()];
1406
+ for (const item of items) {
1407
+ const groupIndex = Number(item.dataset.group);
1408
+ item.__group = groupArr[groupIndex];
1409
+ }
1410
+ const chips = this.el.querySelectorAll('[data-vz="jump-fp"]');
1411
+ for (const chip of chips) {
1412
+ const key = chip.getAttribute("data-fp-key");
1413
+ const fp = findFingerprintByKey(visible, key || "");
1414
+ chip.__fp = fp;
1415
+ }
1416
+ }
1417
+ headerHtml(total, counts) {
1418
+ return `
1419
+ <div class="vz-sidebar-header">
1420
+ <div class="vz-sidebar-title">
1421
+ Comments
1422
+ <span class="vz-sidebar-title-count">${total}</span>
1423
+ </div>
1424
+ <button class="vz-sidebar-close" data-vz="close" aria-label="Close sidebar">\xD7</button>
1425
+ </div>
1426
+ <div class="vz-sidebar-filters" role="tablist" aria-label="Status filter">
1427
+ <button class="vz-sidebar-filter ${this.filter === "open" ? "is-active" : ""}" data-vz="filter" data-filter="open" role="tab" aria-selected="${this.filter === "open"}">
1428
+ Open <span class="vz-sidebar-filter-count">${counts.open}</span>
1429
+ </button>
1430
+ <button class="vz-sidebar-filter ${this.filter === "all" ? "is-active" : ""}" data-vz="filter" data-filter="all" role="tab" aria-selected="${this.filter === "all"}">
1431
+ All <span class="vz-sidebar-filter-count">${total}</span>
1432
+ </button>
1433
+ </div>
1434
+ `;
1435
+ }
1436
+ destroy() {
1437
+ this.el.removeEventListener("click", this.handleClick);
1438
+ this.el.remove();
1439
+ }
1440
+ };
1441
+ function countByStatus(comments) {
1442
+ const c = { open: 0, resolved: 0, wontfix: 0 };
1443
+ for (const x of comments) {
1444
+ const s = x.status ?? "open";
1445
+ c[s] = (c[s] ?? 0) + 1;
1446
+ }
1447
+ return c;
1448
+ }
1449
+ function statusActionsHtml(id, current) {
1450
+ const safe = escapeHtml(id);
1451
+ if (current === "open") {
1452
+ return `
1453
+ <button class="vz-sidebar-item-status" data-vz="status" data-id="${safe}" data-status="resolved" title="Mark resolved">resolve</button>
1454
+ <button class="vz-sidebar-item-status" data-vz="status" data-id="${safe}" data-status="wontfix" title="Won't fix">won&apos;t fix</button>
1455
+ `;
1456
+ }
1457
+ return `<button class="vz-sidebar-item-status" data-vz="status" data-id="${safe}" data-status="open" title="Reopen">reopen</button>`;
1458
+ }
1459
+ function renderOrphansHtml(orphans) {
1460
+ const items = orphans.map((c) => {
1461
+ const status = c.status ?? "open";
1462
+ const primary = (c.fingerprints || [])[0];
1463
+ const label = primary ? primary.tagName.toLowerCase() : "comment";
1464
+ const snippet = primary?.textSnippet?.slice(0, 60) ?? "";
1465
+ return `
1466
+ <div class="vz-sidebar-item vz-sidebar-item-orphan" data-comment-id="${escapeHtml(c.id)}">
1467
+ <div class="vz-sidebar-item-row">
1468
+ ${c.author ? `<div class="vz-sidebar-item-author">${avatarHtml(c.author)} <span class="vz-sidebar-item-author-name">${escapeHtml(c.author.name)}</span></div>` : ""}
1469
+ <span class="vz-status-pill vz-status-orphan">orphaned</span>
1470
+ </div>
1471
+ <div class="vz-sidebar-orphan-anchor">
1472
+ was on <code>${escapeHtml(label)}</code>${snippet ? ` &ldquo;${escapeHtml(snippet)}${(primary?.textSnippet?.length ?? 0) > 60 ? "\u2026" : ""}&rdquo;` : ""}
1473
+ </div>
1474
+ <span class="vz-sidebar-item-text">${renderCommentText(c.text, c.id)}</span>
1475
+ ${renderAttachmentsHtml(c.attachments)}
1476
+ <div class="vz-sidebar-item-meta">
1477
+ <span>${escapeHtml(formatTime(c.createdAt))}${status !== "open" ? ` \xB7 ${status}` : ""}</span>
1478
+ <span class="vz-sidebar-item-actions">
1479
+ <button class="vz-sidebar-item-delete" data-vz="delete" data-id="${escapeHtml(c.id)}" title="Delete comment">delete</button>
1480
+ </span>
1481
+ </div>
1482
+ </div>
1483
+ `;
1484
+ }).join("");
1485
+ return `
1486
+ <div class="vz-sidebar-orphans">
1487
+ <div class="vz-sidebar-orphans-head">
1488
+ Orphaned \xB7 ${orphans.length}
1489
+ <span class="vz-sidebar-orphans-sub">couldn't find the original element on this page</span>
1490
+ </div>
1491
+ ${items}
1492
+ </div>
1493
+ `;
1494
+ }
1495
+ function findFingerprintByKey(comments, key) {
1496
+ for (const c of comments) {
1497
+ for (const fp of c.fingerprints || []) {
1498
+ if (fingerprintKey(fp) === key) return fp;
1499
+ }
1500
+ }
1501
+ return null;
1502
+ }
1503
+
1504
+ // src/styles.ts
1505
+ var STYLES = `
1506
+ [data-vizu-root] {
1507
+ position: fixed; top: 0; left: 0; width: 0; height: 0;
1508
+ z-index: 2147483647;
1509
+ pointer-events: none;
1510
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
1511
+ font-size: 13px; line-height: 1.5; color: #fafafa;
1512
+ --vz-accent: #FF6647;
1513
+ --vz-bg: #0A0A0A;
1514
+ --vz-border: #2A2A2A;
1515
+ --vz-bg-hover: #1A1A1A;
1516
+ --vz-text-muted: #888;
1517
+ --vz-marker-fg: #FFFFFF;
1518
+ }
1519
+ [data-vizu-root] * { box-sizing: border-box; }
1520
+ /* Button reset uses :where() to drop its specificity to 0,0,1 so any single-class
1521
+ selector like .vz-marker / .vz-pill-btn / .vz-btn that sets background or
1522
+ color still wins, without needing to redeclare those everywhere. */
1523
+ :where([data-vizu-root]) button {
1524
+ font: inherit; color: inherit; background: none; border: 0; cursor: pointer; padding: 0;
1525
+ }
1526
+
1527
+ /* Hover highlight (transient) */
1528
+ .vz-highlight {
1529
+ position: fixed;
1530
+ border: 2px dashed var(--vz-accent);
1531
+ background: color-mix(in srgb, var(--vz-accent) 8%, transparent);
1532
+ pointer-events: none;
1533
+ transition: all 90ms ease-out;
1534
+ border-radius: 2px;
1535
+ z-index: 1;
1536
+ }
1537
+
1538
+ /* Always-on outline for commented elements */
1539
+ .vz-comment-outline {
1540
+ position: fixed;
1541
+ border: 1px dashed color-mix(in srgb, var(--vz-accent) 55%, transparent);
1542
+ background: color-mix(in srgb, var(--vz-accent) 4%, transparent);
1543
+ pointer-events: none;
1544
+ border-radius: 2px;
1545
+ z-index: 1;
1546
+ }
1547
+ .vz-comment-outline.is-selected-pending {
1548
+ border: 1.5px dashed var(--vz-accent);
1549
+ background: color-mix(in srgb, var(--vz-accent) 10%, transparent);
1550
+ }
1551
+ /* Drifted: the anchor resolved only via a fuzzy fallback (sibling-index
1552
+ or page-wide text snippet). Amber border warns visitors the match
1553
+ might be wrong. */
1554
+ .vz-comment-outline-drifted {
1555
+ border-color: rgb(252, 211, 77);
1556
+ background: rgba(245, 158, 11, 0.06);
1557
+ }
1558
+ .vz-comment-outline.is-pulsing {
1559
+ animation: vz-pulse 1500ms ease-out;
1560
+ }
1561
+ @keyframes vz-pulse {
1562
+ 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--vz-accent) 60%, transparent); border-color: var(--vz-accent); }
1563
+ 60% { box-shadow: 0 0 0 22px color-mix(in srgb, var(--vz-accent) 0%, transparent); border-color: var(--vz-accent); }
1564
+ 100% { box-shadow: 0 0 0 0 transparent; border-color: color-mix(in srgb, var(--vz-accent) 55%, transparent); }
1565
+ }
1566
+
1567
+ /* Strong "spotlight" rendered when the user jumps to an element (sidebar item,
1568
+ anchor chip, or # reference click). Visible even on elements that don't have
1569
+ a saved comment yet. */
1570
+ .vz-jump-spotlight {
1571
+ position: fixed;
1572
+ border: 2.5px solid var(--vz-accent);
1573
+ background: color-mix(in srgb, var(--vz-accent) 16%, transparent);
1574
+ pointer-events: none;
1575
+ border-radius: 4px;
1576
+ z-index: 4;
1577
+ animation: vz-spotlight 1700ms cubic-bezier(0.22, 0.61, 0.36, 1) forwards;
1578
+ }
1579
+ @keyframes vz-spotlight {
1580
+ 0% { opacity: 0; box-shadow: 0 0 0 0 transparent; }
1581
+ 10% { opacity: 1; box-shadow: 0 0 0 6px color-mix(in srgb, var(--vz-accent) 55%, transparent); }
1582
+ 45% { opacity: 1; box-shadow: 0 0 0 28px color-mix(in srgb, var(--vz-accent) 10%, transparent); }
1583
+ 80% { opacity: 0.9; box-shadow: 0 0 0 36px color-mix(in srgb, var(--vz-accent) 0%, transparent); }
1584
+ 100% { opacity: 0; box-shadow: 0 0 0 36px transparent; background: color-mix(in srgb, var(--vz-accent) 0%, transparent); }
1585
+ }
1586
+
1587
+ /* Figma-style speech-bubble marker \u2014 pinned at top-right of element, tail pointing in */
1588
+ .vz-marker {
1589
+ position: fixed;
1590
+ min-width: 28px; height: 26px;
1591
+ padding: 0 9px;
1592
+ background: var(--vz-accent);
1593
+ color: var(--vz-marker-fg);
1594
+ font-size: 11px; font-weight: 700;
1595
+ letter-spacing: 0.01em;
1596
+ display: inline-flex; align-items: center; justify-content: center;
1597
+ cursor: pointer;
1598
+ pointer-events: auto;
1599
+ /* Asymmetric radius = the "pin" tail: sharp at bottom-left, rounded elsewhere */
1600
+ border-radius: 14px 14px 14px 3px;
1601
+ /* Solid Vizu-colored halo via 2px ring + drop shadow \u2014 visible on any backdrop */
1602
+ box-shadow:
1603
+ 0 4px 12px rgba(0,0,0,0.28),
1604
+ 0 0 0 2px var(--vz-bg);
1605
+ /* Center the marker on the element's top-right corner, half outside the element */
1606
+ transform: translate(-50%, -50%);
1607
+ transition: transform 120ms cubic-bezier(0.34, 1.56, 0.64, 1);
1608
+ z-index: 2;
1609
+ }
1610
+ .vz-marker.is-group-member { font-size: 10px; padding: 0 7px; }
1611
+ .vz-marker.is-sibling-pulsing { animation: vz-marker-pulse 600ms ease-out; }
1612
+
1613
+ /* Drifted marker: amber-tinted fill + dashed ring instead of solid halo
1614
+ so visitors can see at a glance "this might not be the right element". */
1615
+ .vz-marker-drifted {
1616
+ background: rgb(252, 211, 77);
1617
+ color: rgba(0, 0, 0, 0.78);
1618
+ box-shadow:
1619
+ 0 4px 12px rgba(0,0,0,0.28),
1620
+ 0 0 0 2px var(--vz-bg),
1621
+ 0 0 0 3px rgb(252, 211, 77);
1622
+ }
1623
+ @keyframes vz-marker-pulse {
1624
+ 0% { box-shadow: 0 4px 12px rgba(0,0,0,0.28), 0 0 0 2px var(--vz-bg), 0 0 0 0 var(--vz-accent); }
1625
+ 60% { box-shadow: 0 4px 12px rgba(0,0,0,0.28), 0 0 0 2px var(--vz-bg), 0 0 0 10px color-mix(in srgb, var(--vz-accent) 0%, transparent); }
1626
+ 100% { box-shadow: 0 4px 12px rgba(0,0,0,0.28), 0 0 0 2px var(--vz-bg), 0 0 0 0 transparent; }
1627
+ }
1628
+ .vz-marker:hover {
1629
+ transform: translate(-50%, -50%) scale(1.1);
1630
+ }
1631
+ .vz-marker-avatar {
1632
+ width: 100%; height: 100%;
1633
+ border-radius: 50% 50% 50% 3px;
1634
+ object-fit: cover;
1635
+ display: block;
1636
+ }
1637
+ .vz-marker.has-avatar {
1638
+ padding: 0;
1639
+ width: 28px; min-width: 28px; height: 28px;
1640
+ background: var(--vz-bg);
1641
+ border-radius: 50% 50% 50% 3px;
1642
+ overflow: hidden;
1643
+ }
1644
+ .vz-marker.has-avatar .vz-marker-count {
1645
+ position: absolute;
1646
+ bottom: -4px; right: -4px;
1647
+ background: var(--vz-accent);
1648
+ color: var(--vz-marker-fg);
1649
+ min-width: 16px; height: 16px;
1650
+ border-radius: 8px;
1651
+ font-size: 9px; font-weight: 700;
1652
+ display: inline-flex; align-items: center; justify-content: center;
1653
+ padding: 0 4px;
1654
+ box-shadow: 0 0 0 2px var(--vz-bg);
1655
+ }
1656
+ .vz-marker-initials {
1657
+ font-size: 11px; font-weight: 700;
1658
+ color: #fafafa;
1659
+ background: var(--vz-bg-hover);
1660
+ width: 100%; height: 100%;
1661
+ display: inline-flex; align-items: center; justify-content: center;
1662
+ border-radius: 50% 50% 50% 3px;
1663
+ }
1664
+
1665
+ /* Floating pill */
1666
+ .vz-pill {
1667
+ position: fixed;
1668
+ bottom: 24px; left: 50%; transform: translateX(-50%);
1669
+ background: var(--vz-bg);
1670
+ border: 1px solid var(--vz-border);
1671
+ border-radius: 999px;
1672
+ padding: 7px 8px 7px 18px;
1673
+ display: flex; align-items: center; gap: 10px;
1674
+ pointer-events: auto;
1675
+ box-shadow: 0 8px 24px rgba(0,0,0,.4), inset 0 0 0 1px rgba(255,255,255,.04);
1676
+ user-select: none;
1677
+ max-width: calc(100vw - 32px);
1678
+ z-index: 5;
1679
+ }
1680
+ .vz-pill.is-selecting { border-color: var(--vz-accent); }
1681
+ .vz-pill-toggle {
1682
+ width: 28px; height: 28px;
1683
+ border-radius: 6px;
1684
+ display: inline-flex; align-items: center; justify-content: center;
1685
+ color: var(--vz-text-muted);
1686
+ transition: background 100ms, color 100ms;
1687
+ }
1688
+ .vz-pill-toggle:hover { background: var(--vz-bg-hover); color: #fafafa; }
1689
+ .vz-pill-toggle.is-active { background: color-mix(in srgb, var(--vz-accent) 22%, var(--vz-bg-hover)); color: var(--vz-accent); }
1690
+ .vz-pill-toggle svg { width: 14px; height: 14px; }
1691
+ .vz-pill-dot {
1692
+ width: 8px; height: 8px; border-radius: 4px;
1693
+ background: var(--vz-accent);
1694
+ box-shadow: 0 0 8px var(--vz-accent);
1695
+ flex-shrink: 0;
1696
+ }
1697
+ .vz-pill-label { font-size: 12px; font-weight: 600; letter-spacing: 0.02em; }
1698
+ .vz-pill-count-btn {
1699
+ font-size: 11px; color: var(--vz-text-muted);
1700
+ padding: 4px 10px;
1701
+ border-radius: 999px;
1702
+ transition: background 100ms, color 100ms;
1703
+ }
1704
+ .vz-pill-count-btn:hover { background: var(--vz-bg-hover); color: #fafafa; }
1705
+ .vz-pill-count-btn.is-active { background: var(--vz-bg-hover); color: #fafafa; }
1706
+ .vz-pill-btn {
1707
+ padding: 7px 14px;
1708
+ border-radius: 999px;
1709
+ font-size: 12px; font-weight: 500;
1710
+ color: #fafafa;
1711
+ transition: background 100ms;
1712
+ white-space: nowrap;
1713
+ }
1714
+ .vz-pill-btn:hover { background: var(--vz-bg-hover); }
1715
+ .vz-pill-btn.primary {
1716
+ background: var(--vz-accent);
1717
+ color: var(--vz-marker-fg);
1718
+ font-weight: 600;
1719
+ padding: 7px 16px;
1720
+ }
1721
+ .vz-pill-btn.primary:hover { background: color-mix(in srgb, var(--vz-accent) 88%, white); }
1722
+ .vz-pill-divider { width: 1px; height: 18px; background: var(--vz-border); flex-shrink: 0; }
1723
+ .vz-pill-icon-btn {
1724
+ width: 28px; height: 28px;
1725
+ border-radius: 50%;
1726
+ display: inline-flex; align-items: center; justify-content: center;
1727
+ color: #fafafa;
1728
+ transition: background 100ms;
1729
+ }
1730
+ .vz-pill-icon-btn:hover { background: var(--vz-bg-hover); }
1731
+ .vz-pill-user {
1732
+ display: inline-flex; align-items: center; gap: 6px;
1733
+ padding: 3px 10px 3px 4px;
1734
+ background: var(--vz-bg-hover);
1735
+ border-radius: 999px;
1736
+ font-size: 11px;
1737
+ color: #fafafa;
1738
+ }
1739
+ .vz-pill-user-avatar {
1740
+ width: 22px; height: 22px;
1741
+ border-radius: 50%;
1742
+ object-fit: cover;
1743
+ background: var(--vz-border);
1744
+ font-size: 10px; font-weight: 700;
1745
+ color: #fafafa;
1746
+ display: inline-flex; align-items: center; justify-content: center;
1747
+ }
1748
+
1749
+ /* Popover */
1750
+ .vz-popover {
1751
+ position: fixed;
1752
+ background: var(--vz-bg);
1753
+ border: 1px solid var(--vz-border);
1754
+ border-radius: 8px;
1755
+ padding: 12px;
1756
+ min-width: 280px; max-width: 360px;
1757
+ pointer-events: auto;
1758
+ box-shadow: 0 12px 32px rgba(0,0,0,.5);
1759
+ display: flex; flex-direction: column; gap: 8px;
1760
+ z-index: 6;
1761
+ }
1762
+ .vz-popover-header {
1763
+ font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em;
1764
+ color: var(--vz-text-muted);
1765
+ display: flex; justify-content: space-between; align-items: center;
1766
+ }
1767
+ .vz-popover-close {
1768
+ font-size: 18px; line-height: 1;
1769
+ color: #666;
1770
+ width: 22px; height: 22px;
1771
+ display: inline-flex; align-items: center; justify-content: center;
1772
+ border-radius: 4px;
1773
+ }
1774
+ .vz-popover-close:hover { color: #fafafa; background: var(--vz-bg-hover); }
1775
+ .vz-target-label {
1776
+ font-size: 11px; color: var(--vz-text-muted);
1777
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1778
+ word-break: break-all;
1779
+ padding: 6px 8px;
1780
+ background: var(--vz-bg-hover);
1781
+ border-radius: 4px;
1782
+ }
1783
+ .vz-comment-list {
1784
+ display: flex; flex-direction: column; gap: 6px;
1785
+ max-height: 200px; overflow-y: auto;
1786
+ }
1787
+ .vz-comment {
1788
+ background: var(--vz-bg-hover);
1789
+ border: 1px solid var(--vz-border);
1790
+ border-radius: 6px;
1791
+ padding: 8px 10px;
1792
+ font-size: 13px;
1793
+ display: flex; flex-direction: column; gap: 6px;
1794
+ }
1795
+ .vz-comment-author {
1796
+ display: flex; align-items: center; gap: 6px;
1797
+ font-size: 11px;
1798
+ color: var(--vz-text-muted);
1799
+ }
1800
+ .vz-comment-author-avatar {
1801
+ width: 18px; height: 18px;
1802
+ border-radius: 50%;
1803
+ background: var(--vz-border);
1804
+ display: inline-flex; align-items: center; justify-content: center;
1805
+ font-size: 9px; font-weight: 700;
1806
+ color: #fafafa;
1807
+ overflow: hidden;
1808
+ }
1809
+ .vz-comment-author-avatar img { width: 100%; height: 100%; object-fit: cover; }
1810
+ .vz-comment-author-name { color: #fafafa; font-weight: 500; }
1811
+ .vz-comment-meta {
1812
+ font-size: 10px; color: #666;
1813
+ display: flex; justify-content: space-between;
1814
+ }
1815
+ .vz-comment-delete { color: #666; font-size: 10px; text-decoration: underline; }
1816
+ .vz-comment-delete:hover { color: #ff7676; }
1817
+ .vz-empty { font-size: 12px; color: var(--vz-text-muted); padding: 4px 0; }
1818
+ .vz-textarea {
1819
+ background: var(--vz-bg-hover);
1820
+ border: 1px solid var(--vz-border);
1821
+ color: #fafafa;
1822
+ border-radius: 6px;
1823
+ padding: 8px 10px;
1824
+ font: inherit;
1825
+ font-size: 13px;
1826
+ resize: vertical;
1827
+ min-height: 60px;
1828
+ max-height: 160px;
1829
+ overflow-y: auto;
1830
+ outline: none;
1831
+ width: 100%;
1832
+ line-height: 1.5;
1833
+ word-break: break-word;
1834
+ white-space: pre-wrap;
1835
+ }
1836
+ .vz-textarea:focus { border-color: var(--vz-accent); }
1837
+ .vz-textarea:empty::before {
1838
+ content: attr(data-placeholder);
1839
+ color: var(--vz-text-muted);
1840
+ pointer-events: none;
1841
+ }
1842
+ /* Inline chip rendered live inside the contenteditable comment editor */
1843
+ .vz-chip {
1844
+ display: inline-flex; align-items: center; gap: 4px;
1845
+ padding: 1px 8px 1px 6px;
1846
+ border-radius: 999px;
1847
+ background: color-mix(in srgb, var(--vz-accent) 16%, var(--vz-bg-hover));
1848
+ color: var(--vz-accent);
1849
+ font-size: 12px; font-weight: 500;
1850
+ cursor: default;
1851
+ user-select: none;
1852
+ margin: 0 2px;
1853
+ vertical-align: baseline;
1854
+ white-space: nowrap;
1855
+ }
1856
+ .vz-chip::before { content: ''; width: 5px; height: 5px; border-radius: 50%; background: var(--vz-accent); flex-shrink: 0; }
1857
+
1858
+ /* Popover anchor list (multi-anchor comment header) */
1859
+ .vz-anchor-list {
1860
+ display: flex; flex-wrap: wrap; gap: 4px;
1861
+ padding: 6px 0 0;
1862
+ }
1863
+ .vz-anchor-chip {
1864
+ display: inline-flex; align-items: center; gap: 4px;
1865
+ padding: 3px 8px 3px 6px;
1866
+ border-radius: 999px;
1867
+ background: var(--vz-bg-hover);
1868
+ border: 1px solid var(--vz-border);
1869
+ font-size: 11px;
1870
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1871
+ color: #fafafa;
1872
+ cursor: pointer;
1873
+ }
1874
+ .vz-anchor-chip::before {
1875
+ content: '';
1876
+ width: 5px; height: 5px;
1877
+ border-radius: 50%;
1878
+ background: var(--vz-accent);
1879
+ }
1880
+ .vz-anchor-chip:hover { border-color: var(--vz-accent); }
1881
+ .vz-anchor-chip-remove {
1882
+ font-size: 12px; color: var(--vz-text-muted);
1883
+ margin-left: 2px;
1884
+ background: none; border: 0; cursor: pointer;
1885
+ padding: 0 2px;
1886
+ }
1887
+ .vz-anchor-chip-remove:hover { color: #ff7676; }
1888
+ .vz-anchor-hint { font-size: 11px; color: var(--vz-text-muted); padding: 4px 0; }
1889
+ .vz-popover-actions { display: flex; justify-content: flex-end; gap: 6px; }
1890
+ .vz-btn {
1891
+ padding: 6px 12px;
1892
+ border-radius: 6px;
1893
+ font-size: 12px; font-weight: 500;
1894
+ transition: background 100ms;
1895
+ }
1896
+ .vz-btn-ghost { color: var(--vz-text-muted); }
1897
+ .vz-btn-ghost:hover { background: var(--vz-bg-hover); color: #fafafa; }
1898
+ .vz-btn-primary {
1899
+ background: var(--vz-accent);
1900
+ color: var(--vz-marker-fg);
1901
+ font-weight: 600;
1902
+ }
1903
+ .vz-btn-primary:hover { background: color-mix(in srgb, var(--vz-accent) 88%, white); }
1904
+
1905
+ /* Mention picker \u2014 floating dropdown anchored under the @ mention button */
1906
+ .vz-mention-picker {
1907
+ position: absolute;
1908
+ z-index: 10;
1909
+ min-width: 200px;
1910
+ max-width: 280px;
1911
+ max-height: 240px;
1912
+ overflow-y: auto;
1913
+ padding: 4px;
1914
+ background: var(--vz-bg-popover, #1c1c1c);
1915
+ border: 1px solid var(--vz-border, rgba(255,255,255,0.08));
1916
+ border-radius: 8px;
1917
+ box-shadow: 0 12px 28px rgba(0,0,0,0.4), 0 2px 6px rgba(0,0,0,0.3);
1918
+ display: flex;
1919
+ flex-direction: column;
1920
+ gap: 1px;
1921
+ }
1922
+ .vz-mention-search {
1923
+ width: 100%;
1924
+ padding: 7px 9px;
1925
+ margin-bottom: 4px;
1926
+ background: rgba(255,255,255,0.04);
1927
+ border: 1px solid var(--vz-border, rgba(255,255,255,0.08));
1928
+ border-radius: 6px;
1929
+ color: #fafafa;
1930
+ font-size: 13px;
1931
+ outline: none;
1932
+ box-sizing: border-box;
1933
+ }
1934
+ .vz-mention-search:focus { border-color: var(--vz-accent, #ff8b6e); }
1935
+ .vz-mention-search::placeholder { color: var(--vz-text-muted); }
1936
+ .vz-mention-list {
1937
+ display: flex;
1938
+ flex-direction: column;
1939
+ gap: 1px;
1940
+ min-height: 0;
1941
+ }
1942
+ .vz-mention-loading,
1943
+ .vz-mention-empty {
1944
+ padding: 10px 12px;
1945
+ font-size: 12px;
1946
+ color: var(--vz-text-muted);
1947
+ text-align: center;
1948
+ }
1949
+ .vz-mention-item {
1950
+ display: flex;
1951
+ align-items: center;
1952
+ gap: 8px;
1953
+ padding: 7px 10px;
1954
+ border-radius: 6px;
1955
+ background: transparent;
1956
+ border: none;
1957
+ color: #fafafa;
1958
+ font-size: 13px;
1959
+ text-align: left;
1960
+ cursor: pointer;
1961
+ transition: background 80ms;
1962
+ }
1963
+ .vz-mention-item.is-selected,
1964
+ .vz-mention-item:focus-visible {
1965
+ background: var(--vz-bg-hover, rgba(255,255,255,0.08));
1966
+ outline: none;
1967
+ }
1968
+ .vz-mention-avatar {
1969
+ display: inline-flex;
1970
+ align-items: center;
1971
+ justify-content: center;
1972
+ width: 22px; height: 22px;
1973
+ border-radius: 50%;
1974
+ font-size: 10px; font-weight: 600;
1975
+ color: var(--vz-marker-fg, #0a0a0a);
1976
+ background: var(--vz-accent, #ff8b6e);
1977
+ flex-shrink: 0;
1978
+ overflow: hidden;
1979
+ }
1980
+ .vz-mention-avatar img {
1981
+ width: 100%; height: 100%; object-fit: cover;
1982
+ }
1983
+ .vz-mention-item-name {
1984
+ flex: 1;
1985
+ white-space: nowrap;
1986
+ overflow: hidden;
1987
+ text-overflow: ellipsis;
1988
+ }
1989
+
1990
+ /* Sidebar */
1991
+ .vz-sidebar {
1992
+ position: fixed;
1993
+ top: 0; right: 0;
1994
+ width: 360px;
1995
+ height: 100vh;
1996
+ background: var(--vz-bg);
1997
+ border-left: 1px solid var(--vz-border);
1998
+ pointer-events: auto;
1999
+ display: flex; flex-direction: column;
2000
+ transform: translateX(100%);
2001
+ transition: transform 220ms cubic-bezier(0.32, 0.72, 0, 1);
2002
+ box-shadow: -16px 0 48px rgba(0,0,0,0.35);
2003
+ z-index: 7;
2004
+ }
2005
+ .vz-sidebar.is-open { transform: translateX(0); }
2006
+ .vz-sidebar-header {
2007
+ padding: 16px 18px;
2008
+ border-bottom: 1px solid var(--vz-border);
2009
+ display: flex; align-items: center; gap: 10px;
2010
+ flex-shrink: 0;
2011
+ }
2012
+ .vz-sidebar-title {
2013
+ font-size: 13px; font-weight: 600;
2014
+ display: flex; align-items: center; gap: 8px;
2015
+ }
2016
+ .vz-sidebar-title-count {
2017
+ font-size: 11px;
2018
+ color: var(--vz-text-muted);
2019
+ font-weight: 500;
2020
+ padding: 2px 8px;
2021
+ background: var(--vz-bg-hover);
2022
+ border-radius: 999px;
2023
+ }
2024
+ .vz-sidebar-close {
2025
+ margin-left: auto;
2026
+ width: 28px; height: 28px;
2027
+ border-radius: 6px;
2028
+ display: inline-flex; align-items: center; justify-content: center;
2029
+ color: var(--vz-text-muted);
2030
+ font-size: 18px; line-height: 1;
2031
+ }
2032
+ .vz-sidebar-close:hover { background: var(--vz-bg-hover); color: #fafafa; }
2033
+ .vz-sidebar-body {
2034
+ flex: 1 1 auto;
2035
+ overflow-y: auto;
2036
+ padding: 12px;
2037
+ }
2038
+ .vz-sidebar-empty {
2039
+ padding: 32px 18px;
2040
+ text-align: center;
2041
+ color: var(--vz-text-muted);
2042
+ font-size: 13px;
2043
+ }
2044
+ .vz-sidebar-empty strong { color: #fafafa; display: block; margin-bottom: 4px; font-weight: 600; }
2045
+ .vz-sidebar-group {
2046
+ margin-bottom: 16px;
2047
+ }
2048
+ .vz-sidebar-group-head {
2049
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
2050
+ font-size: 10px;
2051
+ text-transform: lowercase;
2052
+ color: var(--vz-text-muted);
2053
+ padding: 4px 6px;
2054
+ margin-bottom: 4px;
2055
+ word-break: break-all;
2056
+ }
2057
+ .vz-sidebar-group-text {
2058
+ font-family: inherit;
2059
+ text-transform: none;
2060
+ font-size: 11px;
2061
+ color: #c4c4c4;
2062
+ margin-top: 2px;
2063
+ }
2064
+ .vz-sidebar-item {
2065
+ display: block;
2066
+ width: 100%;
2067
+ text-align: left;
2068
+ background: var(--vz-bg-hover);
2069
+ border: 1px solid var(--vz-border);
2070
+ border-radius: 8px;
2071
+ padding: 10px 12px;
2072
+ margin-bottom: 6px;
2073
+ font-size: 13px;
2074
+ color: #fafafa;
2075
+ transition: border-color 100ms, transform 100ms;
2076
+ }
2077
+ .vz-sidebar-item:hover {
2078
+ border-color: color-mix(in srgb, var(--vz-accent) 50%, var(--vz-border));
2079
+ transform: translateX(-2px);
2080
+ }
2081
+ .vz-sidebar-item-author {
2082
+ display: flex; align-items: center; gap: 6px;
2083
+ font-size: 11px;
2084
+ color: var(--vz-text-muted);
2085
+ margin-bottom: 4px;
2086
+ }
2087
+ .vz-sidebar-item-author .vz-comment-author-avatar { width: 16px; height: 16px; font-size: 8px; }
2088
+ .vz-sidebar-item-author-name { color: #fafafa; font-weight: 500; }
2089
+ .vz-sidebar-item-text {
2090
+ display: block;
2091
+ line-height: 1.45;
2092
+ white-space: pre-wrap;
2093
+ word-break: break-word;
2094
+ }
2095
+ .vz-sidebar-item-meta {
2096
+ display: flex; justify-content: space-between; align-items: center;
2097
+ margin-top: 6px;
2098
+ font-size: 10px;
2099
+ color: #666;
2100
+ }
2101
+ .vz-sidebar-item-delete {
2102
+ color: #666;
2103
+ background: transparent;
2104
+ border: 0;
2105
+ padding: 0;
2106
+ cursor: pointer;
2107
+ text-decoration: underline;
2108
+ font-size: 10px;
2109
+ font-family: inherit;
2110
+ }
2111
+ .vz-sidebar-item-delete:hover { color: #ff7676; }
2112
+
2113
+ /* Status filter tabs in the sidebar header */
2114
+ .vz-sidebar-filters {
2115
+ display: flex;
2116
+ gap: 4px;
2117
+ padding: 6px 16px 12px;
2118
+ border-bottom: 1px solid var(--vz-border);
2119
+ }
2120
+ .vz-sidebar-filter {
2121
+ background: transparent;
2122
+ border: 1px solid transparent;
2123
+ color: var(--vz-text-soft);
2124
+ font: inherit;
2125
+ font-size: 10.5px;
2126
+ letter-spacing: 0.06em;
2127
+ text-transform: uppercase;
2128
+ padding: 4px 10px;
2129
+ border-radius: 999px;
2130
+ cursor: pointer;
2131
+ display: inline-flex;
2132
+ align-items: center;
2133
+ gap: 6px;
2134
+ transition: color 100ms, background 100ms, border-color 100ms;
2135
+ }
2136
+ .vz-sidebar-filter:hover { color: var(--vz-text); }
2137
+ .vz-sidebar-filter.is-active {
2138
+ background: color-mix(in srgb, var(--vz-accent) 15%, transparent);
2139
+ color: var(--vz-accent);
2140
+ border-color: color-mix(in srgb, var(--vz-accent) 30%, transparent);
2141
+ }
2142
+ .vz-sidebar-filter-count {
2143
+ color: var(--vz-text-faint);
2144
+ font-size: 9.5px;
2145
+ }
2146
+ .vz-sidebar-filter.is-active .vz-sidebar-filter-count {
2147
+ color: inherit;
2148
+ opacity: 0.7;
2149
+ }
2150
+
2151
+ /* Per-item status pill (resolved / wontfix) shown at top-right of dim items */
2152
+ .vz-sidebar-item-row {
2153
+ display: flex;
2154
+ align-items: center;
2155
+ justify-content: space-between;
2156
+ gap: 8px;
2157
+ margin-bottom: 6px;
2158
+ }
2159
+ .vz-status-pill {
2160
+ display: inline-block;
2161
+ padding: 1px 7px;
2162
+ border-radius: 999px;
2163
+ font-size: 9.5px;
2164
+ letter-spacing: 0.06em;
2165
+ text-transform: uppercase;
2166
+ font-weight: 600;
2167
+ }
2168
+ .vz-status-resolved {
2169
+ background: rgba(34, 197, 94, 0.18);
2170
+ color: rgb(74, 222, 128);
2171
+ }
2172
+ .vz-status-wontfix {
2173
+ background: rgba(113, 113, 122, 0.22);
2174
+ color: rgb(161, 161, 170);
2175
+ }
2176
+ /* Drifted anchor \u2014 fell through to a fuzzy fallback. Render amber. */
2177
+ .vz-status-drifted {
2178
+ background: rgba(245, 158, 11, 0.18);
2179
+ color: rgb(252, 211, 77);
2180
+ }
2181
+ /* Orphaned \u2014 anchor element no longer on the page. Quiet zinc, dim text. */
2182
+ .vz-status-orphan {
2183
+ background: rgba(113, 113, 122, 0.24);
2184
+ color: rgb(212, 212, 216);
2185
+ }
2186
+
2187
+ /* Dim non-open items in 'All' view */
2188
+ .vz-sidebar-item-dim { opacity: 0.55; }
2189
+ .vz-sidebar-item-dim:hover { opacity: 0.85; }
2190
+
2191
+ /* Orphaned-comments section \u2014 separate from the active comment list. */
2192
+ .vz-sidebar-orphans {
2193
+ margin-top: 18px;
2194
+ padding-top: 14px;
2195
+ border-top: 1px dashed var(--vz-border);
2196
+ }
2197
+ .vz-sidebar-orphans-head {
2198
+ padding: 0 16px;
2199
+ font-family: var(--vz-mono, ui-monospace, monospace);
2200
+ font-size: 10.5px;
2201
+ letter-spacing: 0.1em;
2202
+ text-transform: uppercase;
2203
+ color: rgb(212, 212, 216);
2204
+ display: flex;
2205
+ flex-direction: column;
2206
+ gap: 2px;
2207
+ margin-bottom: 10px;
2208
+ }
2209
+ .vz-sidebar-orphans-sub {
2210
+ font-size: 9.5px;
2211
+ letter-spacing: 0.06em;
2212
+ text-transform: none;
2213
+ color: var(--vz-text-faint);
2214
+ }
2215
+ .vz-sidebar-item-orphan {
2216
+ opacity: 0.78;
2217
+ }
2218
+ .vz-sidebar-item-orphan:hover { opacity: 1; }
2219
+ .vz-sidebar-orphan-anchor {
2220
+ margin-top: 4px;
2221
+ margin-bottom: 6px;
2222
+ font-size: 11px;
2223
+ color: var(--vz-text-faint);
2224
+ line-height: 1.5;
2225
+ }
2226
+ .vz-sidebar-orphan-anchor code {
2227
+ background: var(--vz-bg-hover);
2228
+ padding: 1px 5px;
2229
+ border-radius: 3px;
2230
+ font-size: 10.5px;
2231
+ }
2232
+
2233
+ /* Per-item action buttons (resolve / reopen / wontfix) */
2234
+ .vz-sidebar-item-actions {
2235
+ display: inline-flex;
2236
+ align-items: center;
2237
+ gap: 10px;
2238
+ }
2239
+ .vz-sidebar-item-status {
2240
+ background: transparent;
2241
+ border: 0;
2242
+ padding: 0;
2243
+ cursor: pointer;
2244
+ color: var(--vz-text-soft);
2245
+ text-decoration: underline;
2246
+ font-size: 10px;
2247
+ font-family: inherit;
2248
+ }
2249
+ .vz-sidebar-item-status:hover { color: var(--vz-accent); }
2250
+
2251
+ /* # element-reference chip (rendered inside comments) */
2252
+ .vz-ref {
2253
+ display: inline-flex; align-items: center; gap: 4px;
2254
+ padding: 1px 8px 1px 6px;
2255
+ border-radius: 999px;
2256
+ background: color-mix(in srgb, var(--vz-accent) 16%, var(--vz-bg-hover));
2257
+ color: var(--vz-accent);
2258
+ font-size: 12px; font-weight: 500;
2259
+ text-decoration: none;
2260
+ cursor: pointer;
2261
+ transition: background 100ms, transform 100ms;
2262
+ vertical-align: baseline;
2263
+ margin: 0 1px;
2264
+ }
2265
+ .vz-ref:hover { background: color-mix(in srgb, var(--vz-accent) 30%, var(--vz-bg-hover)); transform: translateY(-1px); }
2266
+ .vz-ref::before { content: ''; width: 5px; height: 5px; border-radius: 50%; background: var(--vz-accent); flex-shrink: 0; }
2267
+
2268
+ /* Inline @mention chip (rendered inside displayed comments). user-select
2269
+ * prevents the user from selecting the chip's text and editing it; the
2270
+ * displayed comment is intentionally read-only after save. */
2271
+ .vz-mention {
2272
+ display: inline-flex; align-items: center;
2273
+ padding: 1px 8px 1px 8px;
2274
+ border-radius: 999px;
2275
+ background: color-mix(in srgb, var(--vz-accent) 16%, var(--vz-bg-hover));
2276
+ color: var(--vz-accent);
2277
+ font-size: 12px; font-weight: 600;
2278
+ vertical-align: baseline;
2279
+ margin: 0 1px;
2280
+ user-select: none;
2281
+ -webkit-user-select: none;
2282
+ cursor: default;
2283
+ }
2284
+
2285
+ /* Mention chip inside the editor (contenteditable=false token). Same
2286
+ * non-selection rules as .vz-mention so the chip stays atomic even
2287
+ * inside the editable surface \u2014 caret can land before/after but not
2288
+ * inside, no partial-text edit. */
2289
+ .vz-chip-mention {
2290
+ background: color-mix(in srgb, var(--vz-accent) 16%, var(--vz-bg-hover));
2291
+ color: var(--vz-accent);
2292
+ font-weight: 600;
2293
+ user-select: none;
2294
+ -webkit-user-select: none;
2295
+ }
2296
+ .vz-chip-mention::before { display: none; }
2297
+
2298
+ /* @-mention button in the popover action row */
2299
+ .vz-btn-mention { color: var(--vz-accent); }
2300
+
2301
+ /* Reply count + reply toggle in a comment's action row */
2302
+ .vz-comment-actions { display: inline-flex; align-items: center; gap: 10px; }
2303
+ .vz-comment-reply-toggle {
2304
+ background: transparent;
2305
+ border: 0;
2306
+ padding: 0;
2307
+ cursor: pointer;
2308
+ color: var(--vz-text-soft);
2309
+ text-decoration: underline;
2310
+ font-size: 10px;
2311
+ font-family: inherit;
2312
+ }
2313
+ .vz-comment-reply-toggle:hover { color: var(--vz-accent); }
2314
+
2315
+ /* Inline reply list + form (shown when toggle-reply opens it) */
2316
+ .vz-replies {
2317
+ margin-top: 8px;
2318
+ padding-top: 8px;
2319
+ border-top: 1px dashed var(--vz-border);
2320
+ }
2321
+ .vz-reply-list { display: flex; flex-direction: column; gap: 8px; }
2322
+ .vz-reply {
2323
+ padding: 6px 10px;
2324
+ border-radius: 8px;
2325
+ background: var(--vz-bg-hover);
2326
+ }
2327
+ .vz-reply-text {
2328
+ font-size: 13px;
2329
+ color: var(--vz-text);
2330
+ line-height: 1.45;
2331
+ white-space: pre-wrap;
2332
+ }
2333
+ .vz-reply-form {
2334
+ display: flex;
2335
+ gap: 6px;
2336
+ margin-top: 8px;
2337
+ }
2338
+ .vz-reply-input {
2339
+ flex: 1;
2340
+ resize: vertical;
2341
+ min-height: 36px;
2342
+ padding: 6px 8px;
2343
+ font-family: inherit;
2344
+ font-size: 12.5px;
2345
+ background: var(--vz-bg);
2346
+ color: var(--vz-text);
2347
+ border: 1px solid var(--vz-border);
2348
+ border-radius: 6px;
2349
+ outline: none;
2350
+ }
2351
+ .vz-reply-input:focus { border-color: var(--vz-accent); }
2352
+ .vz-btn-reply { padding: 4px 12px; font-size: 12px; }
2353
+
2354
+ /* \u2500\u2500\u2500 Attachment upload UI (popover) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
2355
+
2356
+ .vz-editor-wrap {
2357
+ position: relative;
2358
+ }
2359
+ .vz-attachment-drop-hint {
2360
+ position: absolute;
2361
+ inset: 0;
2362
+ display: flex;
2363
+ align-items: center;
2364
+ justify-content: center;
2365
+ background: color-mix(in srgb, var(--vz-accent) 18%, var(--vz-bg));
2366
+ border: 2px dashed var(--vz-accent);
2367
+ border-radius: 8px;
2368
+ color: var(--vz-accent);
2369
+ font-size: 13px;
2370
+ font-weight: 600;
2371
+ pointer-events: none;
2372
+ z-index: 1;
2373
+ }
2374
+ /* Opt-in only during an active dragover. The explicit display:flex
2375
+ * above out-specifics the browser default for [hidden], so we restate
2376
+ * the hide rule here to make the attribute toggle effective. */
2377
+ .vz-attachment-drop-hint[hidden] { display: none; }
2378
+
2379
+ /* Pending attachments (uploaded but not yet saved) */
2380
+ .vz-attachment-list {
2381
+ display: flex;
2382
+ flex-wrap: wrap;
2383
+ gap: 6px;
2384
+ }
2385
+ .vz-attachment-list:empty { display: none; }
2386
+ .vz-attachment-item {
2387
+ position: relative;
2388
+ width: 64px;
2389
+ height: 64px;
2390
+ border-radius: 6px;
2391
+ overflow: hidden;
2392
+ background: var(--vz-bg-hover);
2393
+ border: 1px solid var(--vz-border);
2394
+ }
2395
+ .vz-attachment-thumb {
2396
+ display: block;
2397
+ width: 100%;
2398
+ height: 100%;
2399
+ object-fit: cover;
2400
+ color: var(--vz-text-soft);
2401
+ font-size: 11px;
2402
+ text-align: center;
2403
+ line-height: 64px;
2404
+ }
2405
+ .vz-attachment-thumb-loading {
2406
+ background: var(--vz-bg-hover);
2407
+ animation: vz-attachment-pulse 1.4s ease-in-out infinite;
2408
+ }
2409
+ @keyframes vz-attachment-pulse {
2410
+ 0%, 100% { opacity: 0.4; }
2411
+ 50% { opacity: 1; }
2412
+ }
2413
+ .vz-attachment-remove {
2414
+ position: absolute;
2415
+ top: 2px;
2416
+ right: 2px;
2417
+ width: 16px;
2418
+ height: 16px;
2419
+ border-radius: 50%;
2420
+ background: rgba(0, 0, 0, 0.72);
2421
+ color: #fafafa;
2422
+ border: 0;
2423
+ cursor: pointer;
2424
+ font-size: 12px;
2425
+ line-height: 14px;
2426
+ padding: 0;
2427
+ }
2428
+ .vz-attachment-remove:hover { background: rgba(0, 0, 0, 0.9); }
2429
+
2430
+ /* Display-side attachment grid (rendered inside comments) */
2431
+ .vz-attachment-grid {
2432
+ display: grid;
2433
+ grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
2434
+ gap: 4px;
2435
+ margin-top: 6px;
2436
+ }
2437
+ .vz-attachment-display {
2438
+ display: block;
2439
+ border-radius: 4px;
2440
+ overflow: hidden;
2441
+ background: var(--vz-bg-hover);
2442
+ border: 1px solid var(--vz-border);
2443
+ text-decoration: none;
2444
+ color: var(--vz-text-soft);
2445
+ }
2446
+ .vz-attachment-display img {
2447
+ display: block;
2448
+ width: 100%;
2449
+ height: 60px;
2450
+ object-fit: cover;
2451
+ }
2452
+ .vz-attachment-display:hover { border-color: var(--vz-accent); }
2453
+ .vz-attachment-file {
2454
+ display: flex;
2455
+ align-items: center;
2456
+ gap: 6px;
2457
+ padding: 6px 8px;
2458
+ font-size: 11px;
2459
+ }
2460
+ .vz-attachment-icon { font-size: 14px; }
2461
+ .vz-attachment-filename {
2462
+ overflow: hidden;
2463
+ white-space: nowrap;
2464
+ text-overflow: ellipsis;
2465
+ }
2466
+
2467
+ /* Banner shown while user is picking an element to reference */
2468
+ .vz-picking-banner {
2469
+ position: fixed;
2470
+ top: 24px; left: 50%; transform: translateX(-50%);
2471
+ background: var(--vz-bg);
2472
+ border: 1px solid var(--vz-accent);
2473
+ border-radius: 999px;
2474
+ padding: 8px 8px 8px 18px;
2475
+ pointer-events: auto;
2476
+ box-shadow: 0 8px 24px rgba(0,0,0,0.5);
2477
+ display: flex; align-items: center; gap: 12px;
2478
+ font-size: 13px;
2479
+ color: #fafafa;
2480
+ z-index: 8;
2481
+ }
2482
+ .vz-picking-banner-hint {
2483
+ color: var(--vz-text-muted); font-size: 11px;
2484
+ display: inline-flex; align-items: center; gap: 6px;
2485
+ }
2486
+ .vz-picking-banner-kbd {
2487
+ background: var(--vz-bg-hover);
2488
+ border: 1px solid var(--vz-border);
2489
+ border-radius: 4px;
2490
+ padding: 1px 6px;
2491
+ font-family: ui-monospace, monospace;
2492
+ font-size: 10px;
2493
+ color: #fafafa;
2494
+ }
2495
+ .vz-picking-banner-cancel {
2496
+ padding: 4px 12px;
2497
+ font-size: 12px;
2498
+ background: var(--vz-bg-hover);
2499
+ border-radius: 999px;
2500
+ color: var(--vz-text-muted);
2501
+ }
2502
+ .vz-picking-banner-cancel:hover { color: #fafafa; }
2503
+
2504
+ .vz-toast {
2505
+ position: fixed;
2506
+ bottom: 80px; left: 50%; transform: translateX(-50%);
2507
+ background: var(--vz-bg);
2508
+ border: 1px solid var(--vz-border);
2509
+ border-radius: 6px;
2510
+ padding: 10px 14px;
2511
+ font-size: 12px;
2512
+ color: #fafafa;
2513
+ pointer-events: none;
2514
+ animation: vz-toast-in 200ms ease-out;
2515
+ z-index: 9;
2516
+ }
2517
+ @keyframes vz-toast-in {
2518
+ from { opacity: 0; transform: translateX(-50%) translateY(8px); }
2519
+ to { opacity: 1; transform: translateX(-50%) translateY(0); }
2520
+ }
2521
+ `;
2522
+ function injectStyles() {
2523
+ if (typeof document === "undefined") return;
2524
+ if (document.getElementById("vizu-styles")) return;
2525
+ const style = document.createElement("style");
2526
+ style.id = "vizu-styles";
2527
+ style.textContent = STYLES;
2528
+ (document.head || document.documentElement).appendChild(style);
2529
+ }
2530
+ // Annotate the CommonJS export names for ESM import in node:
2531
+ 0 && (module.exports = {
2532
+ Highlighter,
2533
+ Pill,
2534
+ Popover,
2535
+ Sidebar,
2536
+ injectStyles
2537
+ });
2538
+ //# sourceMappingURL=internal.cjs.map