@nuniapp/widget 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,43 +1,4 @@
1
- //#region ../shared/src/id.ts
2
- const ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
3
- const PROJECT_ID_PATTERN = new RegExp(`^nuni_[${ALPHABET}]{22}$`);
4
- /** Random base58 string using the platform CSPRNG (browser, Node, Convex). */
5
- function randomBase58(length) {
6
- const bytes = new Uint8Array(length);
7
- crypto.getRandomValues(bytes);
8
- let out = "";
9
- for (const byte of bytes) {
10
- let b = byte;
11
- while (b >= 232) {
12
- const retry = /* @__PURE__ */ new Uint8Array(1);
13
- crypto.getRandomValues(retry);
14
- b = retry[0];
15
- }
16
- out += ALPHABET[b % 58];
17
- }
18
- return out;
19
- }
20
- function isProjectId(value) {
21
- return typeof value === "string" && PROJECT_ID_PATTERN.test(value);
22
- }
23
- /** Secret used by anonymous commenters to edit/delete their own comments. */
24
- function generateSecret() {
25
- return randomBase58(32);
26
- }
27
- //#endregion
28
- //#region ../shared/src/limits.ts
29
- const LIMITS = {
30
- bodyMaxLength: 2e3,
31
- nameMaxLength: 50,
32
- unclaimedCommentCap: 500,
33
- maxOriginsPerProject: 20,
34
- sessionTtlMs: 2592e6,
35
- anchorTextMaxLength: 120,
36
- /** Per page, the widget loads this many newest open and resolved comments. */
37
- pageOpenLimit: 1e3,
38
- pageResolvedLimit: 200
39
- };
40
- //#endregion
1
+ import { a as REACTIONS, i as LIMITS, n as MASK_ATTRIBUTE, o as generateSecret, r as MASK_SELECTOR, s as isProjectId, t as CAPTURE_MARK } from "./mark-B8wojiFu.js";
41
2
  //#region ../shared/src/path.ts
42
3
  /**
43
4
  * Normalize a URL to the key comments are matched by. Query and hash are
@@ -71,16 +32,472 @@ function describeLocation(input) {
71
32
  url: url.href
72
33
  };
73
34
  }
35
+ /** URL that opens the page with the comment focused by the widget. */
36
+ function commentUrl(page, id) {
37
+ const [pathname, hashRoute] = page.path.split("#");
38
+ const origin = new URL(page.origin).origin;
39
+ let url = new URL(pathname ?? "/", origin);
40
+ if (url.origin !== origin) url = new URL("/", origin);
41
+ const params = new URLSearchParams(page.search ?? "");
42
+ params.set("nuni", id);
43
+ url.search = params.toString();
44
+ if (hashRoute) url.hash = hashRoute;
45
+ return url.toString();
46
+ }
47
+ /** Origin + path only: query strings and hashes often carry tokens. */
48
+ function withoutQuery(input, base) {
49
+ try {
50
+ const url = new URL(input, base);
51
+ return `${url.origin}${url.pathname}`;
52
+ } catch {
53
+ return input.split(/[?#]/)[0] ?? "";
54
+ }
55
+ }
56
+ /**
57
+ * Cut a word of free text at the query or hash of the URL in it: after a
58
+ * path ("/reset?token=1", "https://a.com/x#t") or before a key=value pair
59
+ * ("?token=1", "#access_token=1"). One pass, so long words stay cheap.
60
+ */
61
+ function stripWord(word) {
62
+ const slash = word.indexOf("/");
63
+ const equals = word.lastIndexOf("=");
64
+ for (let i = 0; i < word.length - 1; i++) {
65
+ const c = word[i];
66
+ if ((c === "?" || c === "#") && (slash >= 0 && slash < i || equals > i)) return word.slice(0, i);
67
+ }
68
+ return word;
69
+ }
70
+ /**
71
+ * Remove query strings and hashes from every URL inside free text (log
72
+ * lines, markup, CSS values), absolute or relative.
73
+ */
74
+ function stripUrlQueries(text) {
75
+ return text.replace(/[^\s"'<>`]+/g, stripWord);
76
+ }
77
+ const ATTRIBUTE = /(\s([^\s"'<>/=]+)\s*=\s*)("[^"]*"|'[^']*')/g;
78
+ /** Attributes that hold URLs, including lazy-loading ones like data-src. */
79
+ const URL_ATTRIBUTE = /^(?:action|formaction|poster|cite|ping|data|background|(?:[\w-]*[-:])?(?:href|src|srcset|url|uri))$/i;
80
+ /**
81
+ * Remove query strings and hashes from serialized HTML: URL attributes
82
+ * (links, images, srcset lists, data-src) always, relative or absolute,
83
+ * and URLs anywhere else (inline styles, other attributes, text).
84
+ */
85
+ function stripHtmlUrlQueries(html) {
86
+ return stripUrlQueries(html.replace(ATTRIBUTE, (all, prefix, name, quoted) => {
87
+ if (!URL_ATTRIBUTE.test(name)) return all;
88
+ const quote = quoted[0];
89
+ const value = quoted.slice(1, -1);
90
+ return `${prefix}${quote}${/srcset$/i.test(name) ? value.replace(/[?#][^\s,]*/g, "") : value.replace(/[?#][\s\S]*$/, "")}${quote}`;
91
+ }));
92
+ }
93
+ //#endregion
94
+ //#region ../shared/src/prompt.ts
95
+ const PACKAGES = {
96
+ widget: "@nuniapp/widget",
97
+ react: "@nuniapp/react",
98
+ cli: "@nuniapp/cli"
99
+ };
100
+ //#endregion
101
+ //#region ../shared/src/comment-prompt.ts
102
+ /** A code fence longer than any backtick run inside `content`. */
103
+ function fence(content, lang = "") {
104
+ const longest = Math.max(0, ...Array.from(content.matchAll(/`+/g), (m) => m[0].length));
105
+ const ticks = "`".repeat(Math.max(3, longest + 1));
106
+ return `${ticks}${lang}\n${content}\n${ticks}`;
107
+ }
108
+ function quote(text) {
109
+ return text.split("\n").map((line) => line ? `> ${line}` : ">").join("\n");
110
+ }
111
+ function inline(text) {
112
+ const clean = text.replace(/\s+/g, " ").trim();
113
+ return clean.includes("`") ? `"${clean}"` : `\`${clean}\``;
114
+ }
115
+ /**
116
+ * One comment as a ready-to-paste task for a coding agent. Single source of
117
+ * truth for the widget, dashboard, CLI and MCP server.
118
+ */
119
+ function buildCommentPrompt(comment, options = {}) {
120
+ const includeContext = options.includeContext ?? true;
121
+ const { anchor, page } = comment;
122
+ const out = [];
123
+ out.push("Fix this piece of feedback that was left on the live site with Nuni.", "", "## Comment", "", quote(comment.body), "", `From ${comment.authorName} on ${new Date(comment.createdAt).toISOString().slice(0, 10)}` + (comment.status === "resolved" ? " (already resolved)" : ""));
124
+ if (comment.replies?.length) {
125
+ out.push("", `## Replies (${comment.replies.length})`, "");
126
+ for (const reply of comment.replies) out.push(`${reply.authorName}${reply.isOwner ? " (owner)" : ""}:`, quote(reply.body), "");
127
+ out.pop();
128
+ }
129
+ out.push("", "## Where", "");
130
+ out.push(`- Page: ${page.title ? `${page.title} ` : ""}(${page.path})`);
131
+ out.push(`- Open it: ${commentUrl(page, comment._id)}`);
132
+ out.push(`- Viewport: ${comment.viewport.w}×${comment.viewport.h} at ${comment.viewport.dpr}x`);
133
+ if (includeContext && comment.userAgent) out.push(`- Browser: ${comment.userAgent}`);
134
+ out.push("", "## Element", "");
135
+ out.push(`- Tag: <${anchor.tag}>`);
136
+ if (anchor.text) out.push(`- Text: ${inline(anchor.text)}`);
137
+ if (anchor.quote) out.push(`- Selected text: ${inline(anchor.quote.exact)}`);
138
+ if (anchor.region) {
139
+ const pct = (n) => `${Math.round(n * 100)}%`;
140
+ const r = anchor.region;
141
+ out.push(`- Area: ${pct(r.w)} × ${pct(r.h)} of the element, from ${pct(r.x)} left and ${pct(r.y)} top`);
142
+ }
143
+ if (anchor.componentName) out.push(`- React component: ${anchor.componentName}`);
144
+ if (anchor.selectors.testId) out.push(`- Test ID: ${inline(anchor.selectors.testId)}`);
145
+ if (anchor.selectors.id) out.push(`- ID: ${inline(anchor.selectors.id)}`);
146
+ if (anchor.selectors.css) out.push(`- CSS selector: ${inline(anchor.selectors.css)}`);
147
+ out.push(`- DOM path: ${inline(anchor.selectors.path)}`);
148
+ const labels = Object.entries(anchor.attrs).filter(([name]) => name !== "class" && name !== "style").map(([name, value]) => `${name}="${value}"`);
149
+ if (labels.length) out.push(`- Attributes: ${inline(labels.join(" "))}`);
150
+ const context = includeContext ? comment.context : void 0;
151
+ if (context?.dom?.html) out.push("", fence(context.dom.html, "html"));
152
+ const styles = Object.entries(context?.dom?.styles ?? {});
153
+ if (styles.length) out.push("", "Computed styles:", "", fence(styles.map(([k, v]) => `${k}: ${v};`).join("\n"), "css"));
154
+ if (context?.console?.length) {
155
+ out.push("", `## Console (${context.console.length})`, "");
156
+ out.push(fence(context.console.map((e) => `[${e.level}] ${e.message}`).join("\n"), "text"));
157
+ }
158
+ if (context?.network?.length) {
159
+ out.push("", `## Failed requests (${context.network.length})`, "");
160
+ for (const r of context.network) out.push(`- ${r.method} ${r.url} → ${r.status === 0 ? "network error" : r.status}`);
161
+ }
162
+ if (includeContext && comment.screenshotUrl) out.push("", "## Screenshot", "", comment.screenshotUrl);
163
+ out.push("", "## Steps", "", "1. Find the code that renders this element. Search for the text, the component name, the test ID or the selector.", "2. Make the change the comment asks for, and keep the fix focused on it.", "3. Tell me what you changed, so the comment can be resolved in Nuni.", ` If the Nuni CLI is signed in to this project (\`npx ${PACKAGES.cli}@latest login\`), resolve it with \`npx ${PACKAGES.cli}@latest resolve ${comment._id}\` or the \`resolve_comment\` MCP tool.`);
164
+ return out.join("\n");
165
+ }
74
166
  //#endregion
75
167
  //#region src/config.ts
76
- const VERSION = "0.1.3";
168
+ const VERSION = "0.1.5";
77
169
  function resolveConfig(options) {
78
170
  return {
79
171
  project: options.project,
80
172
  convexUrl: options.convexUrl ?? "https://greedy-giraffe-33.convex.cloud",
81
173
  convexSiteUrl: options.convexSiteUrl ?? "https://greedy-giraffe-33.convex.site",
82
174
  appUrl: (options.appUrl ?? "https://nuni.praveenjuge.com").replace(/\/$/, ""),
83
- getPageKey: options.getPageKey
175
+ getPageKey: options.getPageKey ?? pageKeyFor(options.pageKey),
176
+ position: oneOf(options.position, POSITIONS, "bottom-right"),
177
+ accentColor: options.accentColor?.trim() || null,
178
+ theme: oneOf(options.theme, THEMES, "auto"),
179
+ label: options.label?.trim() || null,
180
+ hotkey: options.hotkey === false ? null : options.hotkey?.trim().slice(0, 1).toLowerCase() || "c",
181
+ zIndex: typeof options.zIndex === "number" && Number.isFinite(options.zIndex) ? Math.round(options.zIndex) : null,
182
+ locale: options.locale?.trim() || void 0,
183
+ messages: options.messages ?? {},
184
+ capture: {
185
+ console: options.capture?.console ?? true,
186
+ network: options.capture?.network ?? true,
187
+ dom: options.capture?.dom ?? true,
188
+ screenshot: options.capture?.screenshot ?? true
189
+ }
190
+ };
191
+ }
192
+ const POSITIONS = [
193
+ "bottom-right",
194
+ "bottom-left",
195
+ "top-right",
196
+ "top-left"
197
+ ];
198
+ const THEMES = [
199
+ "auto",
200
+ "light",
201
+ "dark"
202
+ ];
203
+ function oneOf(value, allowed, fallback) {
204
+ return allowed.includes(value) ? value : fallback;
205
+ }
206
+ /** Query parameters that never tell pages apart. */
207
+ const IGNORED_PARAMS = /^(nuni|utm_.*|fbclid|gclid|msclkid|ref)$/i;
208
+ function pageKeyFor(preset = "path") {
209
+ return (url) => {
210
+ const path = normalizePath(url);
211
+ if (preset === "path+search") {
212
+ const params = [...url.searchParams].filter(([name]) => !IGNORED_PARAMS.test(name)).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
213
+ return params.length ? `${path}?${new URLSearchParams(params).toString()}` : path;
214
+ }
215
+ if (preset === "path+hash" && url.hash.length > 1 && !path.includes("#")) {
216
+ if (!/^#!?\//.test(url.hash)) return `${path}${url.hash}`;
217
+ }
218
+ return path;
219
+ };
220
+ }
221
+ //#endregion
222
+ //#region \0@oxc-project+runtime@0.151.0/helpers/esm/typeof.js
223
+ function _typeof(o) {
224
+ "@babel/helpers - typeof";
225
+ return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
226
+ return typeof o;
227
+ } : function(o) {
228
+ return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o;
229
+ }, _typeof(o);
230
+ }
231
+ //#endregion
232
+ //#region \0@oxc-project+runtime@0.151.0/helpers/esm/toPrimitive.js
233
+ function toPrimitive(t, r) {
234
+ if ("object" != _typeof(t) || !t) return t;
235
+ var e = t[Symbol.toPrimitive];
236
+ if (void 0 !== e) {
237
+ var i = e.call(t, r || "default");
238
+ if ("object" != _typeof(i)) return i;
239
+ throw new TypeError("@@toPrimitive must return a primitive value.");
240
+ }
241
+ return ("string" === r ? String : Number)(t);
242
+ }
243
+ //#endregion
244
+ //#region \0@oxc-project+runtime@0.151.0/helpers/esm/toPropertyKey.js
245
+ function toPropertyKey(t) {
246
+ var i = toPrimitive(t, "string");
247
+ return "symbol" == _typeof(i) ? i : i + "";
248
+ }
249
+ //#endregion
250
+ //#region \0@oxc-project+runtime@0.151.0/helpers/esm/defineProperty.js
251
+ function _defineProperty(e, r, t) {
252
+ return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
253
+ value: t,
254
+ enumerable: !0,
255
+ configurable: !0,
256
+ writable: !0
257
+ }) : e[r] = t, e;
258
+ }
259
+ //#endregion
260
+ //#region src/context.ts
261
+ const MESSAGE_MAX = LIMITS.contextMessageMaxLength;
262
+ function describeValue(value) {
263
+ if (typeof value === "string") return value;
264
+ if (value instanceof Error) {
265
+ const frame = value.stack?.split("\n").find((line) => line.trim().startsWith("at "));
266
+ return `${value.name}: ${value.message}${frame ? ` (${frame.trim()})` : ""}`;
267
+ }
268
+ try {
269
+ let budget = 200;
270
+ return JSON.stringify(value, (_key, v) => budget-- > 0 ? v : void 0) ?? String(value);
271
+ } catch {
272
+ return String(value);
273
+ }
274
+ }
275
+ function clip(text) {
276
+ return text.length > MESSAGE_MAX ? `${text.slice(0, MESSAGE_MAX - 1)}…` : text;
277
+ }
278
+ /** Origin + path only: query strings and hashes often carry tokens. */
279
+ function safeUrl(input) {
280
+ return clip(withoutQuery(input, location.href));
281
+ }
282
+ var Ring = class {
283
+ constructor() {
284
+ _defineProperty(this, "items", []);
285
+ }
286
+ push(item) {
287
+ this.items.push(item);
288
+ if (this.items.length > LIMITS.contextEntryMax) this.items.shift();
289
+ }
290
+ };
291
+ /**
292
+ * Keep the latest console errors and failed requests, so a comment can carry
293
+ * what went wrong on the page. Installed once per mount, removed on destroy.
294
+ * `ignore` skips Nuni's own traffic.
295
+ */
296
+ function startCollectors(options, ignore) {
297
+ const logs = new Ring();
298
+ const requests = new Ring();
299
+ const undo = [];
300
+ const log = (level, args) => {
301
+ logs.push({
302
+ level,
303
+ message: clip(stripUrlQueries(args.map(describeValue).join(" "))),
304
+ at: Date.now()
305
+ });
306
+ };
307
+ const failed = (method, url, status) => {
308
+ if (ignore(url)) return;
309
+ requests.push({
310
+ method: method.toUpperCase(),
311
+ url: safeUrl(url),
312
+ status,
313
+ at: Date.now()
314
+ });
315
+ };
316
+ if (options.console) {
317
+ for (const level of ["error", "warn"]) {
318
+ const original = console[level];
319
+ const wrapped = (...args) => {
320
+ log(level, args);
321
+ original.apply(console, args);
322
+ };
323
+ console[level] = wrapped;
324
+ undo.push(() => {
325
+ if (console[level] === wrapped) console[level] = original;
326
+ });
327
+ }
328
+ const onError = (e) => {
329
+ if (e.error) log("error", [e.error]);
330
+ else log("error", [`${e.message}${e.filename ? ` (${safeUrl(e.filename)}:${e.lineno})` : ""}`]);
331
+ };
332
+ const onRejection = (e) => log("error", ["Unhandled rejection:", e.reason]);
333
+ window.addEventListener("error", onError);
334
+ window.addEventListener("unhandledrejection", onRejection);
335
+ undo.push(() => {
336
+ window.removeEventListener("error", onError);
337
+ window.removeEventListener("unhandledrejection", onRejection);
338
+ });
339
+ }
340
+ if (options.network) {
341
+ const originalFetch = window.fetch;
342
+ if (typeof originalFetch === "function") {
343
+ const wrappedFetch = async (input, init) => {
344
+ if (init && init[CAPTURE_MARK]) return originalFetch.call(window, input, init);
345
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
346
+ const method = init?.method ?? (typeof input === "object" && "method" in input ? input.method : "GET");
347
+ try {
348
+ const response = await originalFetch.call(window, input, init);
349
+ if (!response.ok && response.type !== "opaque") failed(method, url, response.status);
350
+ return response;
351
+ } catch (error) {
352
+ if (!(error instanceof DOMException && error.name === "AbortError")) failed(method, url, 0);
353
+ throw error;
354
+ }
355
+ };
356
+ window.fetch = wrappedFetch;
357
+ undo.push(() => {
358
+ if (window.fetch === wrappedFetch) window.fetch = originalFetch;
359
+ });
360
+ }
361
+ const proto = window.XMLHttpRequest?.prototype;
362
+ if (proto) {
363
+ const open = proto.open;
364
+ const send = proto.send;
365
+ const info = /* @__PURE__ */ new WeakMap();
366
+ const wrappedOpen = function(method, url, ...rest) {
367
+ info.set(this, {
368
+ method,
369
+ url: String(url)
370
+ });
371
+ return open.call(this, method, url, ...rest);
372
+ };
373
+ proto.open = wrappedOpen;
374
+ const wrappedSend = function(body) {
375
+ const request = info.get(this);
376
+ if (request) {
377
+ let aborted = false;
378
+ const onAbort = () => aborted = true;
379
+ const onEnd = () => {
380
+ this.removeEventListener("abort", onAbort);
381
+ this.removeEventListener("loadend", onEnd);
382
+ if (!aborted && (this.status === 0 || this.status >= 400)) failed(request.method, request.url, this.status);
383
+ };
384
+ this.addEventListener("abort", onAbort);
385
+ this.addEventListener("loadend", onEnd);
386
+ }
387
+ return send.call(this, body);
388
+ };
389
+ proto.send = wrappedSend;
390
+ undo.push(() => {
391
+ if (proto.open === wrappedOpen) proto.open = open;
392
+ if (proto.send === wrappedSend) proto.send = send;
393
+ });
394
+ }
395
+ }
396
+ return {
397
+ console: () => [...logs.items],
398
+ network: () => [...requests.items],
399
+ stop() {
400
+ for (const fn of undo.splice(0)) fn();
401
+ }
402
+ };
403
+ }
404
+ const STYLE_PROPS = [
405
+ "display",
406
+ "position",
407
+ "width",
408
+ "height",
409
+ "margin",
410
+ "padding",
411
+ "gap",
412
+ "font-family",
413
+ "font-size",
414
+ "font-weight",
415
+ "line-height",
416
+ "letter-spacing",
417
+ "text-align",
418
+ "color",
419
+ "background-color",
420
+ "border",
421
+ "border-radius",
422
+ "box-shadow",
423
+ "opacity"
424
+ ];
425
+ const ATTR_MAX = 200;
426
+ const TEXT_MAX = 200;
427
+ /** Replace an element's contents with a short note. */
428
+ function redact(el, note) {
429
+ el.replaceChildren(el.ownerDocument.createComment(` ${note} `));
430
+ }
431
+ /**
432
+ * Remove what should never leave the page (form values, option lists and
433
+ * masked areas) and trim the rest.
434
+ */
435
+ function scrub(root) {
436
+ const walker = root.ownerDocument.createTreeWalker(root, 5);
437
+ const drop = [];
438
+ for (let node = root; node; node = walker.nextNode()) {
439
+ if (node.nodeType === 3) {
440
+ const text = node.textContent ?? "";
441
+ if (text.length > TEXT_MAX) node.textContent = `${text.slice(0, TEXT_MAX)}…`;
442
+ continue;
443
+ }
444
+ const el = node;
445
+ const tag = el.tagName;
446
+ if (tag === "SCRIPT" || tag === "STYLE" || tag === "NOSCRIPT") {
447
+ drop.push(el);
448
+ continue;
449
+ }
450
+ if (el.hasAttribute("data-nuni-mask")) {
451
+ for (const attr of Array.from(el.attributes)) if (attr.name !== "id" && attr.name !== "class" && attr.name !== "data-nuni-mask") el.removeAttribute(attr.name);
452
+ redact(el, "masked");
453
+ continue;
454
+ }
455
+ if (tag === "INPUT" || tag === "TEXTAREA") {
456
+ el.removeAttribute("value");
457
+ if (tag === "TEXTAREA") el.textContent = "";
458
+ }
459
+ if (tag === "SELECT" || tag === "DATALIST") {
460
+ const count = el.getElementsByTagName("option").length;
461
+ redact(el, `${count} options`);
462
+ }
463
+ for (const attr of Array.from(el.attributes)) if (attr.value.length > ATTR_MAX) el.setAttribute(attr.name, `${attr.value.slice(0, ATTR_MAX)}…`);
464
+ }
465
+ for (const node of drop) node.parentNode?.removeChild(node);
466
+ }
467
+ /** Clone at most `depth` levels of children. */
468
+ function shallowClone(el, depth) {
469
+ const copy = el.cloneNode(false);
470
+ if (depth > 0) for (const child of Array.from(el.childNodes)) copy.appendChild(child.nodeType === 1 ? shallowClone(child, depth - 1) : child.cloneNode(false));
471
+ else if (el.childElementCount) copy.appendChild(el.ownerDocument.createComment(` ${el.childElementCount} more `));
472
+ return copy;
473
+ }
474
+ /** The element's markup and key computed styles, for design feedback. */
475
+ function domContext(el) {
476
+ const max = LIMITS.domSnippetMaxLength;
477
+ let html = "";
478
+ const inMasked = Boolean(el.parentElement?.closest(`[${MASK_ATTRIBUTE}]`));
479
+ for (const depth of inMasked ? [-1] : [
480
+ Infinity,
481
+ 3,
482
+ 1,
483
+ 0
484
+ ]) {
485
+ const copy = depth === Infinity ? el.cloneNode(true) : shallowClone(el, Math.max(0, depth));
486
+ if (depth < 0) copy.setAttribute(MASK_ATTRIBUTE, "");
487
+ scrub(copy);
488
+ html = stripHtmlUrlQueries(copy.outerHTML);
489
+ if (html.length <= max) break;
490
+ }
491
+ if (html.length > max) html = `${html.slice(0, max - 1)}…`;
492
+ const styles = {};
493
+ const computed = el.ownerDocument.defaultView?.getComputedStyle(el);
494
+ for (const prop of STYLE_PROPS) {
495
+ const value = computed?.getPropertyValue(prop).trim();
496
+ if (value) styles[prop] = value;
497
+ }
498
+ return {
499
+ html,
500
+ styles
84
501
  };
85
502
  }
86
503
  //#endregion
@@ -151,6 +568,463 @@ function attributeAllowed(name, options) {
151
568
  return !prefixes || !prefixes.some((p) => name.startsWith(p));
152
569
  }
153
570
  //#endregion
571
+ //#region ../anchor/src/text.ts
572
+ const SKIP_TEXT_TAGS = /* @__PURE__ */ new Set([
573
+ "SCRIPT",
574
+ "STYLE",
575
+ "NOSCRIPT",
576
+ "TEMPLATE",
577
+ "SVG"
578
+ ]);
579
+ function normalizeText(value) {
580
+ return value.replace(/\s+/g, " ").trim();
581
+ }
582
+ /**
583
+ * Visible-ish text of an element, whitespace-normalized and truncated.
584
+ * Areas marked data-nuni-mask count as empty, both when capturing and when
585
+ * resolving, so their (private) text is never stored and matching stays
586
+ * consistent.
587
+ */
588
+ function elementText(el, max = 120) {
589
+ if (el.closest(MASK_SELECTOR)) return "";
590
+ if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") return normalizeText(el.placeholder || el.getAttribute("aria-label") || "").slice(0, max);
591
+ let out = "";
592
+ const walk = (node) => {
593
+ if (out.length > max * 2) return;
594
+ if (node.nodeType === 3) {
595
+ out += node.nodeValue ?? "";
596
+ return;
597
+ }
598
+ if (node.nodeType !== 1) return;
599
+ const tag = node.tagName.toUpperCase();
600
+ if (SKIP_TEXT_TAGS.has(tag)) return;
601
+ if (node.hasAttribute("data-nuni-mask")) return;
602
+ if (node.hasAttribute("data-nuni")) return;
603
+ if (tag === "IMG") {
604
+ out += " " + (node.getAttribute("alt") ?? "") + " ";
605
+ return;
606
+ }
607
+ const shadow = node.shadowRoot;
608
+ if (shadow) {
609
+ for (const child of Array.from(shadow.childNodes)) walk(child);
610
+ return;
611
+ }
612
+ if (tag === "SLOT") {
613
+ const assigned = node.assignedNodes?.({ flatten: true });
614
+ if (assigned?.length) {
615
+ for (const child of assigned) walk(child);
616
+ return;
617
+ }
618
+ }
619
+ for (const child of Array.from(node.childNodes)) walk(child);
620
+ if (tag === "BR" || tag === "P" || tag === "DIV" || tag === "LI") out += " ";
621
+ };
622
+ walk(el);
623
+ return normalizeText(out).slice(0, max);
624
+ }
625
+ function bigrams(value) {
626
+ const s = value.toLowerCase();
627
+ const map = /* @__PURE__ */ new Map();
628
+ for (let i = 0; i < s.length - 1; i++) {
629
+ const gram = s.slice(i, i + 2);
630
+ map.set(gram, (map.get(gram) ?? 0) + 1);
631
+ }
632
+ return map;
633
+ }
634
+ /** Sørensen-Dice coefficient on character bigrams, 0..1. */
635
+ function textSimilarity(a, b) {
636
+ if (a === b) return 1;
637
+ const na = a.toLowerCase();
638
+ const nb = b.toLowerCase();
639
+ if (na === nb) return 1;
640
+ if (na.length < 2 || nb.length < 2) return 0;
641
+ const ga = bigrams(na);
642
+ const gb = bigrams(nb);
643
+ let overlap = 0;
644
+ for (const [gram, count] of ga) {
645
+ const other = gb.get(gram);
646
+ if (other) overlap += Math.min(count, other);
647
+ }
648
+ return 2 * overlap / (na.length - 1 + (nb.length - 1));
649
+ }
650
+ //#endregion
651
+ //#region ../anchor/src/quote.ts
652
+ const SKIP_TAGS = /* @__PURE__ */ new Set([
653
+ "SCRIPT",
654
+ "STYLE",
655
+ "NOSCRIPT",
656
+ "TEMPLATE",
657
+ "SVG"
658
+ ]);
659
+ /** Elements whose edges read as a space between words. */
660
+ const BLOCK_TAGS = /* @__PURE__ */ new Set([
661
+ "ADDRESS",
662
+ "ARTICLE",
663
+ "ASIDE",
664
+ "BLOCKQUOTE",
665
+ "BR",
666
+ "DD",
667
+ "DIV",
668
+ "DL",
669
+ "DT",
670
+ "FIGCAPTION",
671
+ "FIGURE",
672
+ "FOOTER",
673
+ "FORM",
674
+ "H1",
675
+ "H2",
676
+ "H3",
677
+ "H4",
678
+ "H5",
679
+ "H6",
680
+ "HEADER",
681
+ "HR",
682
+ "LI",
683
+ "MAIN",
684
+ "NAV",
685
+ "OL",
686
+ "P",
687
+ "PRE",
688
+ "SECTION",
689
+ "TABLE",
690
+ "TD",
691
+ "TH",
692
+ "TR",
693
+ "UL"
694
+ ]);
695
+ /** Formatting inside a paragraph: a quote climbs out of these. */
696
+ const INLINE_TAGS = /* @__PURE__ */ new Set([
697
+ "A",
698
+ "ABBR",
699
+ "B",
700
+ "BDI",
701
+ "BDO",
702
+ "CITE",
703
+ "CODE",
704
+ "DATA",
705
+ "DEL",
706
+ "DFN",
707
+ "EM",
708
+ "I",
709
+ "INS",
710
+ "KBD",
711
+ "LABEL",
712
+ "MARK",
713
+ "Q",
714
+ "S",
715
+ "SAMP",
716
+ "SMALL",
717
+ "SPAN",
718
+ "STRONG",
719
+ "SUB",
720
+ "SUP",
721
+ "TIME",
722
+ "U",
723
+ "VAR"
724
+ ]);
725
+ /** Longest text searched for a quote inside one element. */
726
+ const MAX_TEXT = 2e4;
727
+ /** Longest text searched for an edited quote, word by word. */
728
+ const MAX_FUZZY_TEXT = 6e3;
729
+ const FUZZY_MIN = .75;
730
+ const WORD = /[\p{L}\p{N}]/u;
731
+ function textMap(el) {
732
+ const map = {
733
+ text: "",
734
+ nodes: [],
735
+ node: [],
736
+ offset: []
737
+ };
738
+ if (el.closest(MASK_SELECTOR)) return map;
739
+ const chars = [];
740
+ let space = true;
741
+ const push = (c, node, offset) => {
742
+ chars.push(c);
743
+ map.node.push(node);
744
+ map.offset.push(offset);
745
+ };
746
+ const gap = () => {
747
+ if (space || !map.nodes.length) return;
748
+ const last = map.nodes.length - 1;
749
+ push(" ", last, map.nodes[last].length);
750
+ space = true;
751
+ };
752
+ const walk = (node) => {
753
+ if (chars.length >= MAX_TEXT) return;
754
+ if (node.nodeType === 3) {
755
+ const value = node.nodeValue ?? "";
756
+ if (!value) return;
757
+ const index = map.nodes.push(node) - 1;
758
+ for (let i = 0; i < value.length && chars.length < MAX_TEXT; i++) {
759
+ const c = value[i];
760
+ if (c === " " || c === "\n" || c === " " || c === "\r" || c === "\f") {
761
+ if (!space) push(" ", index, i);
762
+ space = true;
763
+ } else {
764
+ push(c, index, i);
765
+ space = false;
766
+ }
767
+ }
768
+ return;
769
+ }
770
+ if (node.nodeType !== 1) return;
771
+ const el = node;
772
+ const tag = el.tagName.toUpperCase();
773
+ if (SKIP_TAGS.has(tag)) return;
774
+ if (el.hasAttribute("data-nuni-mask") || el.hasAttribute("data-nuni")) return;
775
+ const block = BLOCK_TAGS.has(tag);
776
+ if (block) gap();
777
+ for (const child of Array.from(el.childNodes)) walk(child);
778
+ if (block) gap();
779
+ };
780
+ walk(el);
781
+ while (chars.length && chars[chars.length - 1] === " ") {
782
+ chars.pop();
783
+ map.node.pop();
784
+ map.offset.pop();
785
+ }
786
+ map.text = chars.join("");
787
+ return map;
788
+ }
789
+ /**
790
+ * Where a DOM boundary point falls in the element's text: the index of the
791
+ * first character at or after it.
792
+ */
793
+ function textOffset(map, container, offset) {
794
+ if (!map.text.length) return 0;
795
+ const point = (container.ownerDocument ?? container).createRange();
796
+ point.setStart(container, offset);
797
+ point.collapse(true);
798
+ let lo = 0;
799
+ let hi = map.text.length;
800
+ try {
801
+ while (lo < hi) {
802
+ const mid = lo + hi >> 1;
803
+ const node = map.nodes[map.node[mid]];
804
+ if (point.comparePoint(node, map.offset[mid]) < 0) lo = mid + 1;
805
+ else hi = mid;
806
+ }
807
+ } catch {
808
+ return -1;
809
+ }
810
+ return lo;
811
+ }
812
+ /** A Range over characters [start, end) of the element's text. */
813
+ function rangeFor(map, start, end) {
814
+ const first = map.nodes[map.node[start]];
815
+ const last = map.nodes[map.node[end - 1]];
816
+ const range = first.ownerDocument.createRange();
817
+ range.setStart(first, map.offset[start]);
818
+ range.setEnd(last, Math.min(last.length, map.offset[end - 1] + 1));
819
+ return range;
820
+ }
821
+ /**
822
+ * The element a text selection is anchored to: the closest element holding
823
+ * all of it, out of inline formatting (a <strong>, a link) so it is the
824
+ * paragraph or heading, which is easier to find again.
825
+ */
826
+ function quoteContainer(range) {
827
+ let node = range.commonAncestorContainer;
828
+ if (node.nodeType !== 1) node = node.parentNode;
829
+ if (!node || node.nodeType !== 1) return null;
830
+ let el = node;
831
+ const body = el.ownerDocument.body;
832
+ while (INLINE_TAGS.has(el.tagName.toUpperCase()) && el.parentElement && el.parentElement !== body) el = el.parentElement;
833
+ return el;
834
+ }
835
+ /** The quoted words of a selection inside `el`, with context around them. */
836
+ function captureQuote(range, el) {
837
+ const map = textMap(el);
838
+ let start = textOffset(map, range.startContainer, range.startOffset);
839
+ let end = textOffset(map, range.endContainer, range.endOffset);
840
+ if (start < 0 || end < 0) return null;
841
+ const { text } = map;
842
+ while (start < end && text[start] === " ") start++;
843
+ while (end > start && text[end - 1] === " ") end--;
844
+ if (end <= start) return null;
845
+ const exact = text.slice(start, Math.min(end, start + LIMITS.quoteMaxLength));
846
+ const after = start + exact.length;
847
+ return {
848
+ exact,
849
+ prefix: text.slice(Math.max(0, start - LIMITS.quoteContextLength), start),
850
+ suffix: text.slice(after, after + LIMITS.quoteContextLength)
851
+ };
852
+ }
853
+ /**
854
+ * Find a quote again inside `el`. When it is not there (the sentence moved
855
+ * to the next paragraph), look in the parent and grandparent, but only take
856
+ * a match there whose surrounding text still agrees.
857
+ */
858
+ function findQuote(el, quote) {
859
+ const own = findIn(el, quote, 0);
860
+ if (own) return own;
861
+ let node = el.parentElement;
862
+ const body = el.ownerDocument.body;
863
+ for (let i = 0; node && node !== body && i < 2; i++) {
864
+ const hit = findIn(node, quote, .6);
865
+ if (hit) return hit;
866
+ node = node.parentElement;
867
+ }
868
+ return null;
869
+ }
870
+ function affixSimilarity(actual, expected) {
871
+ if (!expected) return actual ? .8 : 1;
872
+ return textSimilarity(actual, expected);
873
+ }
874
+ /** How well the text around a match agrees with the quote's context. */
875
+ function contextScore(text, start, end, quote) {
876
+ const before = text.slice(Math.max(0, start - quote.prefix.length), start);
877
+ const after = text.slice(end, end + quote.suffix.length);
878
+ return (affixSimilarity(before.trimStart(), quote.prefix.trimStart()) + affixSimilarity(after.trimEnd(), quote.suffix.trimEnd())) / 2;
879
+ }
880
+ function findIn(el, quote, minContext) {
881
+ const { exact } = quote;
882
+ if (!exact) return null;
883
+ const map = textMap(el);
884
+ const { text } = map;
885
+ if (!text) return null;
886
+ let best = -1;
887
+ let bestScore = -1;
888
+ for (let i = text.indexOf(exact); i >= 0; i = text.indexOf(exact, i + 1)) {
889
+ const score = contextScore(text, i, i + exact.length, quote);
890
+ if (score > bestScore) {
891
+ best = i;
892
+ bestScore = score;
893
+ }
894
+ }
895
+ if (best >= 0 && bestScore >= minContext) return {
896
+ range: rangeFor(map, best, best + exact.length),
897
+ exact: true
898
+ };
899
+ if (exact.length < 8 || text.length > MAX_FUZZY_TEXT) return null;
900
+ const fuzzy = closestSpan(text, quote);
901
+ if (!fuzzy) return null;
902
+ const context = contextScore(text, fuzzy.start, fuzzy.end, quote);
903
+ if (fuzzy.score < FUZZY_MIN || context < Math.max(minContext, .3)) return null;
904
+ return {
905
+ range: rangeFor(map, fuzzy.start, fuzzy.end),
906
+ exact: false
907
+ };
908
+ }
909
+ /** Word-aligned spans of `text` scored by similarity to the quote. */
910
+ function closestSpan(text, quote) {
911
+ const word = (c) => c !== void 0 && WORD.test(c);
912
+ const starts = [];
913
+ const ends = [];
914
+ for (let i = 0; i < text.length; i++) {
915
+ const c = text[i];
916
+ if (c === " ") continue;
917
+ const prev = text[i - 1];
918
+ const next = text[i + 1];
919
+ if (prev === void 0 || prev === " " || word(c) && !word(prev)) starts.push(i);
920
+ if (next === void 0 || next === " " || word(c) && !word(next)) ends.push(i + 1);
921
+ }
922
+ const length = quote.exact.length;
923
+ let best = null;
924
+ let e = 0;
925
+ for (const start of starts) {
926
+ while (e < ends.length && ends[e] <= start) e++;
927
+ for (let j = e; j < ends.length; j++) {
928
+ const span = ends[j] - start;
929
+ if (span < length * .7) continue;
930
+ if (span > length * 1.3) break;
931
+ const score = textSimilarity(text.slice(start, ends[j]), quote.exact);
932
+ if (!best || score > best.score) best = {
933
+ start,
934
+ end: ends[j],
935
+ score
936
+ };
937
+ }
938
+ }
939
+ return best;
940
+ }
941
+ //#endregion
942
+ //#region ../anchor/src/scope.ts
943
+ function isShadowRoot(node) {
944
+ return Boolean(node && node.nodeType === 11 && "host" in node);
945
+ }
946
+ /** The shadow root or document the element lives in. */
947
+ function scopeRootOf(el) {
948
+ const root = el.getRootNode();
949
+ return isShadowRoot(root) ? root : el.ownerDocument;
950
+ }
951
+ function scopeDocument(root) {
952
+ return isShadowRoot(root) ? root.ownerDocument : root;
953
+ }
954
+ /** The <iframe> showing this document, when the parent is same-origin. */
955
+ function frameElementOf(doc) {
956
+ try {
957
+ return doc.defaultView?.frameElement ?? null;
958
+ } catch {
959
+ return null;
960
+ }
961
+ }
962
+ /** A same-origin iframe's document, or null (cross-origin, not loaded). */
963
+ function frameDocument(el) {
964
+ if (el.tagName !== "IFRAME" && el.tagName !== "FRAME") return null;
965
+ try {
966
+ return el.contentDocument;
967
+ } catch {
968
+ return null;
969
+ }
970
+ }
971
+ /**
972
+ * Shadow hosts and iframes between the page's document and the element,
973
+ * outermost first.
974
+ */
975
+ function scopeChain(el, top) {
976
+ const chain = [];
977
+ let node = el;
978
+ for (let depth = 0; depth < 8; depth++) {
979
+ const root = node.getRootNode();
980
+ if (isShadowRoot(root)) {
981
+ chain.unshift({
982
+ kind: "shadow",
983
+ host: root.host
984
+ });
985
+ node = root.host;
986
+ continue;
987
+ }
988
+ if (node.ownerDocument === top) break;
989
+ const frame = frameElementOf(node.ownerDocument);
990
+ if (!frame) break;
991
+ chain.unshift({
992
+ kind: "frame",
993
+ host: frame
994
+ });
995
+ node = frame;
996
+ }
997
+ return chain;
998
+ }
999
+ /**
1000
+ * The element's box in the page's viewport: getBoundingClientRect plus the
1001
+ * offset of every iframe it is inside.
1002
+ */
1003
+ function viewportRect(el, top) {
1004
+ const r = el.getBoundingClientRect();
1005
+ let left = r.left;
1006
+ let topPx = r.top;
1007
+ let clipped = false;
1008
+ let doc = el.ownerDocument;
1009
+ for (let depth = 0; depth < 8 && doc !== top; depth++) {
1010
+ const frame = frameElementOf(doc);
1011
+ if (!frame) break;
1012
+ const view = doc.defaultView;
1013
+ if (view && (left + r.width < 0 || topPx + r.height < 0 || left > view.innerWidth || topPx > view.innerHeight)) clipped = true;
1014
+ const f = frame.getBoundingClientRect();
1015
+ left += f.left + frame.clientLeft;
1016
+ topPx += f.top + frame.clientTop;
1017
+ doc = frame.ownerDocument;
1018
+ }
1019
+ return {
1020
+ left,
1021
+ top: topPx,
1022
+ width: r.width,
1023
+ height: r.height,
1024
+ clipped
1025
+ };
1026
+ }
1027
+ //#endregion
154
1028
  //#region ../anchor/src/stable.ts
155
1029
  /**
156
1030
  * Heuristics that decide whether an id, class or attribute is a stable,
@@ -304,7 +1178,7 @@ function nthOfType(el) {
304
1178
  return index;
305
1179
  }
306
1180
  function countOfType(el) {
307
- const parent = el.parentElement;
1181
+ const parent = el.parentNode;
308
1182
  if (!parent) return 1;
309
1183
  let count = 0;
310
1184
  for (const child of Array.from(parent.children)) if (child.tagName === el.tagName) count++;
@@ -317,21 +1191,23 @@ function queryCount(root, selector) {
317
1191
  return Infinity;
318
1192
  }
319
1193
  }
320
- function isUniqueId(doc, id) {
321
- return queryCount(doc, `#${cssEscape(id)}`) === 1;
1194
+ function isUniqueId(root, id) {
1195
+ return queryCount(root, `#${cssEscape(id)}`) === 1;
322
1196
  }
323
1197
  /**
324
1198
  * Structural path: nth-of-type chain from the nearest ancestor with a
325
1199
  * stable unique id (or from <html>). Always resolvable when the DOM is
326
- * unchanged; the last-resort selector.
1200
+ * unchanged; the last-resort selector. Inside a shadow root it starts at
1201
+ * the root's top-level element instead of <html>.
327
1202
  */
328
1203
  function buildPath(el) {
1204
+ const root = scopeRootOf(el);
329
1205
  const doc = el.ownerDocument;
330
1206
  const parts = [];
331
1207
  let node = el;
332
1208
  while (node && node !== doc.documentElement) {
333
1209
  const id = stableIdOf(node);
334
- if (id && node !== el && isUniqueId(doc, id)) {
1210
+ if (id && node !== el && isUniqueId(root, id)) {
335
1211
  parts.unshift(`#${cssEscape(id)}`);
336
1212
  return parts.join(" > ");
337
1213
  }
@@ -339,8 +1215,28 @@ function buildPath(el) {
339
1215
  parts.unshift(countOfType(node) > 1 ? `${tag}:nth-of-type(${nthOfType(node)})` : tag);
340
1216
  node = node.parentElement;
341
1217
  }
1218
+ if (isShadowRoot(root)) return parts.join(" > ");
342
1219
  return parts.length ? `html > ${parts.join(" > ")}` : "html";
343
1220
  }
1221
+ /**
1222
+ * The element a path points to in a scope. Shadow paths are matched from
1223
+ * the root's top-level elements, like document paths are from <html>.
1224
+ */
1225
+ function queryPath(root, path) {
1226
+ let matches;
1227
+ try {
1228
+ matches = Array.from(root.querySelectorAll(path)).slice(0, 50);
1229
+ } catch {
1230
+ return null;
1231
+ }
1232
+ if (!isShadowRoot(root) || path.startsWith("#")) return matches[0] ?? null;
1233
+ const depth = path.split(" > ").length;
1234
+ return matches.find((el) => {
1235
+ let node = el;
1236
+ for (let i = 1; i < depth && node; i++) node = node.parentElement;
1237
+ return node?.parentNode === root;
1238
+ }) ?? null;
1239
+ }
344
1240
  function describeSegment(el, options) {
345
1241
  const tag = tagOf(el);
346
1242
  const testId = testIdOf(el, options);
@@ -359,7 +1255,7 @@ function describeSegment(el, options) {
359
1255
  }
360
1256
  }
361
1257
  const label = el.getAttribute("aria-label");
362
- if (label && label.length <= 60) segment += attrSelector("aria-label", label);
1258
+ if (label && label.length <= 60 && !el.closest(MASK_SELECTOR)) segment += attrSelector("aria-label", label);
363
1259
  return segment;
364
1260
  }
365
1261
  /**
@@ -369,8 +1265,9 @@ function describeSegment(el, options) {
369
1265
  */
370
1266
  function buildCssSelector(el, options = {}) {
371
1267
  const doc = el.ownerDocument;
1268
+ const root = scopeRootOf(el);
372
1269
  const id = stableIdOf(el);
373
- if (id && isUniqueId(doc, id)) return `#${cssEscape(id)}`;
1270
+ if (id && isUniqueId(root, id)) return `#${cssEscape(id)}`;
374
1271
  const segments = [];
375
1272
  let node = el;
376
1273
  for (let depth = 0; node && depth < 6; depth++) {
@@ -379,93 +1276,34 @@ function buildCssSelector(el, options = {}) {
379
1276
  break;
380
1277
  }
381
1278
  const nodeId = stableIdOf(node);
382
- if (depth > 0 && nodeId && isUniqueId(doc, nodeId)) {
1279
+ if (depth > 0 && nodeId && isUniqueId(root, nodeId)) {
383
1280
  segments.unshift(`#${cssEscape(nodeId)}`);
384
1281
  const selector = segments.join(" > ");
385
- if (queryCount(doc, selector) === 1) return selector;
1282
+ if (queryCount(root, selector) === 1) return selector;
386
1283
  break;
387
1284
  }
388
1285
  segments.unshift(describeSegment(node, options));
389
1286
  let selector = segments.join(" > ");
390
- if (queryCount(doc, selector) === 1) return selector;
1287
+ if (queryCount(root, selector) === 1) return selector;
391
1288
  const parent = node.parentElement;
392
1289
  if (parent && queryCount(parent, `:scope > ${segments[0]}`) > 1) {
393
1290
  segments[0] = `${segments[0]}:nth-of-type(${nthOfType(node)})`;
394
1291
  selector = segments.join(" > ");
395
- if (queryCount(doc, selector) === 1) return selector;
1292
+ if (queryCount(root, selector) === 1) return selector;
396
1293
  }
397
1294
  node = node.parentElement;
398
1295
  }
399
1296
  const selector = segments.join(" > ");
400
- return queryCount(doc, selector) === 1 ? selector : void 0;
401
- }
402
- //#endregion
403
- //#region ../anchor/src/text.ts
404
- const SKIP_TEXT_TAGS = /* @__PURE__ */ new Set([
405
- "SCRIPT",
406
- "STYLE",
407
- "NOSCRIPT",
408
- "TEMPLATE",
409
- "SVG"
410
- ]);
411
- function normalizeText(value) {
412
- return value.replace(/\s+/g, " ").trim();
413
- }
414
- /** Visible-ish text of an element, whitespace-normalized and truncated. */
415
- function elementText(el, max = 120) {
416
- if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) return normalizeText(el.placeholder || el.getAttribute("aria-label") || "").slice(0, max);
417
- let out = "";
418
- const walk = (node) => {
419
- if (out.length > max * 2) return;
420
- if (node.nodeType === 3) {
421
- out += node.nodeValue ?? "";
422
- return;
423
- }
424
- if (node.nodeType !== 1) return;
425
- const tag = node.tagName.toUpperCase();
426
- if (SKIP_TEXT_TAGS.has(tag)) return;
427
- if (tag === "IMG") {
428
- out += " " + (node.getAttribute("alt") ?? "") + " ";
429
- return;
430
- }
431
- for (const child of Array.from(node.childNodes)) walk(child);
432
- if (tag === "BR" || tag === "P" || tag === "DIV" || tag === "LI") out += " ";
433
- };
434
- walk(el);
435
- return normalizeText(out).slice(0, max);
436
- }
437
- function bigrams(value) {
438
- const s = value.toLowerCase();
439
- const map = /* @__PURE__ */ new Map();
440
- for (let i = 0; i < s.length - 1; i++) {
441
- const gram = s.slice(i, i + 2);
442
- map.set(gram, (map.get(gram) ?? 0) + 1);
443
- }
444
- return map;
445
- }
446
- /** Sørensen-Dice coefficient on character bigrams, 0..1. */
447
- function textSimilarity(a, b) {
448
- if (a === b) return 1;
449
- const na = a.toLowerCase();
450
- const nb = b.toLowerCase();
451
- if (na === nb) return 1;
452
- if (na.length < 2 || nb.length < 2) return 0;
453
- const ga = bigrams(na);
454
- const gb = bigrams(nb);
455
- let overlap = 0;
456
- for (const [gram, count] of ga) {
457
- const other = gb.get(gram);
458
- if (other) overlap += Math.min(count, other);
459
- }
460
- return 2 * overlap / (na.length - 1 + (nb.length - 1));
1297
+ return queryCount(root, selector) === 1 ? selector : void 0;
461
1298
  }
462
1299
  //#endregion
463
1300
  //#region ../anchor/src/capture.ts
464
1301
  function capturedAttributes(el, options) {
465
1302
  const attrs = {};
1303
+ if (el.closest(MASK_SELECTOR)) return attrs;
466
1304
  for (const name of CAPTURED_ATTRIBUTES) {
467
1305
  if (!attributeAllowed(name, options)) continue;
468
- if (name === "value" && !(el instanceof HTMLButtonElement || el.getAttribute("type") === "submit")) continue;
1306
+ if (name === "value" && !(el.tagName === "BUTTON" || el.getAttribute("type") === "submit")) continue;
469
1307
  const value = el.getAttribute(name);
470
1308
  if (value) attrs[name] = value.slice(0, 200);
471
1309
  }
@@ -493,8 +1331,22 @@ function ancestorsOf(el) {
493
1331
  /**
494
1332
  * Record everything needed to find `el` again later.
495
1333
  * `point` is the click position in viewport (client) coordinates.
1334
+ *
1335
+ * An element inside open shadow roots or same-origin iframes also records
1336
+ * each host and iframe on the way (`scope`), captured the same way, so it
1337
+ * can be found again level by level.
496
1338
  */
497
1339
  function captureAnchor(el, point, options = {}) {
1340
+ const anchor = captureLocal(el, point, options);
1341
+ const chain = scopeChain(el, options.document ?? (typeof document === "undefined" ? el.ownerDocument : document));
1342
+ if (chain.length) anchor.scope = chain.map(({ kind, host }) => ({
1343
+ kind,
1344
+ host: captureLocal(host, void 0, options)
1345
+ }));
1346
+ return anchor;
1347
+ }
1348
+ /** The anchor of `el` within its own scope (document or shadow root). */
1349
+ function captureLocal(el, point, options) {
498
1350
  const doc = el.ownerDocument;
499
1351
  const rect = documentRect(el);
500
1352
  const viewport = viewportInfo(doc);
@@ -510,7 +1362,7 @@ function captureAnchor(el, point, options = {}) {
510
1362
  const testId = testIdOf(el, options);
511
1363
  const css = buildCssSelector(el, options);
512
1364
  const role = el.getAttribute("role") ?? void 0;
513
- const parent = el.parentElement;
1365
+ const parent = el.parentNode;
514
1366
  const siblings = parent ? Array.from(parent.children) : [el];
515
1367
  const anchor = {
516
1368
  v: 1,
@@ -532,13 +1384,140 @@ function captureAnchor(el, point, options = {}) {
532
1384
  viewport,
533
1385
  docSize: documentSize(doc)
534
1386
  };
535
- if (role) anchor.role = role;
536
- const componentName = reactComponentName(el);
537
- if (componentName) anchor.componentName = componentName;
1387
+ if (role) anchor.role = role;
1388
+ const componentName = reactComponentName(el);
1389
+ if (componentName) anchor.componentName = componentName;
1390
+ return anchor;
1391
+ }
1392
+ /**
1393
+ * A text comment: the anchor of the paragraph (or heading, cell...) holding
1394
+ * the selected text, plus the words themselves and the text around them.
1395
+ * Null when the selection holds no text.
1396
+ */
1397
+ function captureSelection(range, options = {}) {
1398
+ const element = quoteContainer(range);
1399
+ if (!element) return null;
1400
+ const quote = captureQuote(range, element);
1401
+ if (!quote) return null;
1402
+ const first = range.getClientRects()[0] ?? range.getBoundingClientRect();
1403
+ const anchor = captureAnchor(element, {
1404
+ x: first.left,
1405
+ y: first.top + first.height / 2
1406
+ }, options);
1407
+ anchor.quote = quote;
1408
+ return {
1409
+ element,
1410
+ anchor
1411
+ };
1412
+ }
1413
+ /**
1414
+ * The element an area comment belongs to: the smallest one that covers the
1415
+ * whole box (in viewport coordinates).
1416
+ */
1417
+ function areaContainer(doc, box, isIgnored = () => false) {
1418
+ const right = box.left + box.width;
1419
+ const bottom = box.top + box.height;
1420
+ const corners = [
1421
+ [box.left + 1, box.top + 1],
1422
+ [right - 1, box.top + 1],
1423
+ [box.left + 1, bottom - 1],
1424
+ [right - 1, bottom - 1]
1425
+ ];
1426
+ const ignored = (el) => isIgnored(el) || el.closest("[data-nuni]") !== null;
1427
+ let common = null;
1428
+ for (const [x, y] of corners) {
1429
+ const hit = doc.elementsFromPoint(x, y).find((el) => !ignored(el));
1430
+ if (hit) common = common ? commonAncestor(common, hit) : hit;
1431
+ }
1432
+ let el = common ?? doc.body;
1433
+ while (el.parentElement && el !== doc.body) {
1434
+ const r = el.getBoundingClientRect();
1435
+ if (r.left <= box.left + 1 && r.top <= box.top + 1 && r.right >= right - 1 && r.bottom >= bottom - 1) break;
1436
+ el = el.parentElement;
1437
+ }
1438
+ return el;
1439
+ }
1440
+ function commonAncestor(a, b) {
1441
+ const seen = /* @__PURE__ */ new Set();
1442
+ for (let n = a; n; n = n.parentElement) seen.add(n);
1443
+ for (let n = b; n; n = n.parentElement) if (seen.has(n)) return n;
1444
+ return a.ownerDocument.body;
1445
+ }
1446
+ /**
1447
+ * An area comment: the covering element's anchor plus the box as fractions
1448
+ * of that element's size, so it scales with the layout.
1449
+ */
1450
+ function captureArea(el, box, options = {}) {
1451
+ const anchor = captureAnchor(el, {
1452
+ x: box.left + box.width,
1453
+ y: box.top
1454
+ }, options);
1455
+ const r = el.getBoundingClientRect();
1456
+ const x = r.width ? clamp01((box.left - r.left) / r.width) : 0;
1457
+ const y = r.height ? clamp01((box.top - r.top) / r.height) : 0;
1458
+ anchor.region = {
1459
+ x,
1460
+ y,
1461
+ w: r.width ? Math.min(1 - x, box.width / r.width) : 1,
1462
+ h: r.height ? Math.min(1 - y, box.height / r.height) : 1
1463
+ };
538
1464
  return anchor;
539
1465
  }
540
1466
  //#endregion
541
1467
  //#region ../anchor/src/resolve.ts
1468
+ function createResolveCache() {
1469
+ return {
1470
+ byTag: /* @__PURE__ */ new Map(),
1471
+ elements: /* @__PURE__ */ new Map(),
1472
+ text: /* @__PURE__ */ new WeakMap(),
1473
+ content: /* @__PURE__ */ new WeakMap(),
1474
+ words: /* @__PURE__ */ new WeakMap()
1475
+ };
1476
+ }
1477
+ function textOf(el, max, cache) {
1478
+ if (!cache) return elementText(el, max);
1479
+ let byMax = cache.text.get(el);
1480
+ if (!byMax) cache.text.set(el, byMax = /* @__PURE__ */ new Map());
1481
+ let text = byMax.get(max);
1482
+ if (text === void 0) byMax.set(max, text = elementText(el, max));
1483
+ return text;
1484
+ }
1485
+ function contentOf(el, cache) {
1486
+ if (!cache) return el.textContent ?? "";
1487
+ let content = cache.content.get(el);
1488
+ if (content === void 0) cache.content.set(el, content = el.textContent ?? "");
1489
+ return content;
1490
+ }
1491
+ /** All of an element's text with whitespace collapsed, for quote checks. */
1492
+ function wordsOf(el, cache) {
1493
+ let words = cache?.words.get(el);
1494
+ if (words === void 0) {
1495
+ words = normalizeText(contentOf(el, cache));
1496
+ cache?.words.set(el, words);
1497
+ }
1498
+ return words;
1499
+ }
1500
+ function elementsByTag(root, tag, cache) {
1501
+ const find = () => isShadowRoot(root) ? root.querySelectorAll(tag) : root.getElementsByTagName(tag);
1502
+ if (!cache) return find();
1503
+ let tags = cache.byTag.get(root);
1504
+ if (!tags) cache.byTag.set(root, tags = /* @__PURE__ */ new Map());
1505
+ let list = tags.get(tag);
1506
+ if (!list) tags.set(tag, list = Array.from(find()));
1507
+ return list;
1508
+ }
1509
+ /** Every element in the scope, in document order, up to `limit`. */
1510
+ function allElements(root, limit, cache) {
1511
+ const cached = cache?.elements.get(root);
1512
+ if (cached) return cached;
1513
+ const doc = scopeDocument(root);
1514
+ const start = isShadowRoot(root) ? root : doc.body ?? doc.documentElement;
1515
+ const walker = doc.createTreeWalker(start, 1);
1516
+ const out = [];
1517
+ for (let n = walker.nextNode(); n && out.length < limit; n = walker.nextNode()) out.push(n);
1518
+ cache?.elements.set(root, out);
1519
+ return out;
1520
+ }
542
1521
  const THRESHOLD = {
543
1522
  exact: .9,
544
1523
  high: .72,
@@ -606,7 +1585,7 @@ const IDENTIFYING_ATTRS = /* @__PURE__ */ new Set([
606
1585
  "alt",
607
1586
  "title"
608
1587
  ]);
609
- function scoreCandidate(anchor, el, doc, ctx, options) {
1588
+ function scoreCandidate(anchor, el, doc, ctx, options, cache) {
610
1589
  let total = 0;
611
1590
  let weights = 0;
612
1591
  const add = (weight, value) => {
@@ -653,9 +1632,11 @@ function scoreCandidate(anchor, el, doc, ctx, options) {
653
1632
  }
654
1633
  if (anchor.role) add(.5, el.getAttribute("role") === anchor.role ? 1 : 0);
655
1634
  if (anchor.componentName) add(.5, reactComponentName(el) === anchor.componentName ? 1 : 0);
656
- const text = elementText(el);
1635
+ const text = textOf(el, 120, cache);
657
1636
  const textSim = anchor.text ? textSimilarity(text, anchor.text) : text ? .3 : 1;
658
1637
  add(anchor.text ? 3 : 1, textSim);
1638
+ const quoteHit = anchor.quote ? wordsOf(el, cache).includes(anchor.quote.exact) : null;
1639
+ if (quoteHit !== null) add(3, quoteHit ? 1 : 0);
659
1640
  let ancTotal = 0;
660
1641
  let ancWeights = 0;
661
1642
  let context = null;
@@ -670,18 +1651,19 @@ function scoreCandidate(anchor, el, doc, ctx, options) {
670
1651
  else s += .35 * (nodeId ? .5 : 1);
671
1652
  s += .25 * jaccard(stableClasses(node), expected.classes);
672
1653
  ancTotal += weight * s;
673
- if (context === null && expected.text && expected.text !== anchor.text) context = textSimilarity(elementText(node, 80), expected.text);
1654
+ if (context === null && expected.text && expected.text !== anchor.text) context = textSimilarity(textOf(node, 80, cache), expected.text);
674
1655
  node = node.parentElement;
675
1656
  });
676
1657
  if (ancWeights) add(1.5, ancTotal / ancWeights);
677
1658
  if (context !== null) add(2, context);
678
- const parent = el.parentElement;
1659
+ const parent = el.parentNode;
679
1660
  const index = parent ? Array.prototype.indexOf.call(parent.children, el) : 0;
680
1661
  add(.5, Math.max(0, 1 - Math.abs(index - anchor.siblingIndex) / 3));
681
1662
  const geoWeight = Math.max(0, 1 - Math.abs(ctx.viewportRatio - 1) * 2);
1663
+ const r = el.getBoundingClientRect();
1664
+ const rendered = r.width + r.height > 0;
682
1665
  if (geoWeight > 0 && anchor.rect.w + anchor.rect.h > 0) {
683
- const r = el.getBoundingClientRect();
684
- if (r.width + r.height > 0) {
1666
+ if (rendered) {
685
1667
  const view = doc.defaultView;
686
1668
  const cx = r.left + (view?.scrollX ?? 0) + r.width / 2;
687
1669
  const cy = r.top + (view?.scrollY ?? 0) + r.height / 2;
@@ -696,34 +1678,38 @@ function scoreCandidate(anchor, el, doc, ctx, options) {
696
1678
  }
697
1679
  let score = weights ? total / weights : 0;
698
1680
  const structural = (cssHit || !anchor.selectors.css) && pathHit && identifyingAttrsMatch;
699
- if (anchor.text.length >= 2 && textSim < .999) {
700
- const factor = peerCount(el) > 0 ? .2 + .8 * textSim * textSim : .7 + .3 * textSim;
1681
+ const identity = ctx.quoteOnly === el ? Math.max(textSim, .9) : textSim;
1682
+ if (anchor.text.length >= 2 && identity < .999) {
1683
+ const factor = peerCount(el) > 0 ? .2 + .8 * identity * identity : .7 + .3 * identity;
701
1684
  score *= strong || structural ? Math.max(factor, .92) : factor;
702
1685
  }
1686
+ if (quoteHit === false) score *= .85;
703
1687
  return {
704
1688
  el,
705
1689
  score,
706
1690
  text: textSim,
707
1691
  context,
708
- strong: strong || structural
1692
+ strong: strong || structural,
1693
+ rendered
709
1694
  };
710
1695
  }
711
- function collectCandidates(anchor, doc, options) {
1696
+ function collectCandidates(anchor, root, options, cache) {
1697
+ const doc = scopeDocument(root);
712
1698
  const candidates = /* @__PURE__ */ new Set();
713
1699
  const ignored = (el) => el === doc.documentElement || (options.isIgnored?.(el) ?? false);
714
1700
  const add = (el) => {
715
1701
  if (el && !ignored(el)) candidates.add(el);
716
1702
  };
717
- if (anchor.selectors.id) add(doc.getElementById(anchor.selectors.id));
1703
+ if (anchor.selectors.id) add(isShadowRoot(root) ? safeQueryAll(root, `#${cssEscape(anchor.selectors.id)}`, 1)[0] : root.getElementById(anchor.selectors.id));
718
1704
  if (anchor.selectors.testId) {
719
1705
  const parts = splitTestId(anchor.selectors.testId);
720
- if (parts) safeQueryAll(doc, attrQuery(parts[0], parts[1])).forEach(add);
1706
+ if (parts) safeQueryAll(root, attrQuery(parts[0], parts[1])).forEach(add);
721
1707
  }
722
- safeQueryAll(doc, anchor.selectors.css).forEach(add);
723
- const pathMatch = safeQueryAll(doc, anchor.selectors.path, 1)[0] ?? null;
1708
+ safeQueryAll(root, anchor.selectors.css).forEach(add);
1709
+ const pathMatch = queryPath(root, anchor.selectors.path);
724
1710
  add(pathMatch);
725
1711
  const needle = anchor.text.slice(0, 24).toLowerCase();
726
- const sameTag = doc.getElementsByTagName(anchor.tag);
1712
+ const sameTag = elementsByTag(root, anchor.tag, cache);
727
1713
  const scanAll = sameTag.length <= MAX_SCAN;
728
1714
  for (let i = 0; i < sameTag.length && i < MAX_SCAN * 2; i++) {
729
1715
  const el = sameTag[i];
@@ -731,20 +1717,16 @@ function collectCandidates(anchor, doc, options) {
731
1717
  add(el);
732
1718
  continue;
733
1719
  }
734
- const content = el.textContent?.toLowerCase() ?? "";
1720
+ const content = contentOf(el, cache).toLowerCase();
735
1721
  if (needle && content.includes(needle)) add(el);
736
1722
  }
737
1723
  if (anchor.text.length >= 3) {
738
1724
  const exact = anchor.text.toLowerCase();
739
- const walker = doc.createTreeWalker(doc.body ?? doc.documentElement, 1);
740
- let count = 0;
741
- for (let n = walker.nextNode(); n && count < MAX_SCAN * 3; n = walker.nextNode()) {
742
- count++;
743
- const el = n;
1725
+ for (const el of allElements(root, MAX_SCAN * 3, cache)) {
744
1726
  if (el.tagName.toLowerCase() === anchor.tag) continue;
745
- const content = el.textContent;
1727
+ const content = contentOf(el, cache);
746
1728
  if (!content || content.length > exact.length * 3 + 20) continue;
747
- if (elementText(el).toLowerCase() === exact) add(el);
1729
+ if (textOf(el, 120, cache).toLowerCase() === exact) add(el);
748
1730
  }
749
1731
  }
750
1732
  return {
@@ -752,44 +1734,104 @@ function collectCandidates(anchor, doc, options) {
752
1734
  pathMatch
753
1735
  };
754
1736
  }
755
- /** Find the element an anchor was captured on, in the current document. */
756
- function resolveAnchor(anchor, doc = document, options = {}) {
757
- if (anchor.tag === "body" || anchor.tag === "html") return {
1737
+ const RANK = {
1738
+ lost: 0,
1739
+ low: 1,
1740
+ high: 2,
1741
+ exact: 3
1742
+ };
1743
+ /**
1744
+ * Find the element an anchor was captured on, in the current document.
1745
+ * Elements in shadow roots and iframes are found host by host first.
1746
+ * Pass one `cache` when resolving many anchors over the same DOM.
1747
+ */
1748
+ function resolveAnchor(anchor, doc = document, options = {}, cache) {
1749
+ let root = doc;
1750
+ let floor = "exact";
1751
+ const lost = {
1752
+ element: null,
1753
+ confidence: "lost",
1754
+ score: 0
1755
+ };
1756
+ for (const step of anchor.scope ?? []) {
1757
+ const host = resolveIn(step.host, root, options, cache);
1758
+ if (!host.element) return lost;
1759
+ let confidence = host.confidence;
1760
+ if (step.host.text) {
1761
+ if (host.text < .9 && RANK[confidence] < RANK.high) return lost;
1762
+ if (host.text >= .999 && host.margin >= .1 && confidence === "low") confidence = "high";
1763
+ }
1764
+ const next = step.kind === "shadow" ? host.element.shadowRoot : frameDocument(host.element);
1765
+ if (!next) return lost;
1766
+ if (RANK[confidence] < RANK[floor]) floor = confidence;
1767
+ root = next;
1768
+ }
1769
+ const { text: _text, margin: _margin, ...result } = resolveIn(anchor, root, options, cache);
1770
+ if (result.element && RANK[floor] < RANK[result.confidence]) result.confidence = floor;
1771
+ if (result.element && anchor.quote) {
1772
+ const quote = findQuote(result.element, anchor.quote);
1773
+ if (quote) result.range = quote.range;
1774
+ if ((!quote || !quote.exact) && RANK[result.confidence] > RANK.low) result.confidence = "low";
1775
+ }
1776
+ return result;
1777
+ }
1778
+ function resolveIn(anchor, root, options, cache) {
1779
+ const doc = scopeDocument(root);
1780
+ if (!isShadowRoot(root) && (anchor.tag === "body" || anchor.tag === "html")) return {
758
1781
  element: doc.body,
759
1782
  confidence: "exact",
760
- score: 1
1783
+ score: 1,
1784
+ text: 1,
1785
+ margin: 1
761
1786
  };
762
- const { candidates, pathMatch } = collectCandidates(anchor, doc, options);
1787
+ const { candidates, pathMatch } = collectCandidates(anchor, root, options, cache);
763
1788
  if (!candidates.size) return {
764
1789
  element: null,
765
1790
  confidence: "lost",
766
- score: 0
1791
+ score: 0,
1792
+ text: 0,
1793
+ margin: 0
767
1794
  };
768
1795
  const view = doc.defaultView;
769
1796
  const viewportRatio = anchor.viewport.w && view?.innerWidth ? view.innerWidth / anchor.viewport.w : 1;
1797
+ let quoteOnly = null;
1798
+ if (anchor.quote) {
1799
+ const exact = anchor.quote.exact;
1800
+ const hits = [...candidates].filter((el) => wordsOf(el, cache).includes(exact));
1801
+ const innermost = hits.filter((el) => !hits.some((other) => other !== el && el.contains(other)));
1802
+ if (innermost.length === 1) quoteOnly = innermost[0];
1803
+ }
770
1804
  const scored = [];
771
1805
  for (const el of candidates) scored.push(scoreCandidate(anchor, el, doc, {
772
1806
  pathMatch,
773
- viewportRatio
774
- }, options));
775
- if (scored.filter((s) => s.text >= .999 && s.el.tagName.toLowerCase() === anchor.tag).length > 1) for (const s of scored) {
1807
+ viewportRatio,
1808
+ quoteOnly
1809
+ }, options, cache));
1810
+ const visibleOnly = anchor.rect.w + anchor.rect.h > 0 && scored.some((s) => s.rendered);
1811
+ const rival = (s) => !visibleOnly || s.rendered;
1812
+ if (scored.filter((s) => s.text >= .999 && s.el.tagName.toLowerCase() === anchor.tag && rival(s)).length > 1) for (const s of scored) {
776
1813
  if (s.context === null || s.strong) continue;
777
1814
  s.score *= .2 + .8 * s.context * s.context;
778
1815
  }
779
1816
  scored.sort((a, b) => b.score - a.score);
780
1817
  const best = scored[0];
781
- const second = scored.find((s) => s.el !== best.el && !best.el.contains(s.el) && !s.el.contains(best.el));
1818
+ const second = scored.find((s) => s.el !== best.el && !best.el.contains(s.el) && !s.el.contains(best.el) && (!best.rendered || rival(s)));
782
1819
  if (best.score < THRESHOLD.low) return {
783
1820
  element: null,
784
1821
  confidence: "lost",
785
- score: best.score
1822
+ score: best.score,
1823
+ text: best.text,
1824
+ margin: 0
786
1825
  };
787
1826
  let confidence = best.score >= THRESHOLD.exact && best.text >= .999 ? "exact" : best.score >= THRESHOLD.high ? "high" : "low";
788
- if (second && best.score - second.score < AMBIGUITY_MARGIN) confidence = "low";
1827
+ const margin = second ? best.score - second.score : 1;
1828
+ if (margin < AMBIGUITY_MARGIN) confidence = "low";
789
1829
  return {
790
1830
  element: best.el,
791
1831
  confidence,
792
- score: best.score
1832
+ score: best.score,
1833
+ text: best.text,
1834
+ margin
793
1835
  };
794
1836
  }
795
1837
  //#endregion
@@ -4188,44 +5230,6 @@ var ConvexClient = class {
4188
5230
  }
4189
5231
  };
4190
5232
  //#endregion
4191
- //#region \0@oxc-project+runtime@0.151.0/helpers/esm/typeof.js
4192
- function _typeof(o) {
4193
- "@babel/helpers - typeof";
4194
- return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
4195
- return typeof o;
4196
- } : function(o) {
4197
- return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o;
4198
- }, _typeof(o);
4199
- }
4200
- //#endregion
4201
- //#region \0@oxc-project+runtime@0.151.0/helpers/esm/toPrimitive.js
4202
- function toPrimitive(t, r) {
4203
- if ("object" != _typeof(t) || !t) return t;
4204
- var e = t[Symbol.toPrimitive];
4205
- if (void 0 !== e) {
4206
- var i = e.call(t, r || "default");
4207
- if ("object" != _typeof(i)) return i;
4208
- throw new TypeError("@@toPrimitive must return a primitive value.");
4209
- }
4210
- return ("string" === r ? String : Number)(t);
4211
- }
4212
- //#endregion
4213
- //#region \0@oxc-project+runtime@0.151.0/helpers/esm/toPropertyKey.js
4214
- function toPropertyKey(t) {
4215
- var i = toPrimitive(t, "string");
4216
- return "symbol" == _typeof(i) ? i : i + "";
4217
- }
4218
- //#endregion
4219
- //#region \0@oxc-project+runtime@0.151.0/helpers/esm/defineProperty.js
4220
- function _defineProperty(e, r, t) {
4221
- return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
4222
- value: t,
4223
- enumerable: !0,
4224
- configurable: !0,
4225
- writable: !0
4226
- }) : e[r] = t, e;
4227
- }
4228
- //#endregion
4229
5233
  //#region src/api.ts
4230
5234
  var NuniApiError = class extends Error {
4231
5235
  constructor(message, code) {
@@ -4275,6 +5279,21 @@ var NuniApi = class {
4275
5279
  sessionToken
4276
5280
  }, cb, () => {});
4277
5281
  }
5282
+ /** A comment with the owner-only context (screenshot, console, DOM). */
5283
+ onOwnerComment(id, sessionToken, cb) {
5284
+ return this.client.onUpdate(api.comments.getForOwner, {
5285
+ publicId: this.config.project,
5286
+ id,
5287
+ sessionToken
5288
+ }, (comment) => cb(comment), () => {});
5289
+ }
5290
+ /** Replies and reactions of one thread, live while its card is open. */
5291
+ onThread(commentId, cb) {
5292
+ return this.client.onUpdate(api.replies.listForComment, {
5293
+ publicId: this.config.project,
5294
+ commentId
5295
+ }, (thread) => cb(thread), () => {});
5296
+ }
4278
5297
  /** One comment by id, for deep links outside the page listing window. */
4279
5298
  getComment(id) {
4280
5299
  return this.client.query(api.comments.getById, {
@@ -4300,6 +5319,42 @@ var NuniApi = class {
4300
5319
  if (!response.ok || !data.id) throw new NuniApiError(data.message ?? "Couldn't post the comment", data.code ?? "error");
4301
5320
  return data.id;
4302
5321
  }
5322
+ /** Through the HTTP action, so replies are rate limited per IP. */
5323
+ async createReply(reply) {
5324
+ let response;
5325
+ try {
5326
+ response = await fetch(`${this.config.convexSiteUrl}/widget/replies`, {
5327
+ method: "POST",
5328
+ headers: { "Content-Type": "application/json" },
5329
+ body: JSON.stringify({
5330
+ publicId: this.config.project,
5331
+ ...reply
5332
+ })
5333
+ });
5334
+ } catch {
5335
+ throw new NuniApiError("Couldn't reach Nuni. Check your connection.", "network");
5336
+ }
5337
+ const data = await response.json().catch(() => ({}));
5338
+ if (!response.ok || !data.id) throw new NuniApiError(data.message ?? "Couldn't post the reply", data.code ?? "error");
5339
+ return data.id;
5340
+ }
5341
+ /** Attach a screenshot to the author's own, just-posted comment. */
5342
+ async uploadScreenshot(id, authorSecret, image) {
5343
+ const response = await fetch(`${this.config.convexSiteUrl}/widget/screenshot`, {
5344
+ method: "POST",
5345
+ headers: {
5346
+ "Content-Type": image.type,
5347
+ "X-Nuni-Project": this.config.project,
5348
+ "X-Nuni-Comment": id,
5349
+ "X-Nuni-Author": authorSecret
5350
+ },
5351
+ body: image
5352
+ });
5353
+ if (!response.ok) {
5354
+ const data = await response.json().catch(() => ({}));
5355
+ throw new NuniApiError(data.message ?? "Couldn't attach the screenshot", data.code ?? "error");
5356
+ }
5357
+ }
4303
5358
  async run(promise) {
4304
5359
  try {
4305
5360
  return await promise;
@@ -4332,6 +5387,34 @@ var NuniApi = class {
4332
5387
  sessionToken
4333
5388
  }));
4334
5389
  }
5390
+ editReply(id, authorSecret, body) {
5391
+ return this.run(this.client.mutation(api.replies.editOwn, {
5392
+ id,
5393
+ authorSecret,
5394
+ body
5395
+ }));
5396
+ }
5397
+ deleteReply(id, authorSecret) {
5398
+ return this.run(this.client.mutation(api.replies.deleteOwn, {
5399
+ id,
5400
+ authorSecret
5401
+ }));
5402
+ }
5403
+ removeReply(id, sessionToken) {
5404
+ return this.run(this.client.mutation(api.replies.remove, {
5405
+ id,
5406
+ sessionToken
5407
+ }));
5408
+ }
5409
+ toggleReaction(commentId, targetId, emoji, authorSecret) {
5410
+ return this.run(this.client.mutation(api.reactions.toggle, {
5411
+ publicId: this.config.project,
5412
+ commentId,
5413
+ targetId,
5414
+ emoji,
5415
+ authorSecret
5416
+ }));
5417
+ }
4335
5418
  remove(id, sessionToken) {
4336
5419
  return this.run(this.client.mutation(api.comments.remove, {
4337
5420
  id,
@@ -4366,17 +5449,6 @@ function icon(svg) {
4366
5449
  span.append(document.importNode(parsed.documentElement, true));
4367
5450
  return span;
4368
5451
  }
4369
- function timeAgo(ts, now = Date.now()) {
4370
- const s = Math.max(0, Math.round((now - ts) / 1e3));
4371
- if (s < 45) return "just now";
4372
- const m = Math.round(s / 60);
4373
- if (m < 60) return `${m}m ago`;
4374
- const hr = Math.round(m / 60);
4375
- if (hr < 24) return `${hr}h ago`;
4376
- const d = Math.round(hr / 24);
4377
- if (d < 30) return `${d}d ago`;
4378
- return new Date(ts).toLocaleDateString();
4379
- }
4380
5452
  function initials(name) {
4381
5453
  const parts = name.trim().split(/\s+/).filter(Boolean);
4382
5454
  return (parts.length > 1 ? parts[0][0] + parts[parts.length - 1][0] : (parts[0] ?? "?").slice(0, 2)).toUpperCase();
@@ -4385,7 +5457,221 @@ function initials(name) {
4385
5457
  function colorFor(name) {
4386
5458
  let hash = 0;
4387
5459
  for (const ch of name) hash = hash * 31 + ch.charCodeAt(0) >>> 0;
4388
- return `oklch(0.62 0.17 ${hash % 360})`;
5460
+ return `oklch(0.5 0.17 ${hash % 360})`;
5461
+ }
5462
+ /** Copy text, falling back to execCommand where the async clipboard is off (plain http previews). */
5463
+ async function copyText(text) {
5464
+ try {
5465
+ await navigator.clipboard.writeText(text);
5466
+ return true;
5467
+ } catch {}
5468
+ const area = document.createElement("textarea");
5469
+ area.value = text;
5470
+ area.setAttribute("readonly", "");
5471
+ area.style.cssText = "position:fixed;top:0;left:0;opacity:0";
5472
+ document.body.appendChild(area);
5473
+ area.select();
5474
+ try {
5475
+ return document.execCommand("copy");
5476
+ } catch {
5477
+ return false;
5478
+ } finally {
5479
+ area.remove();
5480
+ }
5481
+ }
5482
+ /** Any CSS color as RGB, using the browser's own parser. */
5483
+ function parseColor(color) {
5484
+ const probe = document.createElement("span");
5485
+ probe.style.color = color;
5486
+ if (!probe.style.color) return null;
5487
+ probe.style.display = "none";
5488
+ document.documentElement.appendChild(probe);
5489
+ const computed = getComputedStyle(probe).color;
5490
+ probe.remove();
5491
+ if (/^rgba?\(/.test(computed)) {
5492
+ const parts = computed.match(/[\d.]+/g)?.map(Number);
5493
+ if (parts && parts.length >= 3) return [
5494
+ parts[0],
5495
+ parts[1],
5496
+ parts[2]
5497
+ ];
5498
+ }
5499
+ try {
5500
+ const canvas = document.createElement("canvas");
5501
+ canvas.width = canvas.height = 1;
5502
+ const ctx = canvas.getContext("2d");
5503
+ if (!ctx) return null;
5504
+ ctx.fillStyle = color;
5505
+ ctx.fillRect(0, 0, 1, 1);
5506
+ const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
5507
+ return [
5508
+ r,
5509
+ g,
5510
+ b
5511
+ ];
5512
+ } catch {
5513
+ return null;
5514
+ }
5515
+ }
5516
+ /** Black or white text, whichever reads better on the color (WCAG). */
5517
+ function readableOn([r, g, b]) {
5518
+ const channel = (c) => {
5519
+ const v = c / 255;
5520
+ return v <= .03928 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4;
5521
+ };
5522
+ const l = .2126 * channel(r) + .7152 * channel(g) + .0722 * channel(b);
5523
+ return 1.05 / (l + .05) >= (l + .05) / .05 ? "#ffffff" : "#111111";
5524
+ }
5525
+ //#endregion
5526
+ //#region src/i18n.ts
5527
+ /**
5528
+ * Every word the widget shows. Override any of them with the `messages`
5529
+ * option; `locale` sets how times and counts are written.
5530
+ *
5531
+ * `{name}` is filled in. A message with forms (`one`, `other`, and `zero`,
5532
+ * `two`, `few`, `many` where a language has them) is picked by `{count}`.
5533
+ */
5534
+ const EN = {
5535
+ toolbar: "Nuni comments",
5536
+ comment: "Comment",
5537
+ addComment: "Add a comment",
5538
+ addCommentKey: "Add a comment ({key})",
5539
+ cancelAdding: "Cancel adding a comment",
5540
+ pickElement: "Pick an element",
5541
+ allComments: "All comments",
5542
+ openCount: "Comments on this page: {count} open",
5543
+ commentSelection: "Comment on the selected text",
5544
+ commentSelectionKey: "Comment on the selected text ({key})",
5545
+ pickHint: "Click anything to comment, or drag to mark an area",
5546
+ pickHintMobile: "Tap anything to comment",
5547
+ pickHintKeys: "Arrow keys move, Enter comments, Esc cancels",
5548
+ picked: "Selected {element}",
5549
+ comments: "Comments",
5550
+ close: "Close",
5551
+ tabOpen: "Open ({count})",
5552
+ tabResolved: "Resolved ({count})",
5553
+ loading: "Loading…",
5554
+ noOpen: "No open comments on this page.",
5555
+ noOpenKey: "No open comments on this page. Press {key} to add one.",
5556
+ noResolved: "Nothing resolved yet.",
5557
+ notFoundHere: "Couldn't find on this page",
5558
+ otherPages: "Other pages",
5559
+ openOnPage: "{count} open",
5560
+ replyCount: {
5561
+ one: "{count} reply",
5562
+ other: "{count} replies"
5563
+ },
5564
+ edited: "edited",
5565
+ ownSite: "Own this website?",
5566
+ claim: "Claim Nuni",
5567
+ yourName: "Your name",
5568
+ leaveComment: "Leave a comment",
5569
+ notYou: "Not you?",
5570
+ screenshotAlt: "Screenshot that will be attached",
5571
+ screenshotNote: "Screenshot attached. Only the site owner sees it.",
5572
+ remove: "Remove",
5573
+ cancel: "Cancel",
5574
+ post: "Post",
5575
+ posting: "Posting…",
5576
+ save: "Save",
5577
+ send: "{key} + Enter",
5578
+ pinLabel: "Comment by {name}: {body}",
5579
+ commentBy: "Comment by {name}",
5580
+ openScreenshot: "Open the screenshot",
5581
+ screenshotTaken: "Screenshot taken when the comment was left",
5582
+ resolved: "Resolved",
5583
+ leftOn: "Left on {origin}",
5584
+ approximate: "Approximate",
5585
+ approximateHint: "The page changed; this pin is a best guess",
5586
+ notFound: "Element not found",
5587
+ originallyOn: "Originally on: {text}",
5588
+ resolve: "Resolve",
5589
+ reopen: "Reopen",
5590
+ copyForAgent: "Copy for agent",
5591
+ copyForAgentHint: "Copy for your coding agent",
5592
+ loadingContext: "Loading page context…",
5593
+ edit: "Edit",
5594
+ delete: "Delete",
5595
+ owner: "Owner",
5596
+ addReaction: "Add a reaction",
5597
+ reactions: "Reactions",
5598
+ reactWith: "React with {emoji}",
5599
+ reply: "Reply",
5600
+ replyFirst: "Reply to start a thread",
5601
+ sendReply: "Send reply",
5602
+ editReply: "Edit reply",
5603
+ deleteReply: "Delete reply",
5604
+ confirmDeleteComment: "Delete this comment? This can't be undone.",
5605
+ confirmDeleteReply: "Delete this reply?",
5606
+ added: "Comment added",
5607
+ saved: "Saved",
5608
+ deleted: "Deleted",
5609
+ replyDeleted: "Reply deleted",
5610
+ reopened: "Reopened",
5611
+ copied: "Copied for your coding agent",
5612
+ copyFailed: "Couldn't copy",
5613
+ signedIn: "You're signed in as the owner",
5614
+ linkGone: "That comment was deleted or is on another page",
5615
+ enterName: "Enter your name",
5616
+ writeFirst: "Write a comment first",
5617
+ commentTooLong: "Comments are limited to {count} characters",
5618
+ replyTooLong: "Replies are limited to {count} characters",
5619
+ postFailed: "Couldn't post the comment",
5620
+ replyFailed: "Couldn't post the reply",
5621
+ reactFailed: "Couldn't react",
5622
+ failed: "Something went wrong",
5623
+ justNow: "just now"
5624
+ };
5625
+ function isApple() {
5626
+ if (typeof navigator === "undefined") return false;
5627
+ const platform = navigator.userAgentData?.platform ?? navigator.platform ?? "";
5628
+ return /mac|iphone|ipad|ipod/i.test(platform || navigator.userAgent);
5629
+ }
5630
+ function createI18n(locale, overrides = {}) {
5631
+ const messages = {
5632
+ ...EN,
5633
+ ...overrides
5634
+ };
5635
+ let plural = null;
5636
+ let relative = null;
5637
+ try {
5638
+ plural = new Intl.PluralRules(locale);
5639
+ relative = new Intl.RelativeTimeFormat(locale, {
5640
+ numeric: "auto",
5641
+ style: "narrow"
5642
+ });
5643
+ } catch {
5644
+ plural = new Intl.PluralRules();
5645
+ relative = new Intl.RelativeTimeFormat(void 0, {
5646
+ numeric: "auto",
5647
+ style: "narrow"
5648
+ });
5649
+ }
5650
+ const t = (key, vars = {}) => {
5651
+ const message = messages[key] ?? EN[key];
5652
+ return (typeof message === "string" ? message : message[plural.select(Number(vars.count ?? 0))] ?? message.other).replace(/\{(\w+)\}/g, (match, name) => name in vars ? String(vars[name]) : match);
5653
+ };
5654
+ const timeAgo = (ts, now = Date.now()) => {
5655
+ const s = Math.max(0, Math.round((now - ts) / 1e3));
5656
+ if (s < 45) return t("justNow");
5657
+ const m = Math.round(s / 60);
5658
+ if (m < 60) return relative.format(-m, "minute");
5659
+ const hr = Math.round(m / 60);
5660
+ if (hr < 24) return relative.format(-hr, "hour");
5661
+ const d = Math.round(hr / 24);
5662
+ if (d < 30) return relative.format(-d, "day");
5663
+ try {
5664
+ return new Date(ts).toLocaleDateString(locale);
5665
+ } catch {
5666
+ return new Date(ts).toLocaleDateString();
5667
+ }
5668
+ };
5669
+ return {
5670
+ locale,
5671
+ t,
5672
+ timeAgo,
5673
+ mod: isApple() ? "⌘" : "Ctrl"
5674
+ };
4389
5675
  }
4390
5676
  //#endregion
4391
5677
  //#region src/icons.ts
@@ -4399,6 +5685,8 @@ const ICONS = {
4399
5685
  trash: s("<path d=\"M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6\"/>"),
4400
5686
  edit: s("<path d=\"M12 20h9\"/><path d=\"M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z\"/>"),
4401
5687
  send: s("<path d=\"m22 2-7 20-4-9-9-4z\"/><path d=\"M22 2 11 13\"/>"),
5688
+ bot: s("<path d=\"M12 8V4H8\"/><rect width=\"16\" height=\"12\" x=\"4\" y=\"8\" rx=\"2\"/><path d=\"M2 14h2M20 14h2M15 13v2M9 13v2\"/>"),
5689
+ smile: s("<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M8 14s1.5 2 4 2 4-2 4-2M9 9h.01M15 9h.01\"/>"),
4402
5690
  target: s("<circle cx=\"12\" cy=\"12\" r=\"9\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/>"),
4403
5691
  github: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M12 .5a11.5 11.5 0 0 0-3.6 22.4c.6.1.8-.3.8-.6v-2c-3.2.7-3.9-1.5-3.9-1.5-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2a11 11 0 0 1 5.8 0c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.9 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A11.5 11.5 0 0 0 12 .5z"/></svg>`,
4404
5692
  link: s("<path d=\"M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6\"/>")
@@ -4635,7 +5923,7 @@ const STYLES = `
4635
5923
  -moz-osx-font-smoothing: grayscale;
4636
5924
  }
4637
5925
  @media (prefers-color-scheme: dark) {
4638
- :host {
5926
+ :host(:not([data-theme="light"])) {
4639
5927
  --n-bg: #1d1b20;
4640
5928
  --n-bg-subtle: #2a272e;
4641
5929
  --n-fg: #f4f2f6;
@@ -4646,6 +5934,18 @@ const STYLES = `
4646
5934
  --n-shadow: 0 1px 2px rgba(0,0,0,0.4), 0 12px 32px rgba(0,0,0,0.5);
4647
5935
  }
4648
5936
  }
5937
+ :host([data-theme="dark"]) {
5938
+ --n-bg: #1d1b20;
5939
+ --n-bg-subtle: #2a272e;
5940
+ --n-fg: #f4f2f6;
5941
+ --n-fg-muted: #a8a2ae;
5942
+ --n-border: rgba(255, 255, 255, 0.12);
5943
+ --n-accent: #ff4d94;
5944
+ --n-accent-soft: rgba(255, 77, 148, 0.18);
5945
+ --n-shadow: 0 1px 2px rgba(0,0,0,0.4), 0 12px 32px rgba(0,0,0,0.5);
5946
+ }
5947
+ :host([data-theme="light"]) { color-scheme: light; }
5948
+ :host([data-theme="dark"]) { color-scheme: dark; }
4649
5949
  *, *::before, *::after { box-sizing: border-box; }
4650
5950
  button, input, textarea { font: inherit; color: inherit; }
4651
5951
  button { cursor: pointer; }
@@ -4699,6 +5999,10 @@ button { cursor: pointer; }
4699
5999
 
4700
6000
  /* Picking */
4701
6001
  .pick-hint {
6002
+ display: flex;
6003
+ gap: 8px;
6004
+ align-items: center;
6005
+ max-width: calc(100vw - 24px);
4702
6006
  position: fixed;
4703
6007
  top: 12px;
4704
6008
  left: 50%;
@@ -4735,6 +6039,94 @@ button { cursor: pointer; }
4735
6039
  text-overflow: ellipsis;
4736
6040
  }
4737
6041
 
6042
+ .drag-box { transition: none; border-style: dashed; }
6043
+
6044
+ /* Text and area comments */
6045
+ .marks { position: fixed; inset: 0; pointer-events: none; }
6046
+ .mark {
6047
+ position: fixed;
6048
+ border-bottom: 2px solid var(--n-accent);
6049
+ background: var(--n-accent-soft);
6050
+ opacity: 0.7;
6051
+ pointer-events: none;
6052
+ }
6053
+ .mark-active { opacity: 1; background: color-mix(in srgb, var(--n-accent) 26%, transparent); }
6054
+ .area {
6055
+ position: fixed;
6056
+ border: 2px dashed var(--n-accent);
6057
+ border-radius: 4px;
6058
+ background: var(--n-accent-soft);
6059
+ pointer-events: none;
6060
+ }
6061
+ .select-btn {
6062
+ position: fixed;
6063
+ left: 0;
6064
+ top: 0;
6065
+ display: inline-flex;
6066
+ align-items: center;
6067
+ gap: 6px;
6068
+ height: 32px;
6069
+ padding: 0 12px;
6070
+ border: 0;
6071
+ border-radius: 999px;
6072
+ background: var(--n-fg);
6073
+ color: var(--n-bg);
6074
+ font-weight: 550;
6075
+ font-size: 13px;
6076
+ box-shadow: var(--n-shadow);
6077
+ pointer-events: auto;
6078
+ }
6079
+ .select-btn[hidden] { display: none; }
6080
+ .quote {
6081
+ margin: 0;
6082
+ padding: 2px 0 2px 10px;
6083
+ border-left: 3px solid var(--n-accent);
6084
+ color: var(--n-fg-muted);
6085
+ font-style: italic;
6086
+ white-space: pre-wrap;
6087
+ overflow-wrap: anywhere;
6088
+ max-height: 4.5em;
6089
+ overflow: hidden;
6090
+ }
6091
+ .item-quote {
6092
+ margin-top: 4px;
6093
+ padding-left: 8px;
6094
+ border-left: 2px solid var(--n-accent);
6095
+ color: var(--n-fg-muted);
6096
+ font-size: 12px;
6097
+ font-style: italic;
6098
+ white-space: nowrap;
6099
+ overflow: hidden;
6100
+ text-overflow: ellipsis;
6101
+ }
6102
+
6103
+ .pick-keys { opacity: 0.7; }
6104
+ .pick-keys::before { content: "·"; margin-right: 8px; }
6105
+ @media (max-width: 900px) { .pick-keys { display: none; } }
6106
+
6107
+ /* Confirm dialog */
6108
+ .confirm-backdrop {
6109
+ position: fixed;
6110
+ inset: 0;
6111
+ display: grid;
6112
+ place-items: center;
6113
+ background: rgba(0, 0, 0, 0.32);
6114
+ pointer-events: auto;
6115
+ }
6116
+ .confirm {
6117
+ width: min(360px, calc(100vw - 32px));
6118
+ padding: 16px;
6119
+ border-radius: var(--n-radius);
6120
+ background: var(--n-bg);
6121
+ color: var(--n-fg);
6122
+ border: 1px solid var(--n-border);
6123
+ box-shadow: var(--n-shadow);
6124
+ display: grid;
6125
+ gap: 12px;
6126
+ }
6127
+ .confirm p { margin: 0; }
6128
+ .btn-danger-solid { background: var(--n-danger); color: #fff; border-color: transparent; }
6129
+
4738
6130
  /* Pins */
4739
6131
  .pin {
4740
6132
  position: fixed;
@@ -4760,6 +6152,22 @@ button { cursor: pointer; }
4760
6152
  .pin:hover, .pin[data-active="true"] { z-index: 2; }
4761
6153
  .pin[data-active="true"] { box-shadow: 0 0 0 3px var(--n-accent), 0 2px 8px rgba(0,0,0,0.25); }
4762
6154
  .pin[data-status="resolved"] { opacity: 0.55; filter: grayscale(0.6); }
6155
+ .pin[data-replies]::after {
6156
+ content: attr(data-replies);
6157
+ position: absolute;
6158
+ top: -6px;
6159
+ right: -8px;
6160
+ min-width: 16px;
6161
+ height: 16px;
6162
+ padding: 0 4px;
6163
+ border-radius: 999px;
6164
+ background: var(--n-bg);
6165
+ color: var(--n-fg);
6166
+ border: 1px solid var(--n-border);
6167
+ font-size: 10px;
6168
+ line-height: 14px;
6169
+ text-align: center;
6170
+ }
4763
6171
  .pin[data-confidence="low"] { border-style: dashed; }
4764
6172
  .pin[hidden] { display: none; }
4765
6173
  .pin-pending { opacity: 0.7; }
@@ -4778,6 +6186,43 @@ button { cursor: pointer; }
4778
6186
  overflow: hidden;
4779
6187
  }
4780
6188
  .card-body { padding: 12px; display: grid; gap: 10px; }
6189
+ .card[data-card="thread"] { display: flex; flex-direction: column; max-height: calc(100vh - 24px); }
6190
+ .thread { border-top: 1px solid var(--n-border); display: grid; min-height: 0; }
6191
+ .replies { display: grid; gap: 12px; padding: 12px; overflow-y: auto; max-height: 45vh; }
6192
+ .reply { display: grid; gap: 6px; }
6193
+ .avatar-sm { width: 20px; height: 20px; font-size: 9px; }
6194
+ .badge-owner { background: var(--n-accent-soft); color: var(--n-accent); }
6195
+ .reply-form { display: flex; align-items: flex-end; gap: 8px; padding: 8px 12px 12px; flex-wrap: wrap; }
6196
+ .reply-form .field { flex: 1 1 100%; }
6197
+ /* A fixed size: growing on focus would move the Send button mid-click. */
6198
+ .reply-form .field-reply { flex: 1 1 0; min-height: 56px; height: 56px; resize: none; }
6199
+ .reactions { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; position: relative; }
6200
+ .reaction {
6201
+ display: inline-flex;
6202
+ align-items: center;
6203
+ gap: 4px;
6204
+ height: 24px;
6205
+ padding: 0 8px;
6206
+ border: 1px solid var(--n-border);
6207
+ border-radius: 999px;
6208
+ background: var(--n-bg);
6209
+ color: var(--n-fg);
6210
+ font-size: 12px;
6211
+ }
6212
+ .reaction:hover { background: var(--n-bg-subtle); }
6213
+ .reaction[aria-pressed="true"] { border-color: var(--n-accent); background: var(--n-accent-soft); }
6214
+ .reaction-add { color: var(--n-fg-muted); padding: 0 6px; }
6215
+ .reaction-picker {
6216
+ display: flex;
6217
+ gap: 2px;
6218
+ padding: 4px;
6219
+ border: 1px solid var(--n-border);
6220
+ border-radius: 999px;
6221
+ background: var(--n-bg);
6222
+ box-shadow: var(--n-shadow);
6223
+ }
6224
+ .reaction-picker .reaction { border-color: transparent; font-size: 14px; }
6225
+ .btn-xs { width: 24px; height: 24px; }
4781
6226
  .card-head { display: flex; align-items: center; gap: 8px; }
4782
6227
  .card-head .spacer { flex: 1; }
4783
6228
  .avatar {
@@ -4810,6 +6255,26 @@ button { cursor: pointer; }
4810
6255
  }
4811
6256
  .badge-ok { background: rgba(31,157,85,0.14); color: var(--n-ok); }
4812
6257
  .comment-body { white-space: pre-wrap; word-break: break-word; }
6258
+ .shot {
6259
+ display: block;
6260
+ border: 1px solid var(--n-border);
6261
+ border-radius: 8px;
6262
+ overflow: hidden;
6263
+ background: var(--n-bg-subtle);
6264
+ }
6265
+ .shot img { display: block; width: 100%; max-height: 160px; object-fit: cover; object-position: top; }
6266
+ .shot-preview { display: grid; gap: 4px; }
6267
+ .shot-preview img {
6268
+ display: block;
6269
+ width: 100%;
6270
+ max-height: 200px;
6271
+ /* The whole image, so the commenter sees everything that is attached. */
6272
+ object-fit: contain;
6273
+ border: 1px solid var(--n-border);
6274
+ border-radius: 8px;
6275
+ background: var(--n-bg-subtle);
6276
+ }
6277
+ .shot-note { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--n-fg-muted); }
4813
6278
  .field {
4814
6279
  width: 100%;
4815
6280
  padding: 8px 10px;
@@ -4932,14 +6397,74 @@ a.item { color: inherit; text-decoration: none; }
4932
6397
  @media (prefers-reduced-motion: reduce) {
4933
6398
  * { transition: none !important; animation: none !important; }
4934
6399
  }
6400
+
6401
+ /* The toolbar's corner (the position option) */
6402
+ :host([data-position$="left"]) .toolbar { right: auto; left: 16px; }
6403
+ :host([data-position^="top"]) .toolbar { bottom: auto; top: 16px; }
6404
+ :host([data-position$="left"]) .panel { right: auto; left: 12px; }
6405
+ :host([data-position^="top"]) .panel { top: 72px; bottom: 12px; }
6406
+ :host([data-position^="top"]) .toast { bottom: auto; top: 72px; }
6407
+ @media (max-width: 640px) {
6408
+ :host([data-position$="left"]) .toolbar { left: 12px; }
6409
+ :host([data-position^="top"]) .toolbar { top: 12px; }
6410
+ }
4935
6411
  `;
4936
6412
  //#endregion
4937
6413
  //#region src/widget.ts
4938
6414
  const PICKING_CLASS = "nuni-picking";
6415
+ const PICK_EVENTS = [
6416
+ "pointerdown",
6417
+ "mousedown",
6418
+ "pointerup",
6419
+ "mouseup"
6420
+ ];
6421
+ /** How far the pointer moves before a press becomes an area drag. */
6422
+ const DRAG_THRESHOLD = 6;
6423
+ /** The rectangles of a range that have a size (lines of text). */
6424
+ function lineRects(range) {
6425
+ return Array.from(range.getClientRects()).filter((r) => r.width > 0 && r.height > 0);
6426
+ }
6427
+ /** An area comment's box on screen, from its element's box. */
6428
+ function regionBox(anchor, el) {
6429
+ const region = anchor.region;
6430
+ if (!region) return null;
6431
+ const r = viewportRect(el, document);
6432
+ return {
6433
+ left: r.left + region.x * r.width,
6434
+ top: r.top + region.y * r.height,
6435
+ width: region.w * r.width,
6436
+ height: region.h * r.height
6437
+ };
6438
+ }
6439
+ const collapse = (text) => text.replace(/\s+/g, " ").trim();
6440
+ /** Run when the browser is idle, or soon. */
6441
+ function whenIdle(fn) {
6442
+ if (typeof requestIdleCallback === "function") requestIdleCallback(fn, { timeout: 100 });
6443
+ else setTimeout(fn, 0);
6444
+ }
6445
+ /** Same-origin iframe documents on the page, nested ones too. */
6446
+ function frameDocuments(doc, depth = 0) {
6447
+ if (depth > 3) return [];
6448
+ const out = [];
6449
+ for (const frame of Array.from(doc.querySelectorAll("iframe"))) {
6450
+ const inner = frameDocument(frame);
6451
+ if (inner?.documentElement) out.push(inner, ...frameDocuments(inner, depth + 1));
6452
+ }
6453
+ return out;
6454
+ }
4939
6455
  const MOBILE_QUERY = "(max-width: 640px)";
4940
6456
  function isTypingTarget(target) {
4941
- if (!(target instanceof HTMLElement)) return false;
4942
- return target.isContentEditable || target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT";
6457
+ if (!target || target.nodeType !== 1) return false;
6458
+ const el = target;
6459
+ return el.isContentEditable || el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT";
6460
+ }
6461
+ function dataUrl(blob) {
6462
+ return new Promise((resolve, reject) => {
6463
+ const reader = new FileReader();
6464
+ reader.onload = () => resolve(String(reader.result));
6465
+ reader.onerror = () => reject(reader.error);
6466
+ reader.readAsDataURL(blob);
6467
+ });
4943
6468
  }
4944
6469
  function describeElement(el) {
4945
6470
  const tag = el.tagName.toLowerCase();
@@ -4949,12 +6474,15 @@ function describeElement(el) {
4949
6474
  return `${tag}${id}${cls}${text ? ` "${text}"` : ""}`;
4950
6475
  }
4951
6476
  var NuniWidget = class {
4952
- constructor(config) {
6477
+ constructor(config, runtime) {
4953
6478
  this.config = config;
6479
+ this.runtime = runtime;
4954
6480
  _defineProperty(this, "host", void 0);
4955
6481
  _defineProperty(this, "root", void 0);
6482
+ _defineProperty(this, "markLayer", void 0);
4956
6483
  _defineProperty(this, "pinLayer", void 0);
4957
6484
  _defineProperty(this, "overlayLayer", void 0);
6485
+ _defineProperty(this, "selectButton", void 0);
4958
6486
  _defineProperty(this, "uiLayer", void 0);
4959
6487
  _defineProperty(this, "api", void 0);
4960
6488
  _defineProperty(this, "cleanups", []);
@@ -4982,10 +6510,32 @@ var NuniWidget = class {
4982
6510
  _defineProperty(this, "ownerName", null);
4983
6511
  _defineProperty(this, "deepLinkId", null);
4984
6512
  _defineProperty(this, "extraComment", null);
6513
+ _defineProperty(this, "ownerDetail", null);
6514
+ _defineProperty(this, "ownerDetailId", null);
6515
+ _defineProperty(this, "ownerDetailLoaded", false);
6516
+ _defineProperty(this, "ownerDetailUnsub", null);
6517
+ _defineProperty(this, "thread", null);
6518
+ _defineProperty(this, "threadId", null);
6519
+ _defineProperty(this, "threadUnsub", null);
6520
+ _defineProperty(this, "resolveRun", 0);
6521
+ _defineProperty(this, "scopeWatchers", /* @__PURE__ */ new Map());
6522
+ _defineProperty(this, "pickDocs", []);
6523
+ _defineProperty(this, "dragStart", null);
6524
+ _defineProperty(this, "dragBox", null);
6525
+ _defineProperty(this, "dragEnded", false);
6526
+ _defineProperty(this, "selection", null);
6527
+ _defineProperty(this, "pointerDown", false);
6528
+ _defineProperty(this, "selectionFrame", 0);
6529
+ _defineProperty(this, "returnFocus", null);
6530
+ _defineProperty(this, "focusNext", null);
6531
+ _defineProperty(this, "confirming", null);
6532
+ _defineProperty(this, "live", void 0);
4985
6533
  _defineProperty(this, "layoutFrame", 0);
4986
6534
  _defineProperty(this, "pinPositions", /* @__PURE__ */ new Map());
4987
6535
  _defineProperty(this, "resolveTimer", 0);
6536
+ _defineProperty(this, "i18n", void 0);
4988
6537
  _defineProperty(this, "isIgnored", (el) => el === this.host || this.host.contains(el));
6538
+ _defineProperty(this, "onFrameKey", (e) => this.onKeyDown(e));
4989
6539
  _defineProperty(this, "swallow", (e) => {
4990
6540
  if (!this.fromPage(e)) return;
4991
6541
  if (e.type === "pointerdown" && e instanceof PointerEvent) {
@@ -4994,11 +6544,41 @@ var NuniWidget = class {
4994
6544
  this.hoverEl = pickTarget(target);
4995
6545
  this.renderHighlight();
4996
6546
  }
6547
+ this.dragEnded = false;
6548
+ this.dragStart = e.pointerType !== "touch" && e.button === 0 && (target?.ownerDocument ?? null) === document ? {
6549
+ x: e.clientX,
6550
+ y: e.clientY
6551
+ } : null;
4997
6552
  }
6553
+ if (e.type === "pointerup" && this.dragBox) {
6554
+ const box = this.dragBox;
6555
+ this.dragStart = null;
6556
+ this.dragBox = null;
6557
+ this.dragEnded = true;
6558
+ setTimeout(() => {
6559
+ this.dragEnded = false;
6560
+ if (this.picking) this.commentOnArea(box);
6561
+ }, 0);
6562
+ } else if (e.type === "pointerup") this.dragStart = null;
4998
6563
  e.preventDefault();
4999
6564
  e.stopPropagation();
5000
6565
  });
5001
6566
  _defineProperty(this, "onPickMove", (e) => {
6567
+ const start = this.dragStart;
6568
+ if (start) {
6569
+ const dx = e.clientX - start.x;
6570
+ const dy = e.clientY - start.y;
6571
+ if (this.dragBox || Math.hypot(dx, dy) > DRAG_THRESHOLD) {
6572
+ this.dragBox = {
6573
+ left: Math.min(start.x, e.clientX),
6574
+ top: Math.min(start.y, e.clientY),
6575
+ width: Math.abs(dx),
6576
+ height: Math.abs(dy)
6577
+ };
6578
+ this.renderHighlight();
6579
+ return;
6580
+ }
6581
+ }
5002
6582
  const target = this.fromPage(e);
5003
6583
  const next = target ? pickTarget(target) : null;
5004
6584
  if (next === this.hoverEl) return;
@@ -5011,22 +6591,21 @@ var NuniWidget = class {
5011
6591
  e.preventDefault();
5012
6592
  e.stopPropagation();
5013
6593
  e.stopImmediatePropagation();
6594
+ if (this.dragEnded) return;
5014
6595
  const element = pickTarget(target);
5015
6596
  const anchor = captureAnchor(element, {
5016
- x: e.clientX,
5017
- y: e.clientY
5018
- }, { ignoreAttributePrefixes: [] });
5019
- this.setPicking(false);
5020
- this.card = {
5021
- kind: "composer",
5022
- draft: {
5023
- anchor,
5024
- element
5025
- }
5026
- };
5027
- this.render();
5028
- this.focusComposer();
6597
+ x: e.clientX,
6598
+ y: e.clientY
6599
+ }, {
6600
+ ignoreAttributePrefixes: [],
6601
+ document
6602
+ });
6603
+ this.openComposer({
6604
+ anchor,
6605
+ element
6606
+ });
5029
6607
  });
6608
+ this.i18n = createI18n(config.locale, config.messages);
5030
6609
  let secret = read(KEYS.secret);
5031
6610
  if (!secret) {
5032
6611
  secret = generateSecret();
@@ -5037,15 +6616,56 @@ var NuniWidget = class {
5037
6616
  this.host = document.createElement("div");
5038
6617
  this.host.id = "nuni-root";
5039
6618
  this.host.setAttribute("data-nuni", "");
6619
+ this.applyAppearance();
5040
6620
  this.root = this.host.attachShadow({ mode: "open" });
5041
6621
  const style = document.createElement("style");
5042
6622
  style.textContent = STYLES;
6623
+ this.markLayer = h("div", {
6624
+ class: "marks",
6625
+ "aria-hidden": "true"
6626
+ });
5043
6627
  this.pinLayer = h("div", { class: "pins" });
5044
6628
  this.overlayLayer = h("div", { class: "overlay" });
5045
6629
  this.uiLayer = h("div", { class: "ui" });
5046
- this.root.append(style, this.pinLayer, this.overlayLayer, this.uiLayer);
6630
+ this.selectButton = h("button", {
6631
+ class: "select-btn",
6632
+ type: "button",
6633
+ hidden: true,
6634
+ title: this.withKey("commentSelection", "commentSelectionKey"),
6635
+ onmousedown: (e) => e.preventDefault(),
6636
+ onpointerdown: (e) => e.preventDefault(),
6637
+ onclick: () => this.commentOnSelection()
6638
+ }, icon(ICONS.comment), this.t("comment"));
6639
+ this.live = h("div", {
6640
+ class: "sr",
6641
+ role: "status",
6642
+ "aria-live": "polite"
6643
+ });
6644
+ this.root.append(style, this.markLayer, this.pinLayer, this.overlayLayer, this.uiLayer, this.selectButton, this.live);
6645
+ this.uiLayer.addEventListener("keydown", (e) => this.trapFocus(e));
5047
6646
  this.api = new NuniApi(config);
5048
6647
  }
6648
+ t(key, vars) {
6649
+ return this.i18n.t(key, vars);
6650
+ }
6651
+ /** A message that names the hotkey, or the plain one when it is off. */
6652
+ withKey(plain, keyed) {
6653
+ const key = this.config.hotkey;
6654
+ return key ? this.t(keyed, { key: key.toUpperCase() }) : this.t(plain);
6655
+ }
6656
+ /** Position, theme, brand color and stacking order from the options. */
6657
+ applyAppearance() {
6658
+ const { position, theme, accentColor, zIndex } = this.config;
6659
+ this.host.dataset.position = position;
6660
+ this.host.dataset.theme = theme;
6661
+ if (zIndex !== null) this.host.style.zIndex = String(zIndex);
6662
+ const rgb = accentColor ? parseColor(accentColor) : null;
6663
+ if (!rgb) return;
6664
+ const [r, g, b] = rgb;
6665
+ this.host.style.setProperty("--n-accent", `rgb(${r}, ${g}, ${b})`);
6666
+ this.host.style.setProperty("--n-accent-soft", `rgba(${r}, ${g}, ${b}, 0.14)`);
6667
+ this.host.style.setProperty("--n-accent-fg", readableOn(rgb));
6668
+ }
5049
6669
  start() {
5050
6670
  document.body.appendChild(this.host);
5051
6671
  sha256Hex(this.secret).then((hash) => {
@@ -5078,6 +6698,25 @@ var NuniWidget = class {
5078
6698
  });
5079
6699
  window.addEventListener("resize", onScroll, { passive: true });
5080
6700
  window.addEventListener("message", onMessage);
6701
+ const onSelection = () => this.scheduleSelection();
6702
+ const onDown = (e) => {
6703
+ if (e.composedPath().includes(this.host)) return;
6704
+ this.pointerDown = true;
6705
+ this.scheduleSelection();
6706
+ };
6707
+ const onUp = () => {
6708
+ this.pointerDown = false;
6709
+ this.scheduleSelection();
6710
+ };
6711
+ document.addEventListener("selectionchange", onSelection);
6712
+ document.addEventListener("pointerdown", onDown, true);
6713
+ document.addEventListener("pointerup", onUp, true);
6714
+ this.cleanups.push(() => {
6715
+ document.removeEventListener("selectionchange", onSelection);
6716
+ document.removeEventListener("pointerdown", onDown, true);
6717
+ document.removeEventListener("pointerup", onUp, true);
6718
+ cancelAnimationFrame(this.selectionFrame);
6719
+ });
5081
6720
  this.cleanups.push(() => {
5082
6721
  window.removeEventListener("keydown", onKey, true);
5083
6722
  window.removeEventListener("scroll", onScroll, true);
@@ -5113,6 +6752,11 @@ var NuniWidget = class {
5113
6752
  this.setPicking(false);
5114
6753
  this.pageUnsub?.();
5115
6754
  this.sessionUnsub?.();
6755
+ this.ownerDetailUnsub?.();
6756
+ this.threadUnsub?.();
6757
+ this.resolveRun++;
6758
+ for (const stop of this.scopeWatchers.values()) stop();
6759
+ this.scopeWatchers.clear();
5116
6760
  for (const cleanup of this.cleanups) cleanup();
5117
6761
  cancelAnimationFrame(this.layoutFrame);
5118
6762
  clearTimeout(this.resolveTimer);
@@ -5122,7 +6766,7 @@ var NuniWidget = class {
5122
6766
  }
5123
6767
  currentPageKey() {
5124
6768
  const url = new URL(location.href);
5125
- return this.config.getPageKey?.(url) ?? normalizePath(url);
6769
+ return this.config.getPageKey(url);
5126
6770
  }
5127
6771
  subscribePage() {
5128
6772
  const key = this.currentPageKey();
@@ -5140,7 +6784,7 @@ var NuniWidget = class {
5140
6784
  const merged = extra && !comments.some((c) => c._id === extra._id) ? [...comments, extra] : comments;
5141
6785
  this.comments = merged.sort((a, b) => a.createdAt - b.createdAt);
5142
6786
  this.loaded = true;
5143
- this.resolvePlacements(false);
6787
+ this.resolvePlacements(this.placements.size > 0, this.deepLinkId ?? void 0);
5144
6788
  this.openDeepLink();
5145
6789
  this.render();
5146
6790
  }, () => {});
@@ -5149,7 +6793,7 @@ var NuniWidget = class {
5149
6793
  watchSession(token) {
5150
6794
  this.sessionUnsub?.();
5151
6795
  this.sessionUnsub = this.api.onSession(token, (result) => {
5152
- if (result.valid) this.ownerName = result.ownerName ?? "Owner";
6796
+ if (result.valid) this.ownerName = result.ownerName ?? this.t("owner");
5153
6797
  else {
5154
6798
  this.ownerName = null;
5155
6799
  this.ownerToken = null;
@@ -5160,6 +6804,39 @@ var NuniWidget = class {
5160
6804
  this.render();
5161
6805
  });
5162
6806
  }
6807
+ /** Keep the owner-only details of the open thread subscribed. */
6808
+ syncOwnerDetail() {
6809
+ const token = this.ownerToken;
6810
+ const id = this.card?.kind === "thread" && this.isOwner && token ? this.card.id : null;
6811
+ if (id === this.ownerDetailId) return;
6812
+ this.ownerDetailUnsub?.();
6813
+ this.ownerDetailUnsub = null;
6814
+ this.ownerDetail = null;
6815
+ this.ownerDetailLoaded = false;
6816
+ this.ownerDetailId = id;
6817
+ if (!id || !token) return;
6818
+ this.ownerDetailUnsub = this.api.onOwnerComment(id, token, (comment) => {
6819
+ if (this.ownerDetailId !== id) return;
6820
+ this.ownerDetail = comment;
6821
+ this.ownerDetailLoaded = true;
6822
+ this.render();
6823
+ });
6824
+ }
6825
+ /** Keep the open thread's replies and reactions subscribed. */
6826
+ syncThread() {
6827
+ const id = this.card?.kind === "thread" ? this.card.id : null;
6828
+ if (id === this.threadId) return;
6829
+ this.threadUnsub?.();
6830
+ this.threadUnsub = null;
6831
+ this.thread = null;
6832
+ this.threadId = id;
6833
+ if (!id) return;
6834
+ this.threadUnsub = this.api.onThread(id, (thread) => {
6835
+ if (this.threadId !== id) return;
6836
+ this.thread = thread;
6837
+ this.render();
6838
+ });
6839
+ }
5163
6840
  get isOwner() {
5164
6841
  return Boolean(this.ownerToken && this.ownerName);
5165
6842
  }
@@ -5173,38 +6850,165 @@ var NuniWidget = class {
5173
6850
  clearTimeout(this.resolveTimer);
5174
6851
  this.resolveTimer = window.setTimeout(() => this.resolvePlacements(true), 200);
5175
6852
  }
6853
+ stillPlaced(comment) {
6854
+ const current = this.placements.get(comment._id);
6855
+ if (!current?.element?.isConnected) return false;
6856
+ const quote = comment.anchor.quote;
6857
+ if (quote) return current.range !== void 0 && collapse(current.range.toString()) === quote.exact;
6858
+ const text = elementText(current.element);
6859
+ const expected = comment.anchor.text;
6860
+ return !expected || text === expected || textSimilarity(text, expected) > .8;
6861
+ }
5176
6862
  /**
5177
6863
  * Find each comment's element. With `onlyStale`, keep placements whose
5178
6864
  * element is still attached and still looks like what was captured.
6865
+ * Works in slices of about 8 ms, so a page with many pins stays
6866
+ * responsive; `first` (a deep-linked comment) is found right away.
5179
6867
  */
5180
- resolvePlacements(onlyStale) {
6868
+ resolvePlacements(onlyStale, first) {
6869
+ const run = ++this.resolveRun;
5181
6870
  const ids = new Set(this.comments.map((c) => c._id));
5182
6871
  for (const id of this.placements.keys()) if (!ids.has(id)) this.placements.delete(id);
5183
- for (const comment of this.comments) {
5184
- const current = this.placements.get(comment._id);
5185
- if (onlyStale && current?.element && current.element.isConnected) {
5186
- const text = elementText(current.element);
5187
- const expected = comment.anchor.text;
5188
- if (!expected || text === expected || textSimilarity(text, expected) > .8) continue;
6872
+ const queue = this.comments.filter((c) => !(onlyStale && this.stillPlaced(c)));
6873
+ const index = first ? queue.findIndex((c) => c._id === first) : -1;
6874
+ if (index > 0) queue.unshift(...queue.splice(index, 1));
6875
+ const cache = createResolveCache();
6876
+ const options = {
6877
+ isIgnored: this.isIgnored,
6878
+ document
6879
+ };
6880
+ const slice = () => {
6881
+ if (run !== this.resolveRun) return;
6882
+ const started = performance.now();
6883
+ while (queue.length) {
6884
+ const comment = queue.shift();
6885
+ const result = resolveAnchor(comment.anchor, document, options, cache);
6886
+ this.placements.set(comment._id, {
6887
+ element: result.element,
6888
+ confidence: result.confidence,
6889
+ range: result.range
6890
+ });
6891
+ if (performance.now() - started > 8) break;
6892
+ }
6893
+ this.watchScopes();
6894
+ this.renderPins();
6895
+ this.layout();
6896
+ if (queue.length) whenIdle(slice);
6897
+ else if ((onlyStale || run > 1) && this.panelOpen) this.render();
6898
+ };
6899
+ slice();
6900
+ }
6901
+ /**
6902
+ * Pins inside shadow roots and iframes: the page's observers don't see
6903
+ * changes or scrolling in there, so watch each of those scopes too.
6904
+ */
6905
+ watchScopes() {
6906
+ const roots = /* @__PURE__ */ new Set();
6907
+ for (const { element } of this.placements.values()) {
6908
+ let node = element;
6909
+ for (let depth = 0; node && depth < 8; depth++) {
6910
+ const root = scopeRootOf(node);
6911
+ if (root === document) break;
6912
+ roots.add(root);
6913
+ node = isShadowRoot(root) ? root.host : frameElementOf(root);
5189
6914
  }
5190
- const result = resolveAnchor(comment.anchor, document, { isIgnored: this.isIgnored });
5191
- this.placements.set(comment._id, {
5192
- element: result.element,
5193
- confidence: result.confidence
5194
- });
5195
6915
  }
5196
- this.renderPins();
5197
- this.layout();
5198
- if (onlyStale && this.panelOpen) this.render();
6916
+ for (const [root, stop] of this.scopeWatchers) if (!roots.has(root)) {
6917
+ stop();
6918
+ this.scopeWatchers.delete(root);
6919
+ }
6920
+ for (const root of roots) if (!this.scopeWatchers.has(root)) this.scopeWatchers.set(root, this.watchScope(root));
6921
+ }
6922
+ watchScope(root) {
6923
+ const observer = new MutationObserver(() => this.scheduleResolve());
6924
+ const doc = isShadowRoot(root) ? null : root;
6925
+ observer.observe(doc ? doc.body ?? doc.documentElement : root, {
6926
+ childList: true,
6927
+ subtree: true,
6928
+ characterData: true,
6929
+ attributes: true,
6930
+ attributeFilter: [
6931
+ "class",
6932
+ "style",
6933
+ "hidden",
6934
+ "open",
6935
+ "id"
6936
+ ]
6937
+ });
6938
+ const onScroll = () => this.scheduleLayout();
6939
+ const target = doc?.defaultView ?? root;
6940
+ target.addEventListener("scroll", onScroll, {
6941
+ capture: true,
6942
+ passive: true
6943
+ });
6944
+ doc?.defaultView?.addEventListener("resize", onScroll, { passive: true });
6945
+ return () => {
6946
+ observer.disconnect();
6947
+ target.removeEventListener("scroll", onScroll, true);
6948
+ doc?.defaultView?.removeEventListener("resize", onScroll);
6949
+ };
5199
6950
  }
5200
6951
  pinPoint(comment) {
5201
- const el = this.placements.get(comment._id)?.element;
6952
+ const placement = this.placements.get(comment._id);
6953
+ const el = placement?.element;
5202
6954
  if (!el || !isRendered(el)) return null;
5203
- const r = el.getBoundingClientRect();
6955
+ if (viewportRect(el, document).clipped) return null;
6956
+ return this.anchorPoint(comment.anchor, el, placement.range);
6957
+ }
6958
+ /**
6959
+ * Where a pin goes: after the last selected word for a text comment, the
6960
+ * top-right corner of an area, or where the element was clicked.
6961
+ */
6962
+ anchorPoint(anchor, el, range) {
6963
+ if (range && range.startContainer.ownerDocument === document) {
6964
+ const last = lineRects(range).at(-1);
6965
+ if (last) return {
6966
+ x: last.right,
6967
+ y: last.top + last.height / 2
6968
+ };
6969
+ }
6970
+ const r = viewportRect(el, document);
5204
6971
  return {
5205
- x: r.left + comment.anchor.offset.x * r.width,
5206
- y: r.top + comment.anchor.offset.y * r.height
6972
+ x: r.left + anchor.offset.x * r.width,
6973
+ y: r.top + anchor.offset.y * r.height
6974
+ };
6975
+ }
6976
+ /**
6977
+ * Text comments underline their words, and the open comment (or the one
6978
+ * being written) also shows its words or its area strongly.
6979
+ */
6980
+ renderMarks() {
6981
+ const marks = [];
6982
+ const add = (box, cls) => {
6983
+ const mark = h("div", { class: cls });
6984
+ Object.assign(mark.style, {
6985
+ left: `${box.left}px`,
6986
+ top: `${box.top}px`,
6987
+ width: `${box.width}px`,
6988
+ height: `${box.height}px`
6989
+ });
6990
+ marks.push(mark);
5207
6991
  };
6992
+ const show = (anchor, el, range, active) => {
6993
+ if (!el) return;
6994
+ if (range && range.startContainer.ownerDocument === document) for (const r of lineRects(range)) add(r, active ? "mark mark-active" : "mark");
6995
+ if (active) {
6996
+ const box = regionBox(anchor, el);
6997
+ if (box) add(box, "area");
6998
+ }
6999
+ };
7000
+ if (this.card?.kind === "composer") {
7001
+ const { draft } = this.card;
7002
+ show(draft.anchor, draft.element, draft.range, true);
7003
+ }
7004
+ for (const comment of this.visibleComments()) {
7005
+ const placement = this.placements.get(comment._id);
7006
+ const active = this.activeId === comment._id;
7007
+ if (!active && (comment.status !== "open" || !comment.anchor.quote)) continue;
7008
+ if (this.pins.get(comment._id)?.hidden) continue;
7009
+ show(comment.anchor, placement?.element, placement?.range, active);
7010
+ }
7011
+ this.markLayer.replaceChildren(...marks);
5208
7012
  }
5209
7013
  scheduleLayout() {
5210
7014
  if (this.layoutFrame) return;
@@ -5237,63 +7041,224 @@ var NuniWidget = class {
5237
7041
  });
5238
7042
  pin.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`;
5239
7043
  }
7044
+ this.renderMarks();
5240
7045
  this.positionCard();
5241
- if (this.picking && this.hoverEl) this.renderHighlight();
7046
+ if (this.picking && (this.hoverEl || this.dragBox)) this.renderHighlight();
7047
+ this.placeSelectButton();
5242
7048
  }
5243
7049
  setPicking(on) {
5244
7050
  if (this.picking === on) return;
5245
7051
  this.picking = on;
5246
7052
  const html = document.documentElement;
7053
+ this.dragStart = null;
7054
+ this.dragBox = null;
5247
7055
  if (on) {
5248
7056
  this.card = null;
5249
7057
  if (window.matchMedia(MOBILE_QUERY).matches) this.panelOpen = false;
5250
- html.classList.add(PICKING_CLASS);
5251
- this.injectCursorStyle();
5252
- document.addEventListener("pointermove", this.onPickMove, true);
5253
- document.addEventListener("click", this.onPickClick, true);
5254
- for (const type of [
5255
- "pointerdown",
5256
- "mousedown",
5257
- "pointerup",
5258
- "mouseup"
5259
- ]) document.addEventListener(type, this.swallow, {
5260
- capture: true,
5261
- passive: false
5262
- });
7058
+ this.pickDocs = [document, ...frameDocuments(document)];
7059
+ for (const doc of this.pickDocs) {
7060
+ doc.documentElement.classList.add(PICKING_CLASS);
7061
+ this.injectCursorStyle(doc);
7062
+ doc.addEventListener("pointermove", this.onPickMove, true);
7063
+ doc.addEventListener("click", this.onPickClick, true);
7064
+ for (const type of PICK_EVENTS) doc.addEventListener(type, this.swallow, {
7065
+ capture: true,
7066
+ passive: false
7067
+ });
7068
+ if (doc !== document) doc.addEventListener("keydown", this.onFrameKey, true);
7069
+ }
5263
7070
  } else {
5264
7071
  html.classList.remove(PICKING_CLASS);
5265
7072
  this.hoverEl = null;
5266
- document.removeEventListener("pointermove", this.onPickMove, true);
5267
- document.removeEventListener("click", this.onPickClick, true);
5268
- for (const type of [
5269
- "pointerdown",
5270
- "mousedown",
5271
- "pointerup",
5272
- "mouseup"
5273
- ]) document.removeEventListener(type, this.swallow, true);
7073
+ for (const doc of this.pickDocs) {
7074
+ doc.documentElement?.classList.remove(PICKING_CLASS);
7075
+ doc.removeEventListener("pointermove", this.onPickMove, true);
7076
+ doc.removeEventListener("click", this.onPickClick, true);
7077
+ for (const type of PICK_EVENTS) doc.removeEventListener(type, this.swallow, true);
7078
+ doc.removeEventListener("keydown", this.onFrameKey, true);
7079
+ }
7080
+ this.pickDocs = [];
5274
7081
  }
5275
7082
  this.render();
5276
7083
  }
5277
- injectCursorStyle() {
5278
- if (document.getElementById("nuni-cursor-style")) return;
5279
- const style = document.createElement("style");
7084
+ injectCursorStyle(doc) {
7085
+ if (doc.getElementById("nuni-cursor-style")) return;
7086
+ const style = doc.createElement("style");
5280
7087
  style.id = "nuni-cursor-style";
5281
7088
  style.textContent = `html.${PICKING_CLASS}, html.${PICKING_CLASS} * { cursor: crosshair !important; }`;
5282
- document.head.appendChild(style);
7089
+ (doc.head ?? doc.documentElement).appendChild(style);
5283
7090
  this.cleanups.push(() => style.remove());
5284
7091
  }
7092
+ /**
7093
+ * The element the event happened on, inside open shadow roots too
7094
+ * (composedPath), and never Nuni's own UI.
7095
+ */
5285
7096
  fromPage(e) {
5286
- if (e.composedPath().includes(this.host)) return null;
5287
- const target = e.target;
5288
- return target instanceof Element ? target : null;
7097
+ const path = e.composedPath();
7098
+ if (path.includes(this.host)) return null;
7099
+ const target = path[0] ?? e.target;
7100
+ return target && target.nodeType === 1 ? target : null;
7101
+ }
7102
+ /** An area drawn while picking: anchored to the element that covers it. */
7103
+ commentOnArea(box) {
7104
+ const element = areaContainer(document, box, this.isIgnored);
7105
+ const anchor = captureArea(element, box, {
7106
+ ignoreAttributePrefixes: [],
7107
+ document
7108
+ });
7109
+ this.openComposer({
7110
+ anchor,
7111
+ element
7112
+ });
7113
+ }
7114
+ /** The page's selected text, if it can take a text comment. */
7115
+ pageSelection() {
7116
+ const selection = document.getSelection();
7117
+ if (!selection || selection.isCollapsed || !selection.rangeCount) return null;
7118
+ const range = selection.getRangeAt(0);
7119
+ const node = range.commonAncestorContainer;
7120
+ const el = node.nodeType === 1 ? node : node.parentElement;
7121
+ if (!el || el === this.host || this.host.contains(el)) return null;
7122
+ if (el.closest("input, textarea, select, [contenteditable]")) return null;
7123
+ if (el.closest("[data-nuni]")) return null;
7124
+ return collapse(range.toString()) ? range : null;
7125
+ }
7126
+ commentOnSelection() {
7127
+ const range = this.pageSelection();
7128
+ if (!range) return;
7129
+ const captured = captureSelection(range, {
7130
+ ignoreAttributePrefixes: [],
7131
+ document
7132
+ });
7133
+ if (!captured) return;
7134
+ const kept = range.cloneRange();
7135
+ document.getSelection()?.removeAllRanges();
7136
+ this.selection = null;
7137
+ this.openComposer({
7138
+ ...captured,
7139
+ range: kept
7140
+ });
7141
+ }
7142
+ scheduleSelection() {
7143
+ if (this.selectionFrame) return;
7144
+ this.selectionFrame = requestAnimationFrame(() => {
7145
+ this.selectionFrame = 0;
7146
+ this.selection = this.picking || this.pointerDown ? null : this.pageSelection();
7147
+ this.placeSelectButton();
7148
+ });
7149
+ }
7150
+ /** A small "Comment" button under the selected text. */
7151
+ placeSelectButton() {
7152
+ const button = this.selectButton;
7153
+ const range = this.selection;
7154
+ const last = range ? lineRects(range).at(-1) : void 0;
7155
+ const vw = window.innerWidth;
7156
+ const vh = window.innerHeight;
7157
+ if (!range || !last || last.bottom < 0 || last.top > vh) {
7158
+ button.hidden = true;
7159
+ return;
7160
+ }
7161
+ button.hidden = false;
7162
+ const width = button.offsetWidth || 96;
7163
+ const height = button.offsetHeight || 32;
7164
+ let top = last.bottom + 8;
7165
+ if (top + height > vh - 8) top = last.top - height - 8;
7166
+ const left = Math.max(8, Math.min(last.right - width / 2, vw - width - 8));
7167
+ button.style.transform = `translate(${Math.round(left)}px, ${Math.round(top)}px)`;
7168
+ }
7169
+ openComposer(draft) {
7170
+ const { element } = draft;
7171
+ this.setPicking(false);
7172
+ if (this.config.capture.dom) try {
7173
+ draft.dom = domContext(element);
7174
+ } catch {}
7175
+ this.card = {
7176
+ kind: "composer",
7177
+ draft
7178
+ };
7179
+ this.activeId = null;
7180
+ this.render();
7181
+ this.focusComposer();
7182
+ this.takeScreenshot(draft);
7183
+ }
7184
+ /** Capture while the person types, so posting stays instant. */
7185
+ async takeScreenshot(draft) {
7186
+ const load = this.runtime.loadScreenshot;
7187
+ if (!load || draft.element.ownerDocument !== document) return;
7188
+ const accent = getComputedStyle(this.host).getPropertyValue("--n-accent").trim() || "#d6246e";
7189
+ try {
7190
+ await new Promise((resolve) => setTimeout(resolve, 60));
7191
+ const capture = await load();
7192
+ const { range, anchor, element } = draft;
7193
+ const focus = range ? () => range.getBoundingClientRect() : anchor.region ? () => {
7194
+ const box = regionBox(anchor, element);
7195
+ return {
7196
+ left: box.left,
7197
+ top: box.top,
7198
+ right: box.left + box.width,
7199
+ bottom: box.top + box.height
7200
+ };
7201
+ } : void 0;
7202
+ const blob = capture ? await capture(element, {
7203
+ exclude: this.host,
7204
+ accent,
7205
+ focus
7206
+ }) : null;
7207
+ if (!blob || !this.isComposing(draft) || draft.sending) return;
7208
+ const preview = await dataUrl(blob);
7209
+ if (!this.isComposing(draft) || draft.sending) return;
7210
+ draft.screenshot = {
7211
+ blob,
7212
+ preview
7213
+ };
7214
+ this.render();
7215
+ } catch {}
7216
+ }
7217
+ isComposing(draft) {
7218
+ return this.card?.kind === "composer" && this.card.draft === draft;
7219
+ }
7220
+ collectContext(draft) {
7221
+ const { collectors } = this.runtime;
7222
+ const context = {};
7223
+ const logs = collectors.console();
7224
+ const failed = collectors.network();
7225
+ if (logs.length) context.console = logs;
7226
+ if (failed.length) context.network = failed;
7227
+ if (draft.dom) context.dom = draft.dom;
7228
+ return Object.keys(context).length ? context : void 0;
7229
+ }
7230
+ async attachScreenshot(id, image) {
7231
+ try {
7232
+ await this.api.uploadScreenshot(id, this.secret, image);
7233
+ } catch {}
7234
+ }
7235
+ copyForAgent(comment) {
7236
+ const detail = this.ownerDetail?._id === comment._id ? this.ownerDetail : null;
7237
+ const replies = this.threadId === comment._id ? this.thread?.replies : void 0;
7238
+ copyText(buildCommentPrompt(detail ?? {
7239
+ ...comment,
7240
+ replies
7241
+ }, { includeContext: Boolean(detail) })).then((ok) => this.showToast(ok ? this.t("copied") : this.t("copyFailed")));
5289
7242
  }
5290
7243
  renderHighlight() {
5291
7244
  this.overlayLayer.replaceChildren();
5292
7245
  if (!this.picking) return;
5293
- this.overlayLayer.append(h("div", { class: "pick-hint" }, "Click anything to comment · Esc to cancel"));
7246
+ const mobile = window.matchMedia(MOBILE_QUERY).matches;
7247
+ this.overlayLayer.append(h("div", { class: "pick-hint" }, mobile ? this.t("pickHintMobile") : [h("span", {}, this.t("pickHint")), h("span", { class: "pick-keys" }, this.t("pickHintKeys"))]));
7248
+ if (this.dragBox) {
7249
+ const box = h("div", { class: "highlight drag-box" });
7250
+ Object.assign(box.style, {
7251
+ left: `${this.dragBox.left}px`,
7252
+ top: `${this.dragBox.top}px`,
7253
+ width: `${this.dragBox.width}px`,
7254
+ height: `${this.dragBox.height}px`
7255
+ });
7256
+ this.overlayLayer.append(box);
7257
+ return;
7258
+ }
5294
7259
  const el = this.hoverEl;
5295
- if (!el || el === document.documentElement) return;
5296
- const r = el.getBoundingClientRect();
7260
+ if (!el || el === el.ownerDocument.documentElement) return;
7261
+ const r = viewportRect(el, document);
5297
7262
  const box = h("div", { class: "highlight" }, h("span", { class: "highlight-label" }, describeElement(el)));
5298
7263
  Object.assign(box.style, {
5299
7264
  left: `${r.left - 2}px`,
@@ -5306,19 +7271,171 @@ var NuniWidget = class {
5306
7271
  onKeyDown(e) {
5307
7272
  const inWidget = e.composedPath().includes(this.host);
5308
7273
  if (e.key === "Escape") {
5309
- if (this.picking) this.setPicking(false);
7274
+ if (this.confirming) this.answer(false);
7275
+ else if (this.picking) this.setPicking(false);
5310
7276
  else if (this.card) this.closeCard();
5311
7277
  else if (this.panelOpen) this.togglePanel(false);
5312
7278
  else return;
5313
7279
  e.stopPropagation();
5314
7280
  return;
5315
7281
  }
5316
- if (inWidget || isTypingTarget(e.target) || e.metaKey || e.ctrlKey || e.altKey || e.repeat) return;
5317
- if (e.key === "c" || e.key === "C") {
7282
+ if (isTypingTarget(e.composedPath()[0] ?? e.target) || e.metaKey || e.ctrlKey || e.altKey) return;
7283
+ if (this.picking && !inWidget && this.onPickKey(e)) return;
7284
+ if (inWidget && this.confirming) return;
7285
+ if (e.repeat) return;
7286
+ const hotkey = this.config.hotkey;
7287
+ if (hotkey && e.key.toLowerCase() === hotkey) {
7288
+ e.preventDefault();
7289
+ if (!this.picking && this.pageSelection()) this.commentOnSelection();
7290
+ else this.setPicking(!this.picking);
7291
+ }
7292
+ }
7293
+ /**
7294
+ * Picking without a mouse: arrows move to the parent, first child and
7295
+ * siblings; Enter comments on the highlighted element.
7296
+ */
7297
+ onPickKey(e) {
7298
+ if (![
7299
+ "ArrowUp",
7300
+ "ArrowDown",
7301
+ "ArrowLeft",
7302
+ "ArrowRight",
7303
+ "Enter"
7304
+ ].includes(e.key)) return false;
7305
+ e.preventDefault();
7306
+ e.stopPropagation();
7307
+ let el = this.hoverEl;
7308
+ if (!el || !el.isConnected) {
7309
+ this.movePickTo(this.keyboardStart());
7310
+ return true;
7311
+ }
7312
+ if (e.key === "Enter") {
7313
+ const r = el.getBoundingClientRect();
7314
+ const anchor = captureAnchor(el, {
7315
+ x: r.left + r.width / 2,
7316
+ y: r.top + r.height / 2
7317
+ }, {
7318
+ ignoreAttributePrefixes: [],
7319
+ document
7320
+ });
7321
+ this.openComposer({
7322
+ anchor,
7323
+ element: el
7324
+ });
7325
+ return true;
7326
+ }
7327
+ const visible = (n) => {
7328
+ while (n && (n === this.host || !isRendered(n))) n = e.key === "ArrowLeft" ? n.previousElementSibling : n.nextElementSibling;
7329
+ return n;
7330
+ };
7331
+ const body = el.ownerDocument.body;
7332
+ if (e.key === "ArrowUp") {
7333
+ if (el.parentElement && el !== body) el = el.parentElement;
7334
+ } else if (e.key === "ArrowDown") el = visible(el.firstElementChild) ?? el;
7335
+ else if (e.key === "ArrowLeft") el = visible(el.previousElementSibling) ?? el;
7336
+ else el = visible(el.nextElementSibling) ?? el;
7337
+ this.movePickTo(el);
7338
+ return true;
7339
+ }
7340
+ /** Keyboard picking starts at the focused element, or mid-screen. */
7341
+ keyboardStart() {
7342
+ const focused = document.activeElement;
7343
+ if (focused && focused !== document.body && focused !== this.host) return focused;
7344
+ const middle = document.elementsFromPoint(window.innerWidth / 2, window.innerHeight / 2).find((el) => !this.isIgnored(el));
7345
+ return middle ? pickTarget(middle) : document.body;
7346
+ }
7347
+ movePickTo(el) {
7348
+ this.hoverEl = el;
7349
+ el.scrollIntoView({
7350
+ block: "nearest",
7351
+ inline: "nearest"
7352
+ });
7353
+ this.live.textContent = this.t("picked", { element: describeElement(el) });
7354
+ this.renderHighlight();
7355
+ }
7356
+ /** Tab stays inside an open card, the panel or a dialog. */
7357
+ trapFocus(e) {
7358
+ if (e.key !== "Tab") return;
7359
+ const box = e.composedPath()[0]?.closest?.(".confirm, [data-card], .panel");
7360
+ if (!box) return;
7361
+ const items = Array.from(box.querySelectorAll("button:not([disabled]), input, textarea, a[href], [tabindex=\"0\"]")).filter((el) => !el.closest("[hidden]"));
7362
+ if (!items.length) return;
7363
+ const first = items[0];
7364
+ const last = items[items.length - 1];
7365
+ const active = this.root.activeElement;
7366
+ if (e.shiftKey && active === first) {
7367
+ e.preventDefault();
7368
+ last.focus();
7369
+ } else if (!e.shiftKey && active === last) {
5318
7370
  e.preventDefault();
5319
- this.setPicking(!this.picking);
7371
+ first.focus();
5320
7372
  }
5321
7373
  }
7374
+ /** Remember where focus was, before moving it into the widget. */
7375
+ keepFocus() {
7376
+ if (this.returnFocus) return;
7377
+ const inside = this.root.activeElement;
7378
+ if (inside) {
7379
+ this.returnFocus = inside.dataset.focusKey ?? inside;
7380
+ return;
7381
+ }
7382
+ const active = document.activeElement;
7383
+ this.returnFocus = active && active !== document.body && active !== this.host ? active : null;
7384
+ }
7385
+ /** Back to where focus was before (when that is still there). */
7386
+ restoreFocus() {
7387
+ const back = this.returnFocus;
7388
+ this.returnFocus = null;
7389
+ const target = typeof back === "string" ? this.uiLayer.querySelector(`[data-focus-key="${back}"]`) : back;
7390
+ if (target?.isConnected) target.focus({ preventScroll: true });
7391
+ }
7392
+ /** An "are you sure?" dialog inside the widget. */
7393
+ ask(message, action) {
7394
+ this.confirming?.done(false);
7395
+ const from = this.root.activeElement?.dataset.focusKey ?? null;
7396
+ return new Promise((resolve) => {
7397
+ this.confirming = {
7398
+ message,
7399
+ action,
7400
+ done: resolve,
7401
+ from
7402
+ };
7403
+ this.focusNext = "[data-focus-key=\"confirm-cancel\"]";
7404
+ this.render();
7405
+ });
7406
+ }
7407
+ answer(ok) {
7408
+ const confirming = this.confirming;
7409
+ if (!confirming) return;
7410
+ this.confirming = null;
7411
+ if (!ok && confirming.from) this.focusNext = `[data-focus-key="${confirming.from}"]`;
7412
+ this.render();
7413
+ confirming.done(ok);
7414
+ }
7415
+ renderConfirm() {
7416
+ const c = this.confirming;
7417
+ if (!c) return null;
7418
+ return h("div", {
7419
+ class: "confirm-backdrop",
7420
+ onclick: () => this.answer(false)
7421
+ }, h("div", {
7422
+ class: "confirm",
7423
+ role: "alertdialog",
7424
+ "aria-modal": "true",
7425
+ "aria-labelledby": "nuni-confirm-text",
7426
+ onclick: (e) => e.stopPropagation()
7427
+ }, h("p", { id: "nuni-confirm-text" }, c.message), h("div", { class: "row" }, h("span", { class: "spacer" }), h("button", {
7428
+ class: "btn btn-ghost",
7429
+ type: "button",
7430
+ "data-focus-key": "confirm-cancel",
7431
+ onclick: () => this.answer(false)
7432
+ }, this.t("cancel")), h("button", {
7433
+ class: "btn btn-danger-solid",
7434
+ type: "button",
7435
+ "data-focus-key": "confirm-ok",
7436
+ onclick: () => this.answer(true)
7437
+ }, c.action))));
7438
+ }
5322
7439
  openClaim() {
5323
7440
  const url = new URL(`${this.config.appUrl}/dashboard/claim`);
5324
7441
  url.searchParams.set("project", this.config.project);
@@ -5339,7 +7456,7 @@ var NuniWidget = class {
5339
7456
  this.ownerToken = data.token;
5340
7457
  write(KEYS.session(this.config.project), data.token);
5341
7458
  this.watchSession(data.token);
5342
- this.showToast("You're signed in as the owner");
7459
+ this.showToast(this.t("signedIn"));
5343
7460
  }
5344
7461
  readDeepLink() {
5345
7462
  const url = new URL(location.href);
@@ -5360,12 +7477,12 @@ var NuniWidget = class {
5360
7477
  }
5361
7478
  this.api.getComment(id).then((found) => {
5362
7479
  if (!found || found.page.path !== this.pageKey) {
5363
- this.showToast("That comment was deleted or is on another page");
7480
+ this.showToast(this.t("linkGone"));
5364
7481
  return;
5365
7482
  }
5366
7483
  this.extraComment = found;
5367
7484
  this.comments = [...this.comments.filter((c) => c._id !== found._id), found];
5368
- this.resolvePlacements(false);
7485
+ this.resolvePlacements(false, found._id);
5369
7486
  this.focusDeepLinked(found);
5370
7487
  });
5371
7488
  }
@@ -5377,11 +7494,7 @@ var NuniWidget = class {
5377
7494
  block: "center",
5378
7495
  behavior: "smooth"
5379
7496
  });
5380
- this.activeId = id;
5381
- this.card = {
5382
- kind: "thread",
5383
- id
5384
- };
7497
+ this.openThread(id);
5385
7498
  if (window.matchMedia(MOBILE_QUERY).matches) this.panelOpen = false;
5386
7499
  this.render();
5387
7500
  setTimeout(() => this.scheduleLayout(), 400);
@@ -5389,15 +7502,16 @@ var NuniWidget = class {
5389
7502
  async submitDraft(draft, body, name) {
5390
7503
  const cleanName = name.trim().slice(0, LIMITS.nameMaxLength);
5391
7504
  const cleanBody = body.trim();
5392
- if (!cleanName) return this.setDraftError(draft, "Enter your name");
5393
- if (!cleanBody) return this.setDraftError(draft, "Write a comment first");
5394
- if (cleanBody.length > LIMITS.bodyMaxLength) return this.setDraftError(draft, `Comments are limited to ${LIMITS.bodyMaxLength} characters`);
7505
+ if (!cleanName) return this.setDraftError(draft, this.t("enterName"));
7506
+ if (!cleanBody) return this.setDraftError(draft, this.t("writeFirst"));
7507
+ if (cleanBody.length > LIMITS.bodyMaxLength) return this.setDraftError(draft, this.t("commentTooLong", { count: LIMITS.bodyMaxLength }));
5395
7508
  this.name = cleanName;
5396
7509
  write(KEYS.name, cleanName);
5397
7510
  draft.sending = true;
5398
7511
  draft.error = void 0;
5399
7512
  this.render();
5400
7513
  const loc = describeLocation(location.href);
7514
+ const shot = draft.screenshot;
5401
7515
  try {
5402
7516
  const id = await this.api.createComment({
5403
7517
  body: cleanBody,
@@ -5413,17 +7527,20 @@ var NuniWidget = class {
5413
7527
  w: window.innerWidth,
5414
7528
  h: window.innerHeight,
5415
7529
  dpr: window.devicePixelRatio || 1
5416
- }
7530
+ },
7531
+ context: this.collectContext(draft)
5417
7532
  });
7533
+ if (shot) this.attachScreenshot(id, shot.blob);
5418
7534
  this.placements.set(id, {
5419
7535
  element: draft.element,
5420
- confidence: "exact"
7536
+ confidence: "exact",
7537
+ range: draft.range
5421
7538
  });
5422
7539
  if (this.card?.kind === "composer" && this.card.draft === draft) this.card = null;
5423
- this.showToast("Comment added");
7540
+ this.showToast(this.t("added"));
5424
7541
  } catch (error) {
5425
7542
  draft.sending = false;
5426
- draft.error = error instanceof NuniApiError ? error.message : "Couldn't post the comment";
7543
+ draft.error = error instanceof NuniApiError ? error.message : this.t("postFailed");
5427
7544
  }
5428
7545
  this.render();
5429
7546
  }
@@ -5451,7 +7568,7 @@ var NuniWidget = class {
5451
7568
  } catch (error) {
5452
7569
  if (this.card?.kind === "thread" && this.card.id === id) {
5453
7570
  this.card.busy = false;
5454
- this.card.error = error instanceof Error ? error.message : "Something went wrong";
7571
+ this.card.error = error instanceof Error ? error.message : this.t("failed");
5455
7572
  }
5456
7573
  this.render();
5457
7574
  return false;
@@ -5460,21 +7577,21 @@ var NuniWidget = class {
5460
7577
  resolveComment(c) {
5461
7578
  const token = this.ownerToken;
5462
7579
  if (!token) return;
5463
- this.act(c._id, () => this.api.resolve(c._id, token), "Resolved").then((ok) => {
7580
+ this.act(c._id, () => this.api.resolve(c._id, token), this.t("resolved")).then((ok) => {
5464
7581
  if (ok && !(this.panelOpen && this.tab === "resolved") && this.card?.kind === "thread" && this.card.id === c._id) this.closeCard();
5465
7582
  });
5466
7583
  }
5467
7584
  reopenComment(c) {
5468
7585
  const token = this.ownerToken;
5469
7586
  if (!token) return;
5470
- this.act(c._id, () => this.api.reopen(c._id, token), "Reopened");
7587
+ this.act(c._id, () => this.api.reopen(c._id, token), this.t("reopened"));
5471
7588
  }
5472
- deleteComment(c) {
5473
- if (!window.confirm("Delete this comment? This can't be undone.")) return;
7589
+ async deleteComment(c) {
7590
+ if (!await this.ask(this.t("confirmDeleteComment"), this.t("delete"))) return;
5474
7591
  const token = this.ownerToken;
5475
7592
  const action = this.isMine(c) ? () => this.api.deleteOwn(c._id, this.secret) : token ? () => this.api.remove(c._id, token) : null;
5476
7593
  if (!action) return;
5477
- this.act(c._id, action, "Deleted").then((ok) => {
7594
+ this.act(c._id, action, this.t("deleted")).then((ok) => {
5478
7595
  if (!ok) return;
5479
7596
  if (this.extraComment?._id === c._id) this.extraComment = null;
5480
7597
  this.comments = this.comments.filter((x) => x._id !== c._id);
@@ -5482,8 +7599,72 @@ var NuniWidget = class {
5482
7599
  this.closeCard();
5483
7600
  });
5484
7601
  }
7602
+ isMyReply(reply) {
7603
+ return Boolean(this.secretHash) && reply.authorKeyHash === this.secretHash;
7604
+ }
7605
+ async sendReply(comment, body, name) {
7606
+ const card = this.card;
7607
+ if (card?.kind !== "thread" || card.id !== comment._id) return;
7608
+ const authorName = (this.isOwner ? this.ownerName : name)?.trim().slice(0, LIMITS.nameMaxLength);
7609
+ const clean = body.trim();
7610
+ if (!clean) return;
7611
+ if (!authorName) {
7612
+ card.error = this.t("enterName");
7613
+ this.render();
7614
+ return;
7615
+ }
7616
+ if (clean.length > LIMITS.bodyMaxLength) {
7617
+ card.error = this.t("replyTooLong", { count: LIMITS.bodyMaxLength });
7618
+ this.render();
7619
+ return;
7620
+ }
7621
+ if (!this.isOwner) {
7622
+ this.name = authorName;
7623
+ write(KEYS.name, authorName);
7624
+ }
7625
+ card.sendingReply = true;
7626
+ card.error = void 0;
7627
+ this.render();
7628
+ try {
7629
+ await this.api.createReply({
7630
+ commentId: comment._id,
7631
+ body: clean,
7632
+ authorName,
7633
+ authorSecret: this.secret,
7634
+ sessionToken: this.ownerToken ?? void 0
7635
+ });
7636
+ const box = this.uiLayer.querySelector("[data-focus-key=\"reply\"]");
7637
+ if (box) box.value = "";
7638
+ } catch (error) {
7639
+ card.error = error instanceof NuniApiError ? error.message : this.t("replyFailed");
7640
+ }
7641
+ card.sendingReply = false;
7642
+ this.render();
7643
+ }
7644
+ toggleReaction(comment, targetId, emoji) {
7645
+ this.api.toggleReaction(comment._id, targetId, emoji, this.secret).catch((error) => {
7646
+ if (this.card?.kind === "thread" && this.card.id === comment._id) {
7647
+ this.card.error = error instanceof Error ? error.message : this.t("reactFailed");
7648
+ this.render();
7649
+ }
7650
+ });
7651
+ }
7652
+ async deleteReply(comment, reply) {
7653
+ if (!await this.ask(this.t("confirmDeleteReply"), this.t("delete"))) return;
7654
+ const token = this.ownerToken;
7655
+ const action = this.isMyReply(reply) ? () => this.api.deleteReply(reply._id, this.secret) : token ? () => this.api.removeReply(reply._id, token) : null;
7656
+ if (action) this.act(comment._id, action, this.t("replyDeleted"));
7657
+ }
7658
+ saveReplyEdit(comment, reply, body) {
7659
+ this.act(comment._id, () => this.api.editReply(reply._id, this.secret, body), this.t("saved")).then((ok) => {
7660
+ if (ok && this.card?.kind === "thread") {
7661
+ this.card.replyEditing = void 0;
7662
+ this.render();
7663
+ }
7664
+ });
7665
+ }
5485
7666
  saveEdit(c, body) {
5486
- this.act(c._id, () => this.api.editOwn(c._id, this.secret, body), "Saved");
7667
+ this.act(c._id, () => this.api.editOwn(c._id, this.secret, body), this.t("saved"));
5487
7668
  }
5488
7669
  showToast(message) {
5489
7670
  this.toast = message;
@@ -5495,16 +7676,36 @@ var NuniWidget = class {
5495
7676
  this.render();
5496
7677
  }
5497
7678
  togglePanel(open = !this.panelOpen) {
7679
+ if (open === this.panelOpen) return;
5498
7680
  this.panelOpen = open;
5499
- if (open && window.matchMedia(MOBILE_QUERY).matches) this.card = null;
7681
+ if (open) {
7682
+ this.keepFocus();
7683
+ this.focusNext = ".panel [role=\"tab\"][aria-selected=\"true\"]";
7684
+ if (window.matchMedia(MOBILE_QUERY).matches) this.card = null;
7685
+ }
5500
7686
  this.render();
7687
+ if (!open && !this.card) this.restoreFocus();
5501
7688
  }
5502
7689
  closeCard() {
7690
+ const had = this.card !== null;
5503
7691
  this.card = null;
5504
7692
  this.activeId = null;
5505
7693
  this.render();
7694
+ if (had && !this.panelOpen) this.restoreFocus();
7695
+ }
7696
+ /** Open a comment's thread and move focus into it. */
7697
+ openThread(id) {
7698
+ this.keepFocus();
7699
+ this.activeId = id;
7700
+ this.card = {
7701
+ kind: "thread",
7702
+ id
7703
+ };
7704
+ this.focusNext = "[data-card=\"thread\"] .card-head button";
5506
7705
  }
5507
7706
  render() {
7707
+ this.syncOwnerDetail();
7708
+ this.syncThread();
5508
7709
  this.renderPins();
5509
7710
  this.renderUi();
5510
7711
  this.renderHighlight();
@@ -5530,11 +7731,7 @@ var NuniWidget = class {
5530
7731
  e.stopPropagation();
5531
7732
  if (this.card?.kind === "thread" && this.card.id === id) this.closeCard();
5532
7733
  else {
5533
- this.activeId = id;
5534
- this.card = {
5535
- kind: "thread",
5536
- id
5537
- };
7734
+ this.openThread(id);
5538
7735
  this.render();
5539
7736
  }
5540
7737
  });
@@ -5543,7 +7740,12 @@ var NuniWidget = class {
5543
7740
  }
5544
7741
  pin.textContent = initials(comment.authorName);
5545
7742
  pin.style.background = colorFor(comment.authorName);
5546
- pin.setAttribute("aria-label", `Comment by ${comment.authorName}: ${comment.body.slice(0, 80)}`);
7743
+ pin.setAttribute("aria-label", this.t("pinLabel", {
7744
+ name: comment.authorName,
7745
+ body: comment.body.slice(0, 80)
7746
+ }));
7747
+ if (comment.replyCount) pin.dataset.replies = String(comment.replyCount);
7748
+ else delete pin.dataset.replies;
5547
7749
  pin.dataset.status = comment.status;
5548
7750
  pin.dataset.active = String(this.activeId === comment._id);
5549
7751
  pin.dataset.confidence = this.placements.get(comment._id)?.confidence ?? "lost";
@@ -5551,9 +7753,7 @@ var NuniWidget = class {
5551
7753
  this.pinLayer.querySelector(".pin-draft")?.remove();
5552
7754
  if (this.card?.kind === "composer") {
5553
7755
  const { draft } = this.card;
5554
- const r = draft.element.getBoundingClientRect();
5555
- const x = r.left + draft.anchor.offset.x * r.width;
5556
- const y = r.top + draft.anchor.offset.y * r.height;
7756
+ const { x, y } = this.anchorPoint(draft.anchor, draft.element, draft.range);
5557
7757
  const pin = h("div", {
5558
7758
  class: "pin pin-draft pin-pending",
5559
7759
  "aria-hidden": "true"
@@ -5572,6 +7772,8 @@ var NuniWidget = class {
5572
7772
  class: "toast",
5573
7773
  role: "status"
5574
7774
  }, this.toast));
7775
+ const confirm = this.renderConfirm();
7776
+ if (confirm) nodes.push(confirm);
5575
7777
  const focused = this.root.activeElement;
5576
7778
  const focusKey = focused?.dataset.focusKey;
5577
7779
  const selection = focused instanceof HTMLTextAreaElement || focused instanceof HTMLInputElement ? {
@@ -5579,7 +7781,11 @@ var NuniWidget = class {
5579
7781
  end: focused.selectionEnd
5580
7782
  } : null;
5581
7783
  this.uiLayer.replaceChildren(...nodes);
5582
- if (focusKey) {
7784
+ const next = this.focusNext;
7785
+ this.focusNext = null;
7786
+ const wanted = next ? this.uiLayer.querySelector(next) : null;
7787
+ if (wanted) wanted.focus();
7788
+ else if (focusKey) {
5583
7789
  const next = this.uiLayer.querySelector(`[data-focus-key="${focusKey}"]`);
5584
7790
  next?.focus();
5585
7791
  if (selection && (next instanceof HTMLTextAreaElement || next instanceof HTMLInputElement)) next.setSelectionRange(selection.start, selection.end);
@@ -5591,20 +7797,22 @@ var NuniWidget = class {
5591
7797
  return h("div", {
5592
7798
  class: "toolbar",
5593
7799
  role: "toolbar",
5594
- "aria-label": "Nuni comments"
7800
+ "aria-label": this.t("toolbar")
5595
7801
  }, h("button", {
5596
7802
  class: "tb-btn",
5597
7803
  type: "button",
5598
7804
  "aria-pressed": String(this.picking),
5599
- "aria-label": this.picking ? "Cancel adding a comment" : "Add a comment",
5600
- title: "Add a comment (C)",
7805
+ "data-focus-key": "tb-comment",
7806
+ "aria-label": this.picking ? this.t("cancelAdding") : this.t("addComment"),
7807
+ title: this.withKey("addComment", "addCommentKey"),
5601
7808
  onclick: () => this.setPicking(!this.picking)
5602
- }, icon(ICONS.comment), h("span", { class: "tb-label" }, this.picking ? "Pick an element" : "Comment")), h("button", {
7809
+ }, icon(ICONS.comment), h("span", { class: "tb-label" }, this.picking ? this.t("pickElement") : this.config.label ?? this.t("comment"))), h("button", {
5603
7810
  class: "tb-btn",
5604
7811
  type: "button",
5605
7812
  "aria-pressed": String(this.panelOpen),
5606
- "aria-label": `Comments on this page: ${open} open`,
5607
- title: "All comments",
7813
+ "data-focus-key": "tb-panel",
7814
+ "aria-label": this.t("openCount", { count: open }),
7815
+ title: this.t("allComments"),
5608
7816
  onclick: () => this.togglePanel()
5609
7817
  }, icon(ICONS.list), h("span", { class: "tb-count" }, String(open))));
5610
7818
  }
@@ -5618,72 +7826,87 @@ var NuniWidget = class {
5618
7826
  const item = (c) => h("button", {
5619
7827
  class: "item",
5620
7828
  type: "button",
7829
+ "data-focus-key": `item-${c._id}`,
5621
7830
  onclick: () => this.focusComment(c._id)
5622
7831
  }, h("div", { class: "row" }, h("span", {
5623
7832
  class: "avatar",
5624
7833
  style: `background:${colorFor(c.authorName)}`
5625
- }, initials(c.authorName)), h("span", { class: "author" }, c.authorName), h("span", { class: "meta" }, timeAgo(c.createdAt)), c.page.origin !== location.origin ? h("span", {
7834
+ }, initials(c.authorName)), h("span", { class: "author" }, c.authorName), h("span", { class: "meta" }, this.i18n.timeAgo(c.createdAt) + (c.replyCount ? ` · ${this.t("replyCount", { count: c.replyCount })}` : "")), c.page.origin !== location.origin ? h("span", {
5626
7835
  class: "badge",
5627
7836
  title: c.page.origin
5628
- }, new URL(c.page.origin).host) : null), h("div", { class: "item-text" }, c.body));
7837
+ }, new URL(c.page.origin).host) : null), c.anchor.quote ? h("div", { class: "item-quote" }, c.anchor.quote.exact) : null, h("div", { class: "item-text" }, c.body));
5629
7838
  const children = [];
5630
- if (!this.loaded) children.push(h("div", { class: "empty" }, "Loading…"));
5631
- else if (!list.length) children.push(h("div", { class: "empty" }, this.tab === "open" ? "No open comments on this page. Press C to add one." : "Nothing resolved yet."));
7839
+ if (!this.loaded) children.push(h("div", { class: "empty" }, this.t("loading")));
7840
+ else if (!list.length) children.push(h("div", { class: "empty" }, this.tab === "open" ? this.withKey("noOpen", "noOpenKey") : this.t("noResolved")));
5632
7841
  else {
5633
7842
  children.push(...placed.map(item));
5634
7843
  if (lost.length) {
5635
- children.push(h("div", { class: "section-label" }, "Couldn't find on this page"));
7844
+ children.push(h("div", { class: "section-label" }, this.t("notFoundHere")));
5636
7845
  children.push(...lost.map(item));
5637
7846
  }
5638
7847
  }
5639
7848
  if (otherPages.length) {
5640
- children.push(h("div", { class: "section-label" }, "Other pages"));
7849
+ children.push(h("div", { class: "section-label" }, this.t("otherPages")));
5641
7850
  for (const p of otherPages.slice(0, 20)) {
5642
- const href = p.path.includes("#") ? location.origin + p.path : location.origin + p.path + location.search;
7851
+ const href = /[?#]/.test(p.path) ? location.origin + p.path : location.origin + p.path + location.search;
5643
7852
  children.push(h("a", {
5644
7853
  class: "item",
5645
7854
  href
5646
- }, h("div", { class: "row" }, h("span", { class: "author" }, p.path), h("span", { class: "spacer" }), h("span", { class: "badge" }, `${p.count} open`))));
7855
+ }, h("div", { class: "row" }, h("span", { class: "author" }, p.path), h("span", { class: "spacer" }), h("span", { class: "badge" }, this.t("openOnPage", { count: p.count })))));
5647
7856
  }
5648
7857
  }
5649
7858
  return h("div", {
5650
7859
  class: "panel",
5651
7860
  role: "dialog",
5652
- "aria-label": "Comments"
5653
- }, h("div", { class: "panel-head" }, h("div", { class: "panel-title" }, "Comments"), h("button", {
7861
+ "aria-label": this.t("comments")
7862
+ }, h("div", { class: "panel-head" }, h("div", { class: "panel-title" }, this.t("comments")), h("button", {
5654
7863
  class: "btn btn-ghost btn-icon",
5655
7864
  type: "button",
5656
- "aria-label": "Close",
7865
+ "aria-label": this.t("close"),
5657
7866
  onclick: () => this.togglePanel(false)
5658
7867
  }, icon(ICONS.close))), h("div", {
5659
7868
  class: "tabs",
5660
- role: "tablist"
5661
- }, h("button", {
5662
- class: "tab",
5663
- role: "tab",
5664
- type: "button",
5665
- "aria-selected": String(this.tab === "open"),
5666
- onclick: () => {
5667
- this.tab = "open";
7869
+ role: "tablist",
7870
+ "aria-label": this.t("comments"),
7871
+ onkeydown: (e) => {
7872
+ const key = e.key;
7873
+ if (![
7874
+ "ArrowLeft",
7875
+ "ArrowRight",
7876
+ "Home",
7877
+ "End"
7878
+ ].includes(key)) return;
7879
+ e.preventDefault();
7880
+ this.tab = key === "Home" ? "open" : key === "End" ? "resolved" : this.tab === "open" ? "resolved" : "open";
7881
+ this.focusNext = `[data-focus-key="tab-${this.tab}"]`;
5668
7882
  this.render();
5669
7883
  }
5670
- }, `Open (${open.length})`), h("button", {
7884
+ }, ["open", "resolved"].map((tab) => h("button", {
5671
7885
  class: "tab",
5672
7886
  role: "tab",
5673
7887
  type: "button",
5674
- "aria-selected": String(this.tab === "resolved"),
7888
+ id: `nuni-tab-${tab}`,
7889
+ "data-focus-key": `tab-${tab}`,
7890
+ "aria-selected": String(this.tab === tab),
7891
+ "aria-controls": "nuni-panel-list",
7892
+ tabindex: this.tab === tab ? 0 : -1,
5675
7893
  onclick: () => {
5676
- this.tab = "resolved";
7894
+ this.tab = tab;
5677
7895
  this.render();
5678
7896
  }
5679
- }, `Resolved (${resolved.length})`)), h("div", { class: "panel-list" }, ...children), this.status && !this.status.claimed && !this.isOwner ? this.renderPanelFooter() : null);
7897
+ }, tab === "open" ? this.t("tabOpen", { count: open.length }) : this.t("tabResolved", { count: resolved.length })))), h("div", {
7898
+ class: "panel-list",
7899
+ id: "nuni-panel-list",
7900
+ role: "tabpanel",
7901
+ "aria-labelledby": `nuni-tab-${this.tab}`
7902
+ }, ...children), this.status && !this.status.claimed && !this.isOwner ? this.renderPanelFooter() : null);
5680
7903
  }
5681
7904
  renderPanelFooter() {
5682
- return h("div", { class: "panel-foot" }, h("div", { class: "row" }, h("span", { class: "meta" }, "Own this website?"), h("span", { class: "spacer" }), h("button", {
7905
+ return h("div", { class: "panel-foot" }, h("div", { class: "row" }, h("span", { class: "meta" }, this.t("ownSite")), h("span", { class: "spacer" }), h("button", {
5683
7906
  class: "btn",
5684
7907
  type: "button",
5685
7908
  onclick: () => this.openClaim()
5686
- }, icon(ICONS.github), "Claim Nuni")));
7909
+ }, icon(ICONS.github), this.t("claim"))));
5687
7910
  }
5688
7911
  renderCard() {
5689
7912
  const card = this.card;
@@ -5694,19 +7917,19 @@ var NuniWidget = class {
5694
7917
  const needsName = !this.name;
5695
7918
  const nameInput = h("input", {
5696
7919
  class: "field",
5697
- placeholder: "Your name",
7920
+ placeholder: this.t("yourName"),
5698
7921
  value: this.name,
5699
7922
  maxlength: LIMITS.nameMaxLength,
5700
7923
  autocomplete: "name",
5701
7924
  "data-focus-key": "name",
5702
- "aria-label": "Your name"
7925
+ "aria-label": this.t("yourName")
5703
7926
  });
5704
7927
  const textarea = h("textarea", {
5705
7928
  class: "field",
5706
- placeholder: "Leave a comment",
7929
+ placeholder: this.t("leaveComment"),
5707
7930
  maxlength: LIMITS.bodyMaxLength,
5708
7931
  "data-focus-key": "body",
5709
- "aria-label": "Comment"
7932
+ "aria-label": this.t("comment")
5710
7933
  });
5711
7934
  const prev = this.uiLayer.querySelector("[data-focus-key=\"body\"]");
5712
7935
  if (prev) textarea.value = prev.value;
@@ -5736,18 +7959,28 @@ var NuniWidget = class {
5736
7959
  this.name = "";
5737
7960
  this.render();
5738
7961
  }
5739
- }, "Not you?")), textarea, draft.error ? h("div", {
7962
+ }, this.t("notYou"))), draft.anchor.quote ? h("blockquote", { class: "quote" }, draft.anchor.quote.exact) : null, textarea, draft.screenshot ? h("div", { class: "shot-preview" }, h("img", {
7963
+ src: draft.screenshot.preview,
7964
+ alt: this.t("screenshotAlt")
7965
+ }), h("div", { class: "shot-note" }, h("span", {}, this.t("screenshotNote")), h("span", { class: "spacer" }), h("button", {
7966
+ class: "link",
7967
+ type: "button",
7968
+ onclick: () => {
7969
+ draft.screenshot = void 0;
7970
+ this.render();
7971
+ }
7972
+ }, this.t("remove")))) : null, draft.error ? h("div", {
5740
7973
  class: "error",
5741
7974
  role: "alert"
5742
- }, draft.error) : null, h("div", { class: "row" }, h("span", { class: "kbd" }, "⌘ + Enter"), h("span", { class: "spacer" }), h("button", {
7975
+ }, draft.error) : null, h("div", { class: "row" }, h("span", { class: "kbd" }, this.t("send", { key: this.i18n.mod })), h("span", { class: "spacer" }), h("button", {
5743
7976
  class: "btn btn-ghost",
5744
7977
  type: "button",
5745
7978
  onclick: () => this.closeCard()
5746
- }, "Cancel"), h("button", {
7979
+ }, this.t("cancel")), h("button", {
5747
7980
  class: "btn btn-primary",
5748
7981
  type: "submit",
5749
7982
  disabled: Boolean(draft.sending)
5750
- }, icon(ICONS.send), draft.sending ? "Posting…" : "Post"))));
7983
+ }, icon(ICONS.send), draft.sending ? this.t("posting") : this.t("post")))));
5751
7984
  }
5752
7985
  const comment = this.comments.find((c) => c._id === card.id);
5753
7986
  if (!comment) return null;
@@ -5770,30 +8003,39 @@ var NuniWidget = class {
5770
8003
  card.editing = false;
5771
8004
  this.render();
5772
8005
  }
5773
- }, "Cancel"), h("button", {
8006
+ }, this.t("cancel")), h("button", {
5774
8007
  class: "btn btn-primary",
5775
8008
  type: "button",
5776
8009
  disabled: Boolean(card.busy),
5777
8010
  onclick: () => this.saveEdit(comment, textarea.value)
5778
- }, "Save")));
8011
+ }, this.t("save"))));
5779
8012
  } else body = h("div", { class: "card-body" }, h("div", { class: "card-head" }, h("span", {
5780
8013
  class: "avatar",
5781
8014
  style: `background:${colorFor(comment.authorName)}`
5782
- }, initials(comment.authorName)), h("span", { class: "author" }, comment.authorName), h("span", { class: "meta" }, timeAgo(comment.createdAt) + (comment.editedAt ? " · edited" : "")), h("span", { class: "spacer" }), h("button", {
8015
+ }, initials(comment.authorName)), h("span", { class: "author" }, comment.authorName), h("span", { class: "meta" }, this.i18n.timeAgo(comment.createdAt) + (comment.editedAt ? ` · ${this.t("edited")}` : "")), h("span", { class: "spacer" }), h("button", {
5783
8016
  class: "btn btn-ghost btn-icon",
5784
8017
  type: "button",
5785
- "aria-label": "Close",
8018
+ "aria-label": this.t("close"),
5786
8019
  onclick: () => this.closeCard()
5787
- }, icon(ICONS.close))), h("div", { class: "comment-body" }, comment.body), h("div", { class: "row" }, comment.status === "resolved" ? h("span", { class: "badge badge-ok" }, icon(ICONS.check), "Resolved") : null, comment.page.origin !== location.origin ? h("span", {
8020
+ }, icon(ICONS.close))), comment.anchor.quote ? h("blockquote", { class: "quote" }, comment.anchor.quote.exact) : null, h("div", { class: "comment-body" }, comment.body), this.ownerDetail?._id === comment._id && this.ownerDetail.screenshotUrl ? h("a", {
8021
+ class: "shot",
8022
+ href: this.ownerDetail.screenshotUrl,
8023
+ target: "_blank",
8024
+ rel: "noreferrer",
8025
+ title: this.t("openScreenshot")
8026
+ }, h("img", {
8027
+ src: this.ownerDetail.screenshotUrl,
8028
+ alt: this.t("screenshotTaken")
8029
+ })) : null, h("div", { class: "row" }, comment.status === "resolved" ? h("span", { class: "badge badge-ok" }, icon(ICONS.check), this.t("resolved")) : null, comment.page.origin !== location.origin ? h("span", {
5788
8030
  class: "badge",
5789
- title: `Left on ${comment.page.origin}`
8031
+ title: this.t("leftOn", { origin: comment.page.origin })
5790
8032
  }, new URL(comment.page.origin).host) : null, placement?.confidence === "low" ? h("span", {
5791
8033
  class: "badge",
5792
- title: "The page changed; this pin is a best guess"
5793
- }, "Approximate") : null, !placement?.element ? h("span", {
8034
+ title: this.t("approximateHint")
8035
+ }, this.t("approximate")) : null, !placement?.element ? h("span", {
5794
8036
  class: "badge",
5795
- title: `Originally on: ${comment.anchor.text || comment.anchor.tag}`
5796
- }, "Element not found") : null), card.error ? h("div", {
8037
+ title: this.t("originallyOn", { text: comment.anchor.text || comment.anchor.tag })
8038
+ }, this.t("notFound")) : null), this.renderReactions(comment, comment._id), card.error ? h("div", {
5797
8039
  class: "error",
5798
8040
  role: "alert"
5799
8041
  }, card.error) : null);
@@ -5804,18 +8046,27 @@ var NuniWidget = class {
5804
8046
  type: "button",
5805
8047
  disabled: Boolean(card.busy),
5806
8048
  onclick: () => this.resolveComment(comment)
5807
- }, icon(ICONS.check), "Resolve") : h("button", {
8049
+ }, icon(ICONS.check), this.t("resolve")) : h("button", {
5808
8050
  class: "btn",
5809
8051
  type: "button",
5810
8052
  disabled: Boolean(card.busy),
5811
8053
  onclick: () => this.reopenComment(comment)
5812
- }, icon(ICONS.undo), "Reopen"));
8054
+ }, icon(ICONS.undo), this.t("reopen")));
5813
8055
  actions.push(h("span", { class: "spacer" }));
8056
+ const contextPending = owner && !(this.ownerDetailLoaded && this.ownerDetailId === comment._id);
8057
+ actions.push(h("button", {
8058
+ class: "btn btn-ghost btn-icon",
8059
+ type: "button",
8060
+ "aria-label": this.t("copyForAgent"),
8061
+ title: contextPending ? this.t("loadingContext") : this.t("copyForAgentHint"),
8062
+ disabled: contextPending,
8063
+ onclick: () => this.copyForAgent(comment)
8064
+ }, icon(ICONS.bot)));
5814
8065
  if (mine) actions.push(h("button", {
5815
8066
  class: "btn btn-ghost btn-icon",
5816
8067
  type: "button",
5817
- "aria-label": "Edit",
5818
- title: "Edit",
8068
+ "aria-label": this.t("edit"),
8069
+ title: this.t("edit"),
5819
8070
  onclick: () => {
5820
8071
  card.editing = true;
5821
8072
  this.render();
@@ -5824,18 +8075,164 @@ var NuniWidget = class {
5824
8075
  if (mine || owner) actions.push(h("button", {
5825
8076
  class: "btn btn-ghost btn-icon btn-danger",
5826
8077
  type: "button",
5827
- "aria-label": "Delete",
5828
- title: "Delete",
8078
+ "aria-label": this.t("delete"),
8079
+ title: this.t("delete"),
8080
+ "data-focus-key": "delete",
5829
8081
  disabled: Boolean(card.busy),
5830
- onclick: () => this.deleteComment(comment)
8082
+ onclick: () => void this.deleteComment(comment)
5831
8083
  }, icon(ICONS.trash)));
5832
8084
  }
5833
8085
  return h("div", {
5834
8086
  class: cls,
5835
8087
  "data-card": "thread",
5836
8088
  role: "dialog",
5837
- "aria-label": `Comment by ${comment.authorName}`
5838
- }, body, actions.length > 1 || owner ? h("div", { class: "actions" }, ...actions) : null);
8089
+ "aria-label": this.t("commentBy", { name: comment.authorName })
8090
+ }, body, actions.length ? h("div", { class: "actions" }, ...actions) : null, card.editing ? null : this.renderThread(comment));
8091
+ }
8092
+ /** Emoji chips for one message, plus a picker to add one. */
8093
+ renderReactions(comment, targetId) {
8094
+ const card = this.card;
8095
+ if (card?.kind !== "thread") return null;
8096
+ const summaries = this.threadId === comment._id && this.thread ? this.thread.reactions.filter((r) => r.targetId === targetId) : [];
8097
+ const open = card.picker === targetId;
8098
+ return h("div", { class: "reactions" }, ...summaries.map((r) => {
8099
+ const mine = r.authorKeyHashes.includes(this.secretHash);
8100
+ return h("button", {
8101
+ class: "reaction",
8102
+ type: "button",
8103
+ "aria-pressed": String(mine),
8104
+ "aria-label": `${r.emoji} ${r.count}${mine ? ", including you" : ""}`,
8105
+ onclick: () => this.toggleReaction(comment, targetId, r.emoji)
8106
+ }, `${r.emoji} ${r.count}`);
8107
+ }), h("button", {
8108
+ class: "reaction reaction-add",
8109
+ type: "button",
8110
+ "aria-label": this.t("addReaction"),
8111
+ "aria-expanded": String(open),
8112
+ title: this.t("addReaction"),
8113
+ onclick: () => {
8114
+ card.picker = open ? null : targetId;
8115
+ this.render();
8116
+ }
8117
+ }, icon(ICONS.smile)), open ? h("div", {
8118
+ class: "reaction-picker",
8119
+ role: "group",
8120
+ "aria-label": this.t("reactions")
8121
+ }, ...REACTIONS.map((emoji) => h("button", {
8122
+ class: "reaction",
8123
+ type: "button",
8124
+ "aria-label": this.t("reactWith", { emoji }),
8125
+ onclick: () => {
8126
+ card.picker = null;
8127
+ this.toggleReaction(comment, targetId, emoji);
8128
+ this.render();
8129
+ }
8130
+ }, emoji))) : null);
8131
+ }
8132
+ /** The replies under a comment and the box to add one. */
8133
+ renderThread(comment) {
8134
+ const card = this.card;
8135
+ if (card?.kind !== "thread") return null;
8136
+ const replies = this.threadId === comment._id ? this.thread?.replies ?? [] : [];
8137
+ const owner = this.isOwner;
8138
+ const items = replies.map((reply) => {
8139
+ const mine = this.isMyReply(reply);
8140
+ if (card.replyEditing === reply._id) {
8141
+ const box = h("textarea", {
8142
+ class: "field",
8143
+ maxlength: LIMITS.bodyMaxLength,
8144
+ "data-focus-key": `reply-edit-${reply._id}`,
8145
+ "aria-label": this.t("editReply")
8146
+ });
8147
+ const prev = this.uiLayer.querySelector(`[data-focus-key="reply-edit-${reply._id}"]`);
8148
+ box.value = prev ? prev.value : reply.body;
8149
+ return h("div", { class: "reply" }, box, h("div", { class: "row" }, h("span", { class: "spacer" }), h("button", {
8150
+ class: "btn btn-ghost",
8151
+ type: "button",
8152
+ onclick: () => {
8153
+ card.replyEditing = void 0;
8154
+ this.render();
8155
+ }
8156
+ }, this.t("cancel")), h("button", {
8157
+ class: "btn btn-primary",
8158
+ type: "button",
8159
+ disabled: Boolean(card.busy),
8160
+ onclick: () => this.saveReplyEdit(comment, reply, box.value)
8161
+ }, this.t("save"))));
8162
+ }
8163
+ return h("div", {
8164
+ class: "reply",
8165
+ "data-reply": reply._id
8166
+ }, h("div", { class: "card-head" }, h("span", {
8167
+ class: "avatar avatar-sm",
8168
+ style: `background:${colorFor(reply.authorName)}`
8169
+ }, initials(reply.authorName)), h("span", { class: "author" }, reply.authorName), reply.isOwner ? h("span", { class: "badge badge-owner" }, this.t("owner")) : null, h("span", { class: "meta" }, this.i18n.timeAgo(reply.createdAt) + (reply.editedAt ? ` · ${this.t("edited")}` : "")), h("span", { class: "spacer" }), mine ? h("button", {
8170
+ class: "btn btn-ghost btn-icon btn-xs",
8171
+ type: "button",
8172
+ "aria-label": this.t("editReply"),
8173
+ title: this.t("edit"),
8174
+ onclick: () => {
8175
+ card.replyEditing = reply._id;
8176
+ this.render();
8177
+ }
8178
+ }, icon(ICONS.edit)) : null, mine || owner ? h("button", {
8179
+ class: "btn btn-ghost btn-icon btn-xs btn-danger",
8180
+ type: "button",
8181
+ "aria-label": this.t("deleteReply"),
8182
+ title: this.t("delete"),
8183
+ "data-focus-key": `delete-${reply._id}`,
8184
+ disabled: Boolean(card.busy),
8185
+ onclick: () => void this.deleteReply(comment, reply)
8186
+ }, icon(ICONS.trash)) : null), h("div", { class: "comment-body" }, reply.body), this.renderReactions(comment, reply._id));
8187
+ });
8188
+ const needsName = !owner && !this.name;
8189
+ const nameInput = h("input", {
8190
+ class: "field",
8191
+ placeholder: this.t("yourName"),
8192
+ value: this.name,
8193
+ maxlength: LIMITS.nameMaxLength,
8194
+ autocomplete: "name",
8195
+ "data-focus-key": "reply-name",
8196
+ "aria-label": this.t("yourName")
8197
+ });
8198
+ const prevName = this.uiLayer.querySelector("[data-focus-key=\"reply-name\"]");
8199
+ if (prevName) nameInput.value = prevName.value;
8200
+ const box = h("textarea", {
8201
+ class: "field field-reply",
8202
+ placeholder: replies.length ? this.t("reply") : this.t("replyFirst"),
8203
+ maxlength: LIMITS.bodyMaxLength,
8204
+ "data-focus-key": "reply",
8205
+ "aria-label": this.t("reply")
8206
+ });
8207
+ const prev = this.uiLayer.querySelector("[data-focus-key=\"reply\"]");
8208
+ if (prev) box.value = prev.value;
8209
+ const send = () => void this.sendReply(comment, box.value, needsName ? nameInput.value : this.name);
8210
+ box.addEventListener("keydown", (e) => {
8211
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
8212
+ e.preventDefault();
8213
+ send();
8214
+ }
8215
+ });
8216
+ return h("div", { class: "thread" }, items.length ? h("div", {
8217
+ class: "replies",
8218
+ role: "list",
8219
+ "aria-label": this.t("replyCount", { count: items.length })
8220
+ }, ...items.map((item) => {
8221
+ item.setAttribute("role", "listitem");
8222
+ return item;
8223
+ })) : null, h("form", {
8224
+ class: "reply-form",
8225
+ onsubmit: (e) => {
8226
+ e.preventDefault();
8227
+ send();
8228
+ }
8229
+ }, needsName ? nameInput : null, box, h("button", {
8230
+ class: "btn btn-primary btn-icon",
8231
+ type: "submit",
8232
+ "aria-label": this.t("sendReply"),
8233
+ title: this.t("sendReply"),
8234
+ disabled: Boolean(card.sendingReply)
8235
+ }, icon(ICONS.send))));
5839
8236
  }
5840
8237
  positionCard() {
5841
8238
  const el = this.uiLayer.querySelector("[data-card]");
@@ -5843,11 +8240,7 @@ var NuniWidget = class {
5843
8240
  let point = null;
5844
8241
  if (this.card?.kind === "composer") {
5845
8242
  const { draft } = this.card;
5846
- const r = draft.element.getBoundingClientRect();
5847
- point = {
5848
- x: r.left + draft.anchor.offset.x * r.width,
5849
- y: r.top + draft.anchor.offset.y * r.height
5850
- };
8243
+ point = this.anchorPoint(draft.anchor, draft.element, draft.range);
5851
8244
  } else if (this.card?.kind === "thread") {
5852
8245
  const comment = this.comments.find((c) => this.card?.kind === "thread" && c._id === this.card.id);
5853
8246
  point = comment ? this.pinPositions.get(comment._id) ?? this.pinPoint(comment) : null;
@@ -5863,8 +8256,9 @@ var NuniWidget = class {
5863
8256
  top = point.y - 28;
5864
8257
  if (left + width > vw - 12) left = point.x - width - 12;
5865
8258
  } else {
5866
- left = vw - width - 16;
5867
- top = vh - height - 72;
8259
+ const { position } = this.config;
8260
+ left = position.endsWith("left") ? 16 : vw - width - 16;
8261
+ top = position.startsWith("top") ? 72 : vh - height - 72;
5868
8262
  }
5869
8263
  left = Math.max(12, Math.min(left, vw - width - 12));
5870
8264
  top = Math.max(12, Math.min(top, vh - height - 12));
@@ -5878,15 +8272,27 @@ var NuniWidget = class {
5878
8272
  }
5879
8273
  };
5880
8274
  //#endregion
5881
- //#region src/index.ts
8275
+ //#region src/mount.ts
8276
+ /** Nuni's own requests are not the page's problem. */
8277
+ function isNuniUrl(config) {
8278
+ const origins = [config.convexUrl, config.convexSiteUrl].flatMap((url) => {
8279
+ try {
8280
+ return [new URL(url).origin];
8281
+ } catch {
8282
+ return [];
8283
+ }
8284
+ });
8285
+ return (url) => {
8286
+ try {
8287
+ return origins.includes(new URL(url, location.href).origin);
8288
+ } catch {
8289
+ return false;
8290
+ }
8291
+ };
8292
+ }
5882
8293
  let active = null;
5883
- /**
5884
- * Mount Nuni on the page. Call once on the client. Calling it again with the
5885
- * same project shares the mounted widget; it is removed when every handle is
5886
- * destroyed. A different project replaces it. Each handle only ever tears
5887
- * down the widget it was created for.
5888
- */
5889
- function init(options) {
8294
+ /** The shared implementation of `init` for both builds (see index.ts). */
8295
+ function mount(options, runtime) {
5890
8296
  const noop = { destroy() {} };
5891
8297
  if (typeof window === "undefined" || typeof document === "undefined") return noop;
5892
8298
  if (!isProjectId(options.project)) {
@@ -5899,22 +8305,26 @@ function init(options) {
5899
8305
  const state = {
5900
8306
  project: options.project,
5901
8307
  widget: null,
8308
+ collectors: startCollectors(config.capture, isNuniUrl(config)),
5902
8309
  timer: 0,
5903
8310
  handles: 0
5904
8311
  };
5905
8312
  active = state;
5906
- const mount = () => {
8313
+ const start = () => {
5907
8314
  if (active !== state) return;
5908
8315
  if (!document.body) {
5909
- state.timer = window.setTimeout(mount, 50);
8316
+ state.timer = window.setTimeout(start, 50);
5910
8317
  return;
5911
8318
  }
5912
- state.widget = new NuniWidget(config);
8319
+ state.widget = new NuniWidget(config, {
8320
+ collectors: state.collectors,
8321
+ loadScreenshot: config.capture.screenshot ? runtime.loadScreenshot : null
8322
+ });
5913
8323
  state.widget.start();
5914
8324
  };
5915
8325
  const idle = window.requestIdleCallback;
5916
- if (idle) idle(mount, { timeout: 1500 });
5917
- else state.timer = window.setTimeout(mount, 1);
8326
+ if (idle) idle(start, { timeout: 1500 });
8327
+ else state.timer = window.setTimeout(start, 1);
5918
8328
  return handleFor(state);
5919
8329
  }
5920
8330
  function handleFor(state) {
@@ -5931,7 +8341,19 @@ function unmount(state) {
5931
8341
  clearTimeout(state.timer);
5932
8342
  state.widget?.destroy();
5933
8343
  state.widget = null;
8344
+ state.collectors.stop();
5934
8345
  if (active === state) active = null;
5935
8346
  }
5936
8347
  //#endregion
5937
- export { VERSION, init };
8348
+ //#region src/index.ts
8349
+ /**
8350
+ * Mount Nuni on the page. Call once on the client. Calling it again with the
8351
+ * same project shares the mounted widget; it is removed when every handle is
8352
+ * destroyed. A different project replaces it. Each handle only ever tears
8353
+ * down the widget it was created for.
8354
+ */
8355
+ function init(options) {
8356
+ return mount(options, { loadScreenshot: () => import("./screenshot-DcJ_SzKF.js").then((m) => m.captureScreenshot) });
8357
+ }
8358
+ //#endregion
8359
+ export { VERSION, init, EN as messages };