@nuniapp/widget 0.1.7 → 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,11 +25,18 @@ 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;
31
36
  noResolved: string;
32
37
  notFoundHere: string;
38
+ searchComments: string;
39
+ noMatches: string;
33
40
  otherPages: string;
34
41
  openOnPage: string;
35
42
  replyCount: {
@@ -51,6 +58,25 @@ declare const EN: {
51
58
  one: string;
52
59
  other: string;
53
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;
54
80
  pinLabel: string;
55
81
  commentBy: string;
56
82
  openScreenshot: string;
@@ -64,6 +90,15 @@ declare const EN: {
64
90
  resolve: string;
65
91
  reopen: string;
66
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;
67
102
  copyForAgentHint: string;
68
103
  loadingContext: string;
69
104
  edit: string;
@@ -90,6 +125,12 @@ declare const EN: {
90
125
  linkGone: string;
91
126
  enterName: string;
92
127
  writeFirst: string;
128
+ changeTextFirst: string;
129
+ tooManyImages: string;
130
+ imageUnreadable: string;
131
+ imageFailed: string;
132
+ noScreenshot: string;
133
+ suggestionTooLong: string;
93
134
  commentTooLong: string;
94
135
  replyTooLong: string;
95
136
  postFailed: string;
@@ -105,7 +146,7 @@ type MessageKey = keyof typeof EN;
105
146
  type Messages = { [K in MessageKey]: string | Plural; };
106
147
  //#endregion
107
148
  //#region src/config.d.ts
108
- type Position = "bottom-right" | "bottom-left" | "top-right" | "top-left";
149
+ type Position = "bottom-right" | "bottom-center" | "bottom-left" | "top-right" | "top-center" | "top-left";
109
150
  type Theme = "auto" | "light" | "dark";
110
151
  /** Built-in page keys, for when `getPageKey` is not set. */
111
152
  type PageKey = "path" | "path+search" | "path+hash";
@@ -125,7 +166,10 @@ interface NuniOptions {
125
166
  * the path (default), the path and query string, or the path and hash.
126
167
  */
127
168
  pageKey?: PageKey;
128
- /** Corner for the toolbar. Default "bottom-right". */
169
+ /**
170
+ * Where the toolbar starts. Default "bottom-right". Visitors can drag it to
171
+ * another corner or edge middle, and their choice is remembered.
172
+ */
129
173
  position?: Position;
130
174
  /** Brand color for buttons, pins and highlights (any CSS color). */
131
175
  accentColor?: string;
@@ -141,6 +185,11 @@ interface NuniOptions {
141
185
  locale?: string;
142
186
  /** Replace any of the widget's words. See the `EN` messages. */
143
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;
144
193
  /**
145
194
  * Extra context attached to each comment for the project owner and their
146
195
  * coding agent. Everything is on by default; set a key to `false` to stop
@@ -177,12 +226,6 @@ interface ScreenshotOptions {
177
226
  focus?: () => Box;
178
227
  }
179
228
  type CaptureScreenshot = (el: Element, options: ScreenshotOptions) => Promise<Blob | null>;
180
- declare global {
181
- interface Window {
182
- /** Set by nuni-screenshot.global.js (the CDN build). */
183
- __nuniScreenshot?: CaptureScreenshot;
184
- }
185
- }
186
229
  interface Box {
187
230
  left: number;
188
231
  top: number;
@@ -190,6 +233,49 @@ interface Box {
190
233
  bottom: number;
191
234
  }
192
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
193
279
  //#region src/mount.d.ts
194
280
  interface NuniInstance {
195
281
  destroy(): void;