@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/README.md +12 -11
- package/dist/index.d.ts +39 -3
- package/dist/index.js +645 -105
- package/dist/mark-BeysEAHp.js +66 -0
- package/dist/nuni-screenshot.global.js +14 -0
- package/dist/nuni.global.js +30 -3
- package/dist/screenshot-B2x2-nI_.js +1288 -0
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -1,43 +1,4 @@
|
|
|
1
|
-
|
|
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.
|
|
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
|
-
/**
|
|
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.
|
|
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),
|
|
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
|
|
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/
|
|
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
|
-
|
|
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
|
|
6430
|
+
const start = () => {
|
|
5906
6431
|
if (active !== state) return;
|
|
5907
6432
|
if (!document.body) {
|
|
5908
|
-
state.timer = window.setTimeout(
|
|
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(
|
|
5916
|
-
else state.timer = window.setTimeout(
|
|
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 };
|