@nuniapp/widget 0.1.2 → 0.1.4

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 generateSecret, i as LIMITS, n as MASK_ATTRIBUTE, o as isProjectId, r as MASK_SELECTOR, t as CAPTURE_MARK } from "./mark-BeysEAHp.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,417 @@ 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/comment-prompt.ts
95
+ /** A code fence longer than any backtick run inside `content`. */
96
+ function fence(content, lang = "") {
97
+ const longest = Math.max(0, ...Array.from(content.matchAll(/`+/g), (m) => m[0].length));
98
+ const ticks = "`".repeat(Math.max(3, longest + 1));
99
+ return `${ticks}${lang}\n${content}\n${ticks}`;
100
+ }
101
+ function quote(text) {
102
+ return text.split("\n").map((line) => line ? `> ${line}` : ">").join("\n");
103
+ }
104
+ function inline(text) {
105
+ const clean = text.replace(/\s+/g, " ").trim();
106
+ return clean.includes("`") ? `"${clean}"` : `\`${clean}\``;
107
+ }
108
+ /**
109
+ * One comment as a ready-to-paste task for a coding agent. Single source of
110
+ * truth for the widget, dashboard, CLI and MCP server.
111
+ */
112
+ function buildCommentPrompt(comment, options = {}) {
113
+ const includeContext = options.includeContext ?? true;
114
+ const { anchor, page } = comment;
115
+ const out = [];
116
+ 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)" : ""));
117
+ out.push("", "## Where", "");
118
+ out.push(`- Page: ${page.title ? `${page.title} ` : ""}(${page.path})`);
119
+ out.push(`- Open it: ${commentUrl(page, comment._id)}`);
120
+ out.push(`- Viewport: ${comment.viewport.w}×${comment.viewport.h} at ${comment.viewport.dpr}x`);
121
+ if (includeContext && comment.userAgent) out.push(`- Browser: ${comment.userAgent}`);
122
+ out.push("", "## Element", "");
123
+ out.push(`- Tag: <${anchor.tag}>`);
124
+ if (anchor.text) out.push(`- Text: ${inline(anchor.text)}`);
125
+ if (anchor.componentName) out.push(`- React component: ${anchor.componentName}`);
126
+ if (anchor.selectors.testId) out.push(`- Test ID: ${inline(anchor.selectors.testId)}`);
127
+ if (anchor.selectors.id) out.push(`- ID: ${inline(anchor.selectors.id)}`);
128
+ if (anchor.selectors.css) out.push(`- CSS selector: ${inline(anchor.selectors.css)}`);
129
+ out.push(`- DOM path: ${inline(anchor.selectors.path)}`);
130
+ const labels = Object.entries(anchor.attrs).filter(([name]) => name !== "class" && name !== "style").map(([name, value]) => `${name}="${value}"`);
131
+ if (labels.length) out.push(`- Attributes: ${inline(labels.join(" "))}`);
132
+ const context = includeContext ? comment.context : void 0;
133
+ if (context?.dom?.html) out.push("", fence(context.dom.html, "html"));
134
+ const styles = Object.entries(context?.dom?.styles ?? {});
135
+ if (styles.length) out.push("", "Computed styles:", "", fence(styles.map(([k, v]) => `${k}: ${v};`).join("\n"), "css"));
136
+ if (context?.console?.length) {
137
+ out.push("", `## Console (${context.console.length})`, "");
138
+ out.push(fence(context.console.map((e) => `[${e.level}] ${e.message}`).join("\n"), "text"));
139
+ }
140
+ if (context?.network?.length) {
141
+ out.push("", `## Failed requests (${context.network.length})`, "");
142
+ for (const r of context.network) out.push(`- ${r.method} ${r.url} → ${r.status === 0 ? "network error" : r.status}`);
143
+ }
144
+ if (includeContext && comment.screenshotUrl) out.push("", "## Screenshot", "", comment.screenshotUrl);
145
+ 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.");
146
+ return out.join("\n");
147
+ }
74
148
  //#endregion
75
149
  //#region src/config.ts
76
- const VERSION = "0.1.2";
150
+ const VERSION = "0.1.4";
77
151
  function resolveConfig(options) {
78
152
  return {
79
153
  project: options.project,
80
154
  convexUrl: options.convexUrl ?? "https://greedy-giraffe-33.convex.cloud",
81
155
  convexSiteUrl: options.convexSiteUrl ?? "https://greedy-giraffe-33.convex.site",
82
156
  appUrl: (options.appUrl ?? "https://nuni.praveenjuge.com").replace(/\/$/, ""),
83
- getPageKey: options.getPageKey
157
+ getPageKey: options.getPageKey,
158
+ capture: {
159
+ console: options.capture?.console ?? true,
160
+ network: options.capture?.network ?? true,
161
+ dom: options.capture?.dom ?? true,
162
+ screenshot: options.capture?.screenshot ?? true
163
+ }
164
+ };
165
+ }
166
+ //#endregion
167
+ //#region \0@oxc-project+runtime@0.151.0/helpers/esm/typeof.js
168
+ function _typeof(o) {
169
+ "@babel/helpers - typeof";
170
+ return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
171
+ return typeof o;
172
+ } : function(o) {
173
+ return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o;
174
+ }, _typeof(o);
175
+ }
176
+ //#endregion
177
+ //#region \0@oxc-project+runtime@0.151.0/helpers/esm/toPrimitive.js
178
+ function toPrimitive(t, r) {
179
+ if ("object" != _typeof(t) || !t) return t;
180
+ var e = t[Symbol.toPrimitive];
181
+ if (void 0 !== e) {
182
+ var i = e.call(t, r || "default");
183
+ if ("object" != _typeof(i)) return i;
184
+ throw new TypeError("@@toPrimitive must return a primitive value.");
185
+ }
186
+ return ("string" === r ? String : Number)(t);
187
+ }
188
+ //#endregion
189
+ //#region \0@oxc-project+runtime@0.151.0/helpers/esm/toPropertyKey.js
190
+ function toPropertyKey(t) {
191
+ var i = toPrimitive(t, "string");
192
+ return "symbol" == _typeof(i) ? i : i + "";
193
+ }
194
+ //#endregion
195
+ //#region \0@oxc-project+runtime@0.151.0/helpers/esm/defineProperty.js
196
+ function _defineProperty(e, r, t) {
197
+ return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
198
+ value: t,
199
+ enumerable: !0,
200
+ configurable: !0,
201
+ writable: !0
202
+ }) : e[r] = t, e;
203
+ }
204
+ //#endregion
205
+ //#region src/context.ts
206
+ const MESSAGE_MAX = LIMITS.contextMessageMaxLength;
207
+ function describeValue(value) {
208
+ if (typeof value === "string") return value;
209
+ if (value instanceof Error) {
210
+ const frame = value.stack?.split("\n").find((line) => line.trim().startsWith("at "));
211
+ return `${value.name}: ${value.message}${frame ? ` (${frame.trim()})` : ""}`;
212
+ }
213
+ try {
214
+ let budget = 200;
215
+ return JSON.stringify(value, (_key, v) => budget-- > 0 ? v : void 0) ?? String(value);
216
+ } catch {
217
+ return String(value);
218
+ }
219
+ }
220
+ function clip(text) {
221
+ return text.length > MESSAGE_MAX ? `${text.slice(0, MESSAGE_MAX - 1)}…` : text;
222
+ }
223
+ /** Origin + path only: query strings and hashes often carry tokens. */
224
+ function safeUrl(input) {
225
+ return clip(withoutQuery(input, location.href));
226
+ }
227
+ var Ring = class {
228
+ constructor() {
229
+ _defineProperty(this, "items", []);
230
+ }
231
+ push(item) {
232
+ this.items.push(item);
233
+ if (this.items.length > LIMITS.contextEntryMax) this.items.shift();
234
+ }
235
+ };
236
+ /**
237
+ * Keep the latest console errors and failed requests, so a comment can carry
238
+ * what went wrong on the page. Installed once per mount, removed on destroy.
239
+ * `ignore` skips Nuni's own traffic.
240
+ */
241
+ function startCollectors(options, ignore) {
242
+ const logs = new Ring();
243
+ const requests = new Ring();
244
+ const undo = [];
245
+ const log = (level, args) => {
246
+ logs.push({
247
+ level,
248
+ message: clip(stripUrlQueries(args.map(describeValue).join(" "))),
249
+ at: Date.now()
250
+ });
251
+ };
252
+ const failed = (method, url, status) => {
253
+ if (ignore(url)) return;
254
+ requests.push({
255
+ method: method.toUpperCase(),
256
+ url: safeUrl(url),
257
+ status,
258
+ at: Date.now()
259
+ });
260
+ };
261
+ if (options.console) {
262
+ for (const level of ["error", "warn"]) {
263
+ const original = console[level];
264
+ const wrapped = (...args) => {
265
+ log(level, args);
266
+ original.apply(console, args);
267
+ };
268
+ console[level] = wrapped;
269
+ undo.push(() => {
270
+ if (console[level] === wrapped) console[level] = original;
271
+ });
272
+ }
273
+ const onError = (e) => {
274
+ if (e.error) log("error", [e.error]);
275
+ else log("error", [`${e.message}${e.filename ? ` (${safeUrl(e.filename)}:${e.lineno})` : ""}`]);
276
+ };
277
+ const onRejection = (e) => log("error", ["Unhandled rejection:", e.reason]);
278
+ window.addEventListener("error", onError);
279
+ window.addEventListener("unhandledrejection", onRejection);
280
+ undo.push(() => {
281
+ window.removeEventListener("error", onError);
282
+ window.removeEventListener("unhandledrejection", onRejection);
283
+ });
284
+ }
285
+ if (options.network) {
286
+ const originalFetch = window.fetch;
287
+ if (typeof originalFetch === "function") {
288
+ const wrappedFetch = async (input, init) => {
289
+ if (init && init[CAPTURE_MARK]) return originalFetch.call(window, input, init);
290
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
291
+ const method = init?.method ?? (typeof input === "object" && "method" in input ? input.method : "GET");
292
+ try {
293
+ const response = await originalFetch.call(window, input, init);
294
+ if (!response.ok && response.type !== "opaque") failed(method, url, response.status);
295
+ return response;
296
+ } catch (error) {
297
+ if (!(error instanceof DOMException && error.name === "AbortError")) failed(method, url, 0);
298
+ throw error;
299
+ }
300
+ };
301
+ window.fetch = wrappedFetch;
302
+ undo.push(() => {
303
+ if (window.fetch === wrappedFetch) window.fetch = originalFetch;
304
+ });
305
+ }
306
+ const proto = window.XMLHttpRequest?.prototype;
307
+ if (proto) {
308
+ const open = proto.open;
309
+ const send = proto.send;
310
+ const info = /* @__PURE__ */ new WeakMap();
311
+ const wrappedOpen = function(method, url, ...rest) {
312
+ info.set(this, {
313
+ method,
314
+ url: String(url)
315
+ });
316
+ return open.call(this, method, url, ...rest);
317
+ };
318
+ proto.open = wrappedOpen;
319
+ const wrappedSend = function(body) {
320
+ const request = info.get(this);
321
+ if (request) {
322
+ let aborted = false;
323
+ const onAbort = () => aborted = true;
324
+ const onEnd = () => {
325
+ this.removeEventListener("abort", onAbort);
326
+ this.removeEventListener("loadend", onEnd);
327
+ if (!aborted && (this.status === 0 || this.status >= 400)) failed(request.method, request.url, this.status);
328
+ };
329
+ this.addEventListener("abort", onAbort);
330
+ this.addEventListener("loadend", onEnd);
331
+ }
332
+ return send.call(this, body);
333
+ };
334
+ proto.send = wrappedSend;
335
+ undo.push(() => {
336
+ if (proto.open === wrappedOpen) proto.open = open;
337
+ if (proto.send === wrappedSend) proto.send = send;
338
+ });
339
+ }
340
+ }
341
+ return {
342
+ console: () => [...logs.items],
343
+ network: () => [...requests.items],
344
+ stop() {
345
+ for (const fn of undo.splice(0)) fn();
346
+ }
347
+ };
348
+ }
349
+ const STYLE_PROPS = [
350
+ "display",
351
+ "position",
352
+ "width",
353
+ "height",
354
+ "margin",
355
+ "padding",
356
+ "gap",
357
+ "font-family",
358
+ "font-size",
359
+ "font-weight",
360
+ "line-height",
361
+ "letter-spacing",
362
+ "text-align",
363
+ "color",
364
+ "background-color",
365
+ "border",
366
+ "border-radius",
367
+ "box-shadow",
368
+ "opacity"
369
+ ];
370
+ const ATTR_MAX = 200;
371
+ const TEXT_MAX = 200;
372
+ /** Replace an element's contents with a short note. */
373
+ function redact(el, note) {
374
+ el.replaceChildren(el.ownerDocument.createComment(` ${note} `));
375
+ }
376
+ /**
377
+ * Remove what should never leave the page (form values, option lists and
378
+ * masked areas) and trim the rest.
379
+ */
380
+ function scrub(root) {
381
+ const walker = root.ownerDocument.createTreeWalker(root, 5);
382
+ const drop = [];
383
+ for (let node = root; node; node = walker.nextNode()) {
384
+ if (node.nodeType === 3) {
385
+ const text = node.textContent ?? "";
386
+ if (text.length > TEXT_MAX) node.textContent = `${text.slice(0, TEXT_MAX)}…`;
387
+ continue;
388
+ }
389
+ const el = node;
390
+ const tag = el.tagName;
391
+ if (tag === "SCRIPT" || tag === "STYLE" || tag === "NOSCRIPT") {
392
+ drop.push(el);
393
+ continue;
394
+ }
395
+ if (el.hasAttribute("data-nuni-mask")) {
396
+ for (const attr of Array.from(el.attributes)) if (attr.name !== "id" && attr.name !== "class" && attr.name !== "data-nuni-mask") el.removeAttribute(attr.name);
397
+ redact(el, "masked");
398
+ continue;
399
+ }
400
+ if (tag === "INPUT" || tag === "TEXTAREA") {
401
+ el.removeAttribute("value");
402
+ if (tag === "TEXTAREA") el.textContent = "";
403
+ }
404
+ if (tag === "SELECT" || tag === "DATALIST") {
405
+ const count = el.getElementsByTagName("option").length;
406
+ redact(el, `${count} options`);
407
+ }
408
+ for (const attr of Array.from(el.attributes)) if (attr.value.length > ATTR_MAX) el.setAttribute(attr.name, `${attr.value.slice(0, ATTR_MAX)}…`);
409
+ }
410
+ for (const node of drop) node.parentNode?.removeChild(node);
411
+ }
412
+ /** Clone at most `depth` levels of children. */
413
+ function shallowClone(el, depth) {
414
+ const copy = el.cloneNode(false);
415
+ if (depth > 0) for (const child of Array.from(el.childNodes)) copy.appendChild(child.nodeType === 1 ? shallowClone(child, depth - 1) : child.cloneNode(false));
416
+ else if (el.childElementCount) copy.appendChild(el.ownerDocument.createComment(` ${el.childElementCount} more `));
417
+ return copy;
418
+ }
419
+ /** The element's markup and key computed styles, for design feedback. */
420
+ function domContext(el) {
421
+ const max = LIMITS.domSnippetMaxLength;
422
+ let html = "";
423
+ const inMasked = Boolean(el.parentElement?.closest(`[${MASK_ATTRIBUTE}]`));
424
+ for (const depth of inMasked ? [-1] : [
425
+ Infinity,
426
+ 3,
427
+ 1,
428
+ 0
429
+ ]) {
430
+ const copy = depth === Infinity ? el.cloneNode(true) : shallowClone(el, Math.max(0, depth));
431
+ if (depth < 0) copy.setAttribute(MASK_ATTRIBUTE, "");
432
+ scrub(copy);
433
+ html = stripHtmlUrlQueries(copy.outerHTML);
434
+ if (html.length <= max) break;
435
+ }
436
+ if (html.length > max) html = `${html.slice(0, max - 1)}…`;
437
+ const styles = {};
438
+ const computed = el.ownerDocument.defaultView?.getComputedStyle(el);
439
+ for (const prop of STYLE_PROPS) {
440
+ const value = computed?.getPropertyValue(prop).trim();
441
+ if (value) styles[prop] = value;
442
+ }
443
+ return {
444
+ html,
445
+ styles
84
446
  };
85
447
  }
86
448
  //#endregion
@@ -359,7 +721,7 @@ function describeSegment(el, options) {
359
721
  }
360
722
  }
361
723
  const label = el.getAttribute("aria-label");
362
- if (label && label.length <= 60) segment += attrSelector("aria-label", label);
724
+ if (label && label.length <= 60 && !el.closest(MASK_SELECTOR)) segment += attrSelector("aria-label", label);
363
725
  return segment;
364
726
  }
365
727
  /**
@@ -411,8 +773,14 @@ const SKIP_TEXT_TAGS = /* @__PURE__ */ new Set([
411
773
  function normalizeText(value) {
412
774
  return value.replace(/\s+/g, " ").trim();
413
775
  }
414
- /** Visible-ish text of an element, whitespace-normalized and truncated. */
776
+ /**
777
+ * Visible-ish text of an element, whitespace-normalized and truncated.
778
+ * Areas marked data-nuni-mask count as empty, both when capturing and when
779
+ * resolving, so their (private) text is never stored and matching stays
780
+ * consistent.
781
+ */
415
782
  function elementText(el, max = 120) {
783
+ if (el.closest(MASK_SELECTOR)) return "";
416
784
  if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) return normalizeText(el.placeholder || el.getAttribute("aria-label") || "").slice(0, max);
417
785
  let out = "";
418
786
  const walk = (node) => {
@@ -424,6 +792,7 @@ function elementText(el, max = 120) {
424
792
  if (node.nodeType !== 1) return;
425
793
  const tag = node.tagName.toUpperCase();
426
794
  if (SKIP_TEXT_TAGS.has(tag)) return;
795
+ if (node.hasAttribute("data-nuni-mask")) return;
427
796
  if (tag === "IMG") {
428
797
  out += " " + (node.getAttribute("alt") ?? "") + " ";
429
798
  return;
@@ -463,6 +832,7 @@ function textSimilarity(a, b) {
463
832
  //#region ../anchor/src/capture.ts
464
833
  function capturedAttributes(el, options) {
465
834
  const attrs = {};
835
+ if (el.closest(MASK_SELECTOR)) return attrs;
466
836
  for (const name of CAPTURED_ATTRIBUTES) {
467
837
  if (!attributeAllowed(name, options)) continue;
468
838
  if (name === "value" && !(el instanceof HTMLButtonElement || el.getAttribute("type") === "submit")) continue;
@@ -4188,44 +4558,6 @@ var ConvexClient = class {
4188
4558
  }
4189
4559
  };
4190
4560
  //#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
4561
  //#region src/api.ts
4230
4562
  var NuniApiError = class extends Error {
4231
4563
  constructor(message, code) {
@@ -4275,6 +4607,14 @@ var NuniApi = class {
4275
4607
  sessionToken
4276
4608
  }, cb, () => {});
4277
4609
  }
4610
+ /** A comment with the owner-only context (screenshot, console, DOM). */
4611
+ onOwnerComment(id, sessionToken, cb) {
4612
+ return this.client.onUpdate(api.comments.getForOwner, {
4613
+ publicId: this.config.project,
4614
+ id,
4615
+ sessionToken
4616
+ }, (comment) => cb(comment), () => {});
4617
+ }
4278
4618
  /** One comment by id, for deep links outside the page listing window. */
4279
4619
  getComment(id) {
4280
4620
  return this.client.query(api.comments.getById, {
@@ -4300,6 +4640,23 @@ var NuniApi = class {
4300
4640
  if (!response.ok || !data.id) throw new NuniApiError(data.message ?? "Couldn't post the comment", data.code ?? "error");
4301
4641
  return data.id;
4302
4642
  }
4643
+ /** Attach a screenshot to the author's own, just-posted comment. */
4644
+ async uploadScreenshot(id, authorSecret, image) {
4645
+ const response = await fetch(`${this.config.convexSiteUrl}/widget/screenshot`, {
4646
+ method: "POST",
4647
+ headers: {
4648
+ "Content-Type": image.type,
4649
+ "X-Nuni-Project": this.config.project,
4650
+ "X-Nuni-Comment": id,
4651
+ "X-Nuni-Author": authorSecret
4652
+ },
4653
+ body: image
4654
+ });
4655
+ if (!response.ok) {
4656
+ const data = await response.json().catch(() => ({}));
4657
+ throw new NuniApiError(data.message ?? "Couldn't attach the screenshot", data.code ?? "error");
4658
+ }
4659
+ }
4303
4660
  async run(promise) {
4304
4661
  try {
4305
4662
  return await promise;
@@ -4387,6 +4744,26 @@ function colorFor(name) {
4387
4744
  for (const ch of name) hash = hash * 31 + ch.charCodeAt(0) >>> 0;
4388
4745
  return `oklch(0.62 0.17 ${hash % 360})`;
4389
4746
  }
4747
+ /** Copy text, falling back to execCommand where the async clipboard is off (plain http previews). */
4748
+ async function copyText(text) {
4749
+ try {
4750
+ await navigator.clipboard.writeText(text);
4751
+ return true;
4752
+ } catch {}
4753
+ const area = document.createElement("textarea");
4754
+ area.value = text;
4755
+ area.setAttribute("readonly", "");
4756
+ area.style.cssText = "position:fixed;top:0;left:0;opacity:0";
4757
+ document.body.appendChild(area);
4758
+ area.select();
4759
+ try {
4760
+ return document.execCommand("copy");
4761
+ } catch {
4762
+ return false;
4763
+ } finally {
4764
+ area.remove();
4765
+ }
4766
+ }
4390
4767
  //#endregion
4391
4768
  //#region src/icons.ts
4392
4769
  const s = (d) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${d}</svg>`;
@@ -4399,6 +4776,7 @@ const ICONS = {
4399
4776
  trash: s("<path d=\"M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6\"/>"),
4400
4777
  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
4778
  send: s("<path d=\"m22 2-7 20-4-9-9-4z\"/><path d=\"M22 2 11 13\"/>"),
4779
+ bot: s("<path d=\"M12 8V4H8\"/><rect width=\"16\" height=\"12\" x=\"4\" y=\"8\" rx=\"2\"/><path d=\"M2 14h2M20 14h2M15 13v2M9 13v2\"/>"),
4402
4780
  target: s("<circle cx=\"12\" cy=\"12\" r=\"9\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/>"),
4403
4781
  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
4782
  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\"/>")
@@ -4632,6 +5010,7 @@ const STYLES = `
4632
5010
  font-size: 14px;
4633
5011
  line-height: 1.45;
4634
5012
  -webkit-font-smoothing: antialiased;
5013
+ -moz-osx-font-smoothing: grayscale;
4635
5014
  }
4636
5015
  @media (prefers-color-scheme: dark) {
4637
5016
  :host {
@@ -4809,6 +5188,26 @@ button { cursor: pointer; }
4809
5188
  }
4810
5189
  .badge-ok { background: rgba(31,157,85,0.14); color: var(--n-ok); }
4811
5190
  .comment-body { white-space: pre-wrap; word-break: break-word; }
5191
+ .shot {
5192
+ display: block;
5193
+ border: 1px solid var(--n-border);
5194
+ border-radius: 8px;
5195
+ overflow: hidden;
5196
+ background: var(--n-bg-subtle);
5197
+ }
5198
+ .shot img { display: block; width: 100%; max-height: 160px; object-fit: cover; object-position: top; }
5199
+ .shot-preview { display: grid; gap: 4px; }
5200
+ .shot-preview img {
5201
+ display: block;
5202
+ width: 100%;
5203
+ max-height: 200px;
5204
+ /* The whole image, so the commenter sees everything that is attached. */
5205
+ object-fit: contain;
5206
+ border: 1px solid var(--n-border);
5207
+ border-radius: 8px;
5208
+ background: var(--n-bg-subtle);
5209
+ }
5210
+ .shot-note { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--n-fg-muted); }
4812
5211
  .field {
4813
5212
  width: 100%;
4814
5213
  padding: 8px 10px;
@@ -4940,6 +5339,14 @@ function isTypingTarget(target) {
4940
5339
  if (!(target instanceof HTMLElement)) return false;
4941
5340
  return target.isContentEditable || target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT";
4942
5341
  }
5342
+ function dataUrl(blob) {
5343
+ return new Promise((resolve, reject) => {
5344
+ const reader = new FileReader();
5345
+ reader.onload = () => resolve(String(reader.result));
5346
+ reader.onerror = () => reject(reader.error);
5347
+ reader.readAsDataURL(blob);
5348
+ });
5349
+ }
4943
5350
  function describeElement(el) {
4944
5351
  const tag = el.tagName.toLowerCase();
4945
5352
  const id = el.id ? `#${el.id}` : "";
@@ -4948,8 +5355,9 @@ function describeElement(el) {
4948
5355
  return `${tag}${id}${cls}${text ? ` "${text}"` : ""}`;
4949
5356
  }
4950
5357
  var NuniWidget = class {
4951
- constructor(config) {
5358
+ constructor(config, runtime) {
4952
5359
  this.config = config;
5360
+ this.runtime = runtime;
4953
5361
  _defineProperty(this, "host", void 0);
4954
5362
  _defineProperty(this, "root", void 0);
4955
5363
  _defineProperty(this, "pinLayer", void 0);
@@ -4981,6 +5389,10 @@ var NuniWidget = class {
4981
5389
  _defineProperty(this, "ownerName", null);
4982
5390
  _defineProperty(this, "deepLinkId", null);
4983
5391
  _defineProperty(this, "extraComment", null);
5392
+ _defineProperty(this, "ownerDetail", null);
5393
+ _defineProperty(this, "ownerDetailId", null);
5394
+ _defineProperty(this, "ownerDetailLoaded", false);
5395
+ _defineProperty(this, "ownerDetailUnsub", null);
4984
5396
  _defineProperty(this, "layoutFrame", 0);
4985
5397
  _defineProperty(this, "pinPositions", /* @__PURE__ */ new Map());
4986
5398
  _defineProperty(this, "resolveTimer", 0);
@@ -5016,15 +5428,20 @@ var NuniWidget = class {
5016
5428
  y: e.clientY
5017
5429
  }, { ignoreAttributePrefixes: [] });
5018
5430
  this.setPicking(false);
5431
+ const draft = {
5432
+ anchor,
5433
+ element
5434
+ };
5435
+ if (this.config.capture.dom) try {
5436
+ draft.dom = domContext(element);
5437
+ } catch {}
5019
5438
  this.card = {
5020
5439
  kind: "composer",
5021
- draft: {
5022
- anchor,
5023
- element
5024
- }
5440
+ draft
5025
5441
  };
5026
5442
  this.render();
5027
5443
  this.focusComposer();
5444
+ this.takeScreenshot(draft);
5028
5445
  });
5029
5446
  let secret = read(KEYS.secret);
5030
5447
  if (!secret) {
@@ -5112,6 +5529,7 @@ var NuniWidget = class {
5112
5529
  this.setPicking(false);
5113
5530
  this.pageUnsub?.();
5114
5531
  this.sessionUnsub?.();
5532
+ this.ownerDetailUnsub?.();
5115
5533
  for (const cleanup of this.cleanups) cleanup();
5116
5534
  cancelAnimationFrame(this.layoutFrame);
5117
5535
  clearTimeout(this.resolveTimer);
@@ -5159,6 +5577,24 @@ var NuniWidget = class {
5159
5577
  this.render();
5160
5578
  });
5161
5579
  }
5580
+ /** Keep the owner-only details of the open thread subscribed. */
5581
+ syncOwnerDetail() {
5582
+ const token = this.ownerToken;
5583
+ const id = this.card?.kind === "thread" && this.isOwner && token ? this.card.id : null;
5584
+ if (id === this.ownerDetailId) return;
5585
+ this.ownerDetailUnsub?.();
5586
+ this.ownerDetailUnsub = null;
5587
+ this.ownerDetail = null;
5588
+ this.ownerDetailLoaded = false;
5589
+ this.ownerDetailId = id;
5590
+ if (!id || !token) return;
5591
+ this.ownerDetailUnsub = this.api.onOwnerComment(id, token, (comment) => {
5592
+ if (this.ownerDetailId !== id) return;
5593
+ this.ownerDetail = comment;
5594
+ this.ownerDetailLoaded = true;
5595
+ this.render();
5596
+ });
5597
+ }
5162
5598
  get isOwner() {
5163
5599
  return Boolean(this.ownerToken && this.ownerName);
5164
5600
  }
@@ -5286,6 +5722,50 @@ var NuniWidget = class {
5286
5722
  const target = e.target;
5287
5723
  return target instanceof Element ? target : null;
5288
5724
  }
5725
+ /** Capture while the person types, so posting stays instant. */
5726
+ async takeScreenshot(draft) {
5727
+ const load = this.runtime.loadScreenshot;
5728
+ if (!load) return;
5729
+ const accent = getComputedStyle(this.host).getPropertyValue("--n-accent").trim() || "#d6246e";
5730
+ try {
5731
+ await new Promise((resolve) => setTimeout(resolve, 60));
5732
+ const capture = await load();
5733
+ const blob = capture ? await capture(draft.element, {
5734
+ exclude: this.host,
5735
+ accent
5736
+ }) : null;
5737
+ if (!blob || !this.isComposing(draft) || draft.sending) return;
5738
+ const preview = await dataUrl(blob);
5739
+ if (!this.isComposing(draft) || draft.sending) return;
5740
+ draft.screenshot = {
5741
+ blob,
5742
+ preview
5743
+ };
5744
+ this.render();
5745
+ } catch {}
5746
+ }
5747
+ isComposing(draft) {
5748
+ return this.card?.kind === "composer" && this.card.draft === draft;
5749
+ }
5750
+ collectContext(draft) {
5751
+ const { collectors } = this.runtime;
5752
+ const context = {};
5753
+ const logs = collectors.console();
5754
+ const failed = collectors.network();
5755
+ if (logs.length) context.console = logs;
5756
+ if (failed.length) context.network = failed;
5757
+ if (draft.dom) context.dom = draft.dom;
5758
+ return Object.keys(context).length ? context : void 0;
5759
+ }
5760
+ async attachScreenshot(id, image) {
5761
+ try {
5762
+ await this.api.uploadScreenshot(id, this.secret, image);
5763
+ } catch {}
5764
+ }
5765
+ copyForAgent(comment) {
5766
+ const detail = this.ownerDetail?._id === comment._id ? this.ownerDetail : null;
5767
+ copyText(buildCommentPrompt(detail ?? comment, { includeContext: Boolean(detail) })).then((ok) => this.showToast(ok ? "Copied for your coding agent" : "Couldn't copy"));
5768
+ }
5289
5769
  renderHighlight() {
5290
5770
  this.overlayLayer.replaceChildren();
5291
5771
  if (!this.picking) return;
@@ -5397,6 +5877,7 @@ var NuniWidget = class {
5397
5877
  draft.error = void 0;
5398
5878
  this.render();
5399
5879
  const loc = describeLocation(location.href);
5880
+ const shot = draft.screenshot;
5400
5881
  try {
5401
5882
  const id = await this.api.createComment({
5402
5883
  body: cleanBody,
@@ -5412,8 +5893,10 @@ var NuniWidget = class {
5412
5893
  w: window.innerWidth,
5413
5894
  h: window.innerHeight,
5414
5895
  dpr: window.devicePixelRatio || 1
5415
- }
5896
+ },
5897
+ context: this.collectContext(draft)
5416
5898
  });
5899
+ if (shot) this.attachScreenshot(id, shot.blob);
5417
5900
  this.placements.set(id, {
5418
5901
  element: draft.element,
5419
5902
  confidence: "exact"
@@ -5504,6 +5987,7 @@ var NuniWidget = class {
5504
5987
  this.render();
5505
5988
  }
5506
5989
  render() {
5990
+ this.syncOwnerDetail();
5507
5991
  this.renderPins();
5508
5992
  this.renderUi();
5509
5993
  this.renderHighlight();
@@ -5735,7 +6219,17 @@ var NuniWidget = class {
5735
6219
  this.name = "";
5736
6220
  this.render();
5737
6221
  }
5738
- }, "Not you?")), textarea, draft.error ? h("div", {
6222
+ }, "Not you?")), textarea, draft.screenshot ? h("div", { class: "shot-preview" }, h("img", {
6223
+ src: draft.screenshot.preview,
6224
+ alt: "Screenshot that will be attached"
6225
+ }), h("div", { class: "shot-note" }, h("span", {}, "Screenshot attached. Only the site owner sees it."), h("span", { class: "spacer" }), h("button", {
6226
+ class: "link",
6227
+ type: "button",
6228
+ onclick: () => {
6229
+ draft.screenshot = void 0;
6230
+ this.render();
6231
+ }
6232
+ }, "Remove"))) : null, draft.error ? h("div", {
5739
6233
  class: "error",
5740
6234
  role: "alert"
5741
6235
  }, draft.error) : null, h("div", { class: "row" }, h("span", { class: "kbd" }, "⌘ + Enter"), h("span", { class: "spacer" }), h("button", {
@@ -5783,7 +6277,16 @@ var NuniWidget = class {
5783
6277
  type: "button",
5784
6278
  "aria-label": "Close",
5785
6279
  onclick: () => this.closeCard()
5786
- }, 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", {
6280
+ }, icon(ICONS.close))), h("div", { class: "comment-body" }, comment.body), this.ownerDetail?._id === comment._id && this.ownerDetail.screenshotUrl ? h("a", {
6281
+ class: "shot",
6282
+ href: this.ownerDetail.screenshotUrl,
6283
+ target: "_blank",
6284
+ rel: "noreferrer",
6285
+ title: "Open the screenshot"
6286
+ }, h("img", {
6287
+ src: this.ownerDetail.screenshotUrl,
6288
+ alt: "Screenshot taken when the comment was left"
6289
+ })) : null, 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", {
5787
6290
  class: "badge",
5788
6291
  title: `Left on ${comment.page.origin}`
5789
6292
  }, new URL(comment.page.origin).host) : null, placement?.confidence === "low" ? h("span", {
@@ -5810,6 +6313,15 @@ var NuniWidget = class {
5810
6313
  onclick: () => this.reopenComment(comment)
5811
6314
  }, icon(ICONS.undo), "Reopen"));
5812
6315
  actions.push(h("span", { class: "spacer" }));
6316
+ const contextPending = owner && !(this.ownerDetailLoaded && this.ownerDetailId === comment._id);
6317
+ actions.push(h("button", {
6318
+ class: "btn btn-ghost btn-icon",
6319
+ type: "button",
6320
+ "aria-label": "Copy for agent",
6321
+ title: contextPending ? "Loading page context…" : "Copy for your coding agent",
6322
+ disabled: contextPending,
6323
+ onclick: () => this.copyForAgent(comment)
6324
+ }, icon(ICONS.bot)));
5813
6325
  if (mine) actions.push(h("button", {
5814
6326
  class: "btn btn-ghost btn-icon",
5815
6327
  type: "button",
@@ -5834,7 +6346,7 @@ var NuniWidget = class {
5834
6346
  "data-card": "thread",
5835
6347
  role: "dialog",
5836
6348
  "aria-label": `Comment by ${comment.authorName}`
5837
- }, body, actions.length > 1 || owner ? h("div", { class: "actions" }, ...actions) : null);
6349
+ }, body, actions.length ? h("div", { class: "actions" }, ...actions) : null);
5838
6350
  }
5839
6351
  positionCard() {
5840
6352
  const el = this.uiLayer.querySelector("[data-card]");
@@ -5877,19 +6389,31 @@ var NuniWidget = class {
5877
6389
  }
5878
6390
  };
5879
6391
  //#endregion
5880
- //#region src/index.ts
6392
+ //#region src/mount.ts
6393
+ /** Nuni's own requests are not the page's problem. */
6394
+ function isNuniUrl(config) {
6395
+ const origins = [config.convexUrl, config.convexSiteUrl].flatMap((url) => {
6396
+ try {
6397
+ return [new URL(url).origin];
6398
+ } catch {
6399
+ return [];
6400
+ }
6401
+ });
6402
+ return (url) => {
6403
+ try {
6404
+ return origins.includes(new URL(url, location.href).origin);
6405
+ } catch {
6406
+ return false;
6407
+ }
6408
+ };
6409
+ }
5881
6410
  let active = null;
5882
- /**
5883
- * Mount Nuni on the page. Call once on the client. Calling it again with the
5884
- * same project shares the mounted widget; it is removed when every handle is
5885
- * destroyed. A different project replaces it. Each handle only ever tears
5886
- * down the widget it was created for.
5887
- */
5888
- function init(options) {
6411
+ /** The shared implementation of `init` for both builds (see index.ts). */
6412
+ function mount(options, runtime) {
5889
6413
  const noop = { destroy() {} };
5890
6414
  if (typeof window === "undefined" || typeof document === "undefined") return noop;
5891
6415
  if (!isProjectId(options.project)) {
5892
- console.warn(`[nuni] "${options.project}" is not a valid project ID. Run \`npx @nuniapp/cli init\` to get one.`);
6416
+ console.warn(`[nuni] "${options.project}" is not a valid project ID. Run \`npx @nuniapp/cli@latest init\` to get one.`);
5893
6417
  return noop;
5894
6418
  }
5895
6419
  if (active?.project === options.project) return handleFor(active);
@@ -5898,22 +6422,26 @@ function init(options) {
5898
6422
  const state = {
5899
6423
  project: options.project,
5900
6424
  widget: null,
6425
+ collectors: startCollectors(config.capture, isNuniUrl(config)),
5901
6426
  timer: 0,
5902
6427
  handles: 0
5903
6428
  };
5904
6429
  active = state;
5905
- const mount = () => {
6430
+ const start = () => {
5906
6431
  if (active !== state) return;
5907
6432
  if (!document.body) {
5908
- state.timer = window.setTimeout(mount, 50);
6433
+ state.timer = window.setTimeout(start, 50);
5909
6434
  return;
5910
6435
  }
5911
- state.widget = new NuniWidget(config);
6436
+ state.widget = new NuniWidget(config, {
6437
+ collectors: state.collectors,
6438
+ loadScreenshot: config.capture.screenshot ? runtime.loadScreenshot : null
6439
+ });
5912
6440
  state.widget.start();
5913
6441
  };
5914
6442
  const idle = window.requestIdleCallback;
5915
- if (idle) idle(mount, { timeout: 1500 });
5916
- else state.timer = window.setTimeout(mount, 1);
6443
+ if (idle) idle(start, { timeout: 1500 });
6444
+ else state.timer = window.setTimeout(start, 1);
5917
6445
  return handleFor(state);
5918
6446
  }
5919
6447
  function handleFor(state) {
@@ -5930,7 +6458,19 @@ function unmount(state) {
5930
6458
  clearTimeout(state.timer);
5931
6459
  state.widget?.destroy();
5932
6460
  state.widget = null;
6461
+ state.collectors.stop();
5933
6462
  if (active === state) active = null;
5934
6463
  }
5935
6464
  //#endregion
6465
+ //#region src/index.ts
6466
+ /**
6467
+ * Mount Nuni on the page. Call once on the client. Calling it again with the
6468
+ * same project shares the mounted widget; it is removed when every handle is
6469
+ * destroyed. A different project replaces it. Each handle only ever tears
6470
+ * down the widget it was created for.
6471
+ */
6472
+ function init(options) {
6473
+ return mount(options, { loadScreenshot: () => import("./screenshot-B2x2-nI_.js").then((m) => m.captureScreenshot) });
6474
+ }
6475
+ //#endregion
5936
6476
  export { VERSION, init };