@nuniapp/widget 0.1.3 → 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.3";
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\"/>")
@@ -4810,6 +5188,26 @@ button { cursor: pointer; }
4810
5188
  }
4811
5189
  .badge-ok { background: rgba(31,157,85,0.14); color: var(--n-ok); }
4812
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); }
4813
5211
  .field {
4814
5212
  width: 100%;
4815
5213
  padding: 8px 10px;
@@ -4941,6 +5339,14 @@ function isTypingTarget(target) {
4941
5339
  if (!(target instanceof HTMLElement)) return false;
4942
5340
  return target.isContentEditable || target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT";
4943
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
+ }
4944
5350
  function describeElement(el) {
4945
5351
  const tag = el.tagName.toLowerCase();
4946
5352
  const id = el.id ? `#${el.id}` : "";
@@ -4949,8 +5355,9 @@ function describeElement(el) {
4949
5355
  return `${tag}${id}${cls}${text ? ` "${text}"` : ""}`;
4950
5356
  }
4951
5357
  var NuniWidget = class {
4952
- constructor(config) {
5358
+ constructor(config, runtime) {
4953
5359
  this.config = config;
5360
+ this.runtime = runtime;
4954
5361
  _defineProperty(this, "host", void 0);
4955
5362
  _defineProperty(this, "root", void 0);
4956
5363
  _defineProperty(this, "pinLayer", void 0);
@@ -4982,6 +5389,10 @@ var NuniWidget = class {
4982
5389
  _defineProperty(this, "ownerName", null);
4983
5390
  _defineProperty(this, "deepLinkId", null);
4984
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);
4985
5396
  _defineProperty(this, "layoutFrame", 0);
4986
5397
  _defineProperty(this, "pinPositions", /* @__PURE__ */ new Map());
4987
5398
  _defineProperty(this, "resolveTimer", 0);
@@ -5017,15 +5428,20 @@ var NuniWidget = class {
5017
5428
  y: e.clientY
5018
5429
  }, { ignoreAttributePrefixes: [] });
5019
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 {}
5020
5438
  this.card = {
5021
5439
  kind: "composer",
5022
- draft: {
5023
- anchor,
5024
- element
5025
- }
5440
+ draft
5026
5441
  };
5027
5442
  this.render();
5028
5443
  this.focusComposer();
5444
+ this.takeScreenshot(draft);
5029
5445
  });
5030
5446
  let secret = read(KEYS.secret);
5031
5447
  if (!secret) {
@@ -5113,6 +5529,7 @@ var NuniWidget = class {
5113
5529
  this.setPicking(false);
5114
5530
  this.pageUnsub?.();
5115
5531
  this.sessionUnsub?.();
5532
+ this.ownerDetailUnsub?.();
5116
5533
  for (const cleanup of this.cleanups) cleanup();
5117
5534
  cancelAnimationFrame(this.layoutFrame);
5118
5535
  clearTimeout(this.resolveTimer);
@@ -5160,6 +5577,24 @@ var NuniWidget = class {
5160
5577
  this.render();
5161
5578
  });
5162
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
+ }
5163
5598
  get isOwner() {
5164
5599
  return Boolean(this.ownerToken && this.ownerName);
5165
5600
  }
@@ -5287,6 +5722,50 @@ var NuniWidget = class {
5287
5722
  const target = e.target;
5288
5723
  return target instanceof Element ? target : null;
5289
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
+ }
5290
5769
  renderHighlight() {
5291
5770
  this.overlayLayer.replaceChildren();
5292
5771
  if (!this.picking) return;
@@ -5398,6 +5877,7 @@ var NuniWidget = class {
5398
5877
  draft.error = void 0;
5399
5878
  this.render();
5400
5879
  const loc = describeLocation(location.href);
5880
+ const shot = draft.screenshot;
5401
5881
  try {
5402
5882
  const id = await this.api.createComment({
5403
5883
  body: cleanBody,
@@ -5413,8 +5893,10 @@ var NuniWidget = class {
5413
5893
  w: window.innerWidth,
5414
5894
  h: window.innerHeight,
5415
5895
  dpr: window.devicePixelRatio || 1
5416
- }
5896
+ },
5897
+ context: this.collectContext(draft)
5417
5898
  });
5899
+ if (shot) this.attachScreenshot(id, shot.blob);
5418
5900
  this.placements.set(id, {
5419
5901
  element: draft.element,
5420
5902
  confidence: "exact"
@@ -5505,6 +5987,7 @@ var NuniWidget = class {
5505
5987
  this.render();
5506
5988
  }
5507
5989
  render() {
5990
+ this.syncOwnerDetail();
5508
5991
  this.renderPins();
5509
5992
  this.renderUi();
5510
5993
  this.renderHighlight();
@@ -5736,7 +6219,17 @@ var NuniWidget = class {
5736
6219
  this.name = "";
5737
6220
  this.render();
5738
6221
  }
5739
- }, "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", {
5740
6233
  class: "error",
5741
6234
  role: "alert"
5742
6235
  }, draft.error) : null, h("div", { class: "row" }, h("span", { class: "kbd" }, "⌘ + Enter"), h("span", { class: "spacer" }), h("button", {
@@ -5784,7 +6277,16 @@ var NuniWidget = class {
5784
6277
  type: "button",
5785
6278
  "aria-label": "Close",
5786
6279
  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", {
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", {
5788
6290
  class: "badge",
5789
6291
  title: `Left on ${comment.page.origin}`
5790
6292
  }, new URL(comment.page.origin).host) : null, placement?.confidence === "low" ? h("span", {
@@ -5811,6 +6313,15 @@ var NuniWidget = class {
5811
6313
  onclick: () => this.reopenComment(comment)
5812
6314
  }, icon(ICONS.undo), "Reopen"));
5813
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)));
5814
6325
  if (mine) actions.push(h("button", {
5815
6326
  class: "btn btn-ghost btn-icon",
5816
6327
  type: "button",
@@ -5835,7 +6346,7 @@ var NuniWidget = class {
5835
6346
  "data-card": "thread",
5836
6347
  role: "dialog",
5837
6348
  "aria-label": `Comment by ${comment.authorName}`
5838
- }, body, actions.length > 1 || owner ? h("div", { class: "actions" }, ...actions) : null);
6349
+ }, body, actions.length ? h("div", { class: "actions" }, ...actions) : null);
5839
6350
  }
5840
6351
  positionCard() {
5841
6352
  const el = this.uiLayer.querySelector("[data-card]");
@@ -5878,15 +6389,27 @@ var NuniWidget = class {
5878
6389
  }
5879
6390
  };
5880
6391
  //#endregion
5881
- //#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
+ }
5882
6410
  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) {
6411
+ /** The shared implementation of `init` for both builds (see index.ts). */
6412
+ function mount(options, runtime) {
5890
6413
  const noop = { destroy() {} };
5891
6414
  if (typeof window === "undefined" || typeof document === "undefined") return noop;
5892
6415
  if (!isProjectId(options.project)) {
@@ -5899,22 +6422,26 @@ function init(options) {
5899
6422
  const state = {
5900
6423
  project: options.project,
5901
6424
  widget: null,
6425
+ collectors: startCollectors(config.capture, isNuniUrl(config)),
5902
6426
  timer: 0,
5903
6427
  handles: 0
5904
6428
  };
5905
6429
  active = state;
5906
- const mount = () => {
6430
+ const start = () => {
5907
6431
  if (active !== state) return;
5908
6432
  if (!document.body) {
5909
- state.timer = window.setTimeout(mount, 50);
6433
+ state.timer = window.setTimeout(start, 50);
5910
6434
  return;
5911
6435
  }
5912
- state.widget = new NuniWidget(config);
6436
+ state.widget = new NuniWidget(config, {
6437
+ collectors: state.collectors,
6438
+ loadScreenshot: config.capture.screenshot ? runtime.loadScreenshot : null
6439
+ });
5913
6440
  state.widget.start();
5914
6441
  };
5915
6442
  const idle = window.requestIdleCallback;
5916
- if (idle) idle(mount, { timeout: 1500 });
5917
- else state.timer = window.setTimeout(mount, 1);
6443
+ if (idle) idle(start, { timeout: 1500 });
6444
+ else state.timer = window.setTimeout(start, 1);
5918
6445
  return handleFor(state);
5919
6446
  }
5920
6447
  function handleFor(state) {
@@ -5931,7 +6458,19 @@ function unmount(state) {
5931
6458
  clearTimeout(state.timer);
5932
6459
  state.widget?.destroy();
5933
6460
  state.widget = null;
6461
+ state.collectors.stop();
5934
6462
  if (active === state) active = null;
5935
6463
  }
5936
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
5937
6476
  export { VERSION, init };