@nuniapp/widget 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,255 @@
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
+ /** CLI and MCP sessions, created by `nuni login`. */
36
+ cliSessionTtlMs: 7776e6,
37
+ /** How long a project transfer link works. */
38
+ transferTtlMs: 6048e5,
39
+ /** Comments changed at once from the dashboard. */
40
+ bulkMax: 100,
41
+ /** Pins found or lost in one report from a visitor's widget. */
42
+ pinReportMax: 200,
43
+ /** The dashboard's "Not found" count stops here ("99+"). */
44
+ lostCountMax: 99,
45
+ /** How long a `nuni login` code waits for approval in the dashboard. */
46
+ cliLoginTtlMs: 6e5,
47
+ anchorTextMaxLength: 120,
48
+ /** Selected text kept with a text comment, and the context around it. */
49
+ quoteMaxLength: 500,
50
+ quoteContextLength: 32,
51
+ /** Per page, the widget loads this many newest open and resolved comments. */
52
+ pageOpenLimit: 1e3,
53
+ pageResolvedLimit: 200,
54
+ /** The newest of a commenter's own comments, across pages. */
55
+ yoursLimit: 100,
56
+ /** Most console or network entries kept with one comment. */
57
+ contextEntryMax: 20,
58
+ contextMessageMaxLength: 500,
59
+ /** Trimmed outerHTML of the commented element. */
60
+ domSnippetMaxLength: 4e3,
61
+ screenshotMaxBytes: 6e5,
62
+ /** Images a commenter attaches to one comment (their own or marked up). */
63
+ imagesPerComment: 3,
64
+ imageMaxBytes: 6e5,
65
+ /** Longest side of an attached image, in pixels. */
66
+ imageMaxSide: 1600,
67
+ /** Replies kept per comment thread. */
68
+ repliesPerComment: 200,
69
+ /** Reactions kept per thread (the comment and its replies). */
70
+ reactionsPerComment: 2e3,
71
+ /** How long after posting the author can still attach the screenshot. */
72
+ screenshotUploadWindowMs: 3e5
73
+ };
74
+ /** The reactions anyone can add to a comment or reply. */
75
+ const REACTIONS = [
76
+ "👍",
77
+ "❤️",
78
+ "🎉",
79
+ "👀",
80
+ "✅",
81
+ "😄"
82
+ ];
83
+ //#endregion
84
+ //#region ../shared/src/privacy.ts
85
+ /**
86
+ * Site owners mark private areas with this attribute. Their text never
87
+ * reaches Nuni: not in the pin anchor (which is public), the markup snippet
88
+ * or the screenshot.
89
+ */
90
+ const MASK_ATTRIBUTE = "data-nuni-mask";
91
+ const MASK_SELECTOR = `[${MASK_ATTRIBUTE}]`;
92
+ //#endregion
93
+ //#region src/mark.ts
94
+ /**
95
+ * Marks Nuni's own screenshot requests (fonts and images it inlines), so the
96
+ * network collector can skip them without muting the page. `Symbol.for` is
97
+ * shared by the widget and the separately loaded screenshot script.
98
+ */
99
+ const CAPTURE_MARK = Symbol.for("nuni.capture");
100
+ //#endregion
101
+ //#region src/image.ts
102
+ /** The image types the backend accepts. */
103
+ const IMAGE_TYPES = [
104
+ "image/png",
105
+ "image/jpeg",
106
+ "image/webp"
107
+ ];
108
+ function toBlob(canvas, type, quality) {
109
+ return new Promise((resolve) => canvas.toBlob(resolve, type, quality));
110
+ }
111
+ /**
112
+ * WebP, or JPEG where the browser can't encode WebP, at the best quality
113
+ * that fits `maxBytes`. Null when even the smallest doesn't fit.
114
+ */
115
+ async function encodeCanvas(canvas, maxBytes) {
116
+ for (const [type, quality] of [
117
+ ["image/webp", .82],
118
+ ["image/jpeg", .8],
119
+ ["image/jpeg", .55]
120
+ ]) {
121
+ const blob = await toBlob(canvas, type, quality);
122
+ if (blob && blob.type === type && blob.size <= maxBytes) return blob;
123
+ }
124
+ return null;
125
+ }
126
+ /**
127
+ * An image someone picked or pasted, scaled down to the size limit and
128
+ * re-encoded, which also drops its metadata (location, camera). Null when
129
+ * it isn't a readable image.
130
+ */
131
+ async function imageFromFile(file) {
132
+ if (!IMAGE_TYPES.includes(file.type)) return null;
133
+ let bitmap;
134
+ try {
135
+ bitmap = await createImageBitmap(file);
136
+ } catch {
137
+ return null;
138
+ }
139
+ const scale = Math.min(1, LIMITS.imageMaxSide / Math.max(bitmap.width, bitmap.height));
140
+ const canvas = document.createElement("canvas");
141
+ canvas.width = Math.max(1, Math.round(bitmap.width * scale));
142
+ canvas.height = Math.max(1, Math.round(bitmap.height * scale));
143
+ try {
144
+ const ctx = canvas.getContext("2d");
145
+ if (!ctx) return null;
146
+ ctx.fillStyle = "#fff";
147
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
148
+ ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
149
+ } finally {
150
+ bitmap.close();
151
+ }
152
+ return encodeCanvas(canvas, LIMITS.imageMaxBytes);
153
+ }
154
+ //#endregion
155
+ //#region src/dom.ts
156
+ /** Tiny element factory. Strings become text nodes (never HTML). */
157
+ function h(tag, attrs = {}, ...children) {
158
+ const el = document.createElement(tag);
159
+ for (const [key, value] of Object.entries(attrs)) {
160
+ if (value == null || value === false) continue;
161
+ if (key.startsWith("on") && typeof value === "function") el.addEventListener(key.slice(2).toLowerCase(), value);
162
+ else if (key === "class") el.className = String(value);
163
+ else if (value === true) el.setAttribute(key, "");
164
+ else el.setAttribute(key, String(value));
165
+ }
166
+ for (const child of children.flat()) {
167
+ if (child == null || child === false) continue;
168
+ el.append(typeof child === "string" ? document.createTextNode(child) : child);
169
+ }
170
+ return el;
171
+ }
172
+ /** Renders one of the constant SVG strings in icons.ts (never user input). */
173
+ function icon(svg) {
174
+ const span = document.createElement("span");
175
+ span.className = "icon";
176
+ span.setAttribute("aria-hidden", "true");
177
+ const parsed = new DOMParser().parseFromString(svg, "image/svg+xml");
178
+ span.append(document.importNode(parsed.documentElement, true));
179
+ return span;
180
+ }
181
+ function initials(name) {
182
+ const parts = name.trim().split(/\s+/).filter(Boolean);
183
+ return (parts.length > 1 ? parts[0][0] + parts[parts.length - 1][0] : (parts[0] ?? "?").slice(0, 2)).toUpperCase();
184
+ }
185
+ /** Stable pleasant color per author name. */
186
+ function colorFor(name) {
187
+ let hash = 0;
188
+ for (const ch of name) hash = hash * 31 + ch.charCodeAt(0) >>> 0;
189
+ return `oklch(0.5 0.17 ${hash % 360})`;
190
+ }
191
+ /** Copy text, falling back to execCommand where the async clipboard is off (plain http previews). */
192
+ async function copyText(text) {
193
+ try {
194
+ await navigator.clipboard.writeText(text);
195
+ return true;
196
+ } catch {}
197
+ const area = document.createElement("textarea");
198
+ area.value = text;
199
+ area.setAttribute("readonly", "");
200
+ area.style.cssText = "position:fixed;top:0;left:0;opacity:0";
201
+ document.body.appendChild(area);
202
+ area.select();
203
+ try {
204
+ return document.execCommand("copy");
205
+ } catch {
206
+ return false;
207
+ } finally {
208
+ area.remove();
209
+ }
210
+ }
211
+ /** Any CSS color as RGB, using the browser's own parser. */
212
+ function parseColor(color) {
213
+ const probe = document.createElement("span");
214
+ probe.style.color = color;
215
+ if (!probe.style.color) return null;
216
+ probe.style.display = "none";
217
+ document.documentElement.appendChild(probe);
218
+ const computed = getComputedStyle(probe).color;
219
+ probe.remove();
220
+ if (/^rgba?\(/.test(computed)) {
221
+ const parts = computed.match(/[\d.]+/g)?.map(Number);
222
+ if (parts && parts.length >= 3) return [
223
+ parts[0],
224
+ parts[1],
225
+ parts[2]
226
+ ];
227
+ }
228
+ try {
229
+ const canvas = document.createElement("canvas");
230
+ canvas.width = canvas.height = 1;
231
+ const ctx = canvas.getContext("2d");
232
+ if (!ctx) return null;
233
+ ctx.fillStyle = color;
234
+ ctx.fillRect(0, 0, 1, 1);
235
+ const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
236
+ return [
237
+ r,
238
+ g,
239
+ b
240
+ ];
241
+ } catch {
242
+ return null;
243
+ }
244
+ }
245
+ /** Black or white text, whichever reads better on the color (WCAG). */
246
+ function readableOn([r, g, b]) {
247
+ const channel = (c) => {
248
+ const v = c / 255;
249
+ return v <= .03928 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4;
250
+ };
251
+ const l = .2126 * channel(r) + .7152 * channel(g) + .0722 * channel(b);
252
+ return 1.05 / (l + .05) >= (l + .05) / .05 ? "#ffffff" : "#111111";
253
+ }
254
+ //#endregion
255
+ export { isProjectId as _, initials as a, IMAGE_TYPES as c, CAPTURE_MARK as d, MASK_ATTRIBUTE as f, generateSecret as g, REACTIONS as h, icon as i, encodeCanvas as l, LIMITS as m, copyText as n, parseColor as o, MASK_SELECTOR as p, h as r, readableOn as s, colorFor as t, imageFromFile as u };
package/dist/index.d.ts CHANGED
@@ -25,6 +25,11 @@ declare const EN: {
25
25
  close: string;
26
26
  tabOpen: string;
27
27
  tabResolved: string;
28
+ tabYours: string;
29
+ yours: string;
30
+ noYours: string;
31
+ thisPage: string;
32
+ statusOpen: string;
28
33
  loading: string;
29
34
  noOpen: string;
30
35
  noOpenKey: string;
@@ -53,6 +58,25 @@ declare const EN: {
53
58
  one: string;
54
59
  other: string;
55
60
  };
61
+ suggestEdit: string;
62
+ suggestedEdit: string;
63
+ removeSuggestion: string;
64
+ newText: string;
65
+ addNote: string;
66
+ addImage: string;
67
+ markUpScreenshot: string;
68
+ preparingScreenshot: string;
69
+ markUpTitle: string;
70
+ markUpBox: string;
71
+ markUpArrow: string;
72
+ markUpPen: string;
73
+ markUpUndo: string;
74
+ markUpDone: string;
75
+ markUpFailed: string;
76
+ imagesPublic: string;
77
+ imageN: string;
78
+ removeImage: string;
79
+ openImage: string;
56
80
  pinLabel: string;
57
81
  commentBy: string;
58
82
  openScreenshot: string;
@@ -66,6 +90,15 @@ declare const EN: {
66
90
  resolve: string;
67
91
  reopen: string;
68
92
  copyForAgent: string;
93
+ copyNewText: string;
94
+ movePin: string;
95
+ movePinHint: string;
96
+ pinMoved: string;
97
+ closeOutdated: string;
98
+ closedOutdated: string;
99
+ outdated: string;
100
+ lostHint: string;
101
+ newTextCopied: string;
69
102
  copyForAgentHint: string;
70
103
  loadingContext: string;
71
104
  edit: string;
@@ -92,6 +125,12 @@ declare const EN: {
92
125
  linkGone: string;
93
126
  enterName: string;
94
127
  writeFirst: string;
128
+ changeTextFirst: string;
129
+ tooManyImages: string;
130
+ imageUnreadable: string;
131
+ imageFailed: string;
132
+ noScreenshot: string;
133
+ suggestionTooLong: string;
95
134
  commentTooLong: string;
96
135
  replyTooLong: string;
97
136
  postFailed: string;
@@ -146,6 +185,11 @@ interface NuniOptions {
146
185
  locale?: string;
147
186
  /** Replace any of the widget's words. See the `EN` messages. */
148
187
  messages?: Partial<Messages>;
188
+ /**
189
+ * Let commenters attach images and mark up the screenshot. They are shown
190
+ * to everyone who can see the comment. Default true; claimed sites only.
191
+ */
192
+ images?: boolean;
149
193
  /**
150
194
  * Extra context attached to each comment for the project owner and their
151
195
  * coding agent. Everything is on by default; set a key to `false` to stop
@@ -182,12 +226,6 @@ interface ScreenshotOptions {
182
226
  focus?: () => Box;
183
227
  }
184
228
  type CaptureScreenshot = (el: Element, options: ScreenshotOptions) => Promise<Blob | null>;
185
- declare global {
186
- interface Window {
187
- /** Set by nuni-screenshot.global.js (the CDN build). */
188
- __nuniScreenshot?: CaptureScreenshot;
189
- }
190
- }
191
229
  interface Box {
192
230
  left: number;
193
231
  top: number;
@@ -195,6 +233,49 @@ interface Box {
195
233
  bottom: number;
196
234
  }
197
235
  //#endregion
236
+ //#region src/markup.d.ts
237
+ /**
238
+ * A small editor to draw boxes, arrows and freehand lines on a screenshot
239
+ * before it is attached. Loaded with the screenshot code, on demand.
240
+ */
241
+ interface MarkUpLabels {
242
+ title: string;
243
+ box: string;
244
+ arrow: string;
245
+ pen: string;
246
+ undo: string;
247
+ cancel: string;
248
+ done: string;
249
+ /** Shown when the drawing can't be saved as an image. */
250
+ failed: string;
251
+ }
252
+ interface MarkUpOptions {
253
+ /** Where the editor opens: inside the widget's shadow root. */
254
+ container: HTMLElement;
255
+ labels: MarkUpLabels;
256
+ color: string;
257
+ }
258
+ /** Resolves with the marked-up image, or null when cancelled. */
259
+ type MarkUp = (image: Blob, options: MarkUpOptions) => Promise<Blob | null>;
260
+ //#endregion
261
+ //#region src/tools.d.ts
262
+ /**
263
+ * The code loaded on demand once someone comments (a separate chunk for npm
264
+ * users, a separate script for the CDN build): taking the screenshot and
265
+ * marking it up.
266
+ */
267
+ declare const screenshotTools: {
268
+ capture: CaptureScreenshot;
269
+ markUp: MarkUp;
270
+ };
271
+ type ScreenshotTools = typeof screenshotTools;
272
+ declare global {
273
+ interface Window {
274
+ /** Set by nuni-screenshot.global.js (the CDN build). */
275
+ __nuniScreenshot?: ScreenshotTools;
276
+ }
277
+ }
278
+ //#endregion
198
279
  //#region src/mount.d.ts
199
280
  interface NuniInstance {
200
281
  destroy(): void;