@pixelatlarge/peanut-gallery-widget 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anna Poirier
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,33 @@
1
- # Temporary Holding Version
1
+ # @pixelatlarge/peanut-gallery-widget
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Drop-in annotation layer for hosted prototypes. Reviewers click any element and leave a note; notes stay attached to the element and are filed as a GitHub issue. Part of [Peanut Gallery](https://github.com/PixelAtLarge/peanut-gallery), which turns that feedback into pull requests with Claude. [Try the demo](https://pixelatlarge.github.io/peanut-gallery/).
4
+
5
+ **Script tag (any page):**
6
+
7
+ ```html
8
+ <script src="https://unpkg.com/@pixelatlarge/peanut-gallery-widget/dist/peanut-gallery.global.js"
9
+ data-repo="owner/prototype-repo"
10
+ data-activation="query"></script>
11
+ ```
12
+
13
+ Open the page with `?peanut` in the URL and the toolbar appears.
14
+
15
+ **React:**
16
+
17
+ ```tsx
18
+ import { PeanutGallery } from '@pixelatlarge/peanut-gallery-widget/react';
19
+
20
+ <PeanutGallery repo="owner/prototype-repo" />
21
+ ```
22
+
23
+ **Programmatic:**
24
+
25
+ ```ts
26
+ import { init } from '@pixelatlarge/peanut-gallery-widget';
27
+
28
+ init({ repo: 'owner/prototype-repo', relayUrl: 'https://example.com/api/peanut-gallery' });
29
+ ```
30
+
31
+ All options (relay mode, activation, theming, `onSubmit`, preloaded notes) are in the [main README](https://github.com/PixelAtLarge/peanut-gallery#1-add-the-widget-to-the-prototype).
32
+
33
+ MIT licensed.
@@ -0,0 +1,630 @@
1
+ // src/widget.ts
2
+ import {
3
+ createAnchor,
4
+ resolveAnchor,
5
+ newIssueUrl,
6
+ uid,
7
+ LABELS,
8
+ MAX_STEPS,
9
+ resolveTheme,
10
+ themeToCssVars,
11
+ DEFAULT_FONTS_URL
12
+ } from "@pixelatlarge/peanut-gallery-core";
13
+
14
+ // src/styles.ts
15
+ var css = `
16
+ :host { all: initial; }
17
+ * { box-sizing: border-box; font-family: var(--pg-font-body); font-size: 13px; line-height: 1.4; color: var(--pg-text); }
18
+ .pg-hidden { display: none !important; }
19
+
20
+ .pg-toolbar {
21
+ position: fixed; right: 16px; bottom: 16px; z-index: 2147483000;
22
+ display: flex; align-items: center; gap: 6px;
23
+ background: var(--pg-toolbar); border-radius: 999px; padding: 6px 8px 6px 14px;
24
+ box-shadow: 0 10px 28px rgba(21, 42, 74, .28); user-select: none;
25
+ }
26
+ .pg-toolbar .pg-brand { font-weight: 700; color: var(--pg-on-toolbar); margin-right: 4px; letter-spacing: .01em; }
27
+ .pg-btn {
28
+ appearance: none; border: 0; cursor: pointer; border-radius: 999px; padding: 6px 12px;
29
+ background: var(--pg-toolbar-button); color: var(--pg-on-toolbar); font-weight: 600;
30
+ }
31
+ .pg-btn:hover { background: var(--pg-toolbar-button-hover); }
32
+ .pg-btn.pg-primary { background: var(--pg-primary); color: var(--pg-on-primary); }
33
+ .pg-btn.pg-primary:hover { background: var(--pg-primary-hover); }
34
+ .pg-btn.pg-active, .pg-btn.pg-active:hover { background: var(--pg-accent); color: var(--pg-on-accent); }
35
+ .pg-btn:disabled { opacity: .5; cursor: default; }
36
+ .pg-btn:focus-visible, .pg-pin:focus-visible { outline: 2px solid var(--pg-accent); outline-offset: 2px; }
37
+ .pg-count { background: var(--pg-count); color: var(--pg-on-count); border-radius: 999px; padding: 1px 7px; font-weight: 700; margin-left: 6px; }
38
+
39
+ .pg-highlight {
40
+ position: absolute; z-index: 2147482000; pointer-events: none;
41
+ outline: 2px solid var(--pg-highlight); outline-offset: 2px; background: var(--pg-highlight-tint); border-radius: 3px;
42
+ }
43
+ .pg-tag {
44
+ position: absolute; top: -23px; left: -2px; background: var(--pg-highlight); color: var(--pg-on-primary); font-size: 11px;
45
+ padding: 2px 6px; border-radius: 4px; white-space: nowrap; font-family: var(--pg-font-mono);
46
+ }
47
+
48
+ .pg-pin {
49
+ position: absolute; z-index: 2147482500; width: 24px; height: 24px; margin: -12px 0 0 -12px;
50
+ border-radius: 999px 999px 999px 2px; background: var(--pg-accent); color: var(--pg-on-accent); font-weight: 700; font-size: 12px;
51
+ display: flex; align-items: center; justify-content: center; cursor: pointer;
52
+ box-shadow: 0 2px 8px rgba(21, 42, 74, .35); border: 2px solid var(--pg-surface);
53
+ }
54
+ .pg-pin.pg-low { background: var(--pg-pin-low); }
55
+ .pg-pin:hover { transform: scale(1.1); }
56
+
57
+ .pg-composer, .pg-panel {
58
+ position: absolute; z-index: 2147483100; background: var(--pg-surface); border-radius: 12px;
59
+ border: 1px solid var(--pg-line); box-shadow: 0 14px 40px rgba(21, 42, 74, .22); padding: 12px; width: 300px;
60
+ }
61
+ .pg-composer textarea {
62
+ width: 100%; min-height: 80px; resize: vertical; border: 1px solid var(--pg-line); border-radius: 8px; padding: 8px;
63
+ font-size: 13px; background: var(--pg-surface);
64
+ }
65
+ .pg-composer textarea:focus { outline: 2px solid var(--pg-primary); border-color: transparent; }
66
+ .pg-row { display: flex; gap: 6px; align-items: center; margin-top: 8px; }
67
+ .pg-row .pg-spacer { flex: 1; }
68
+ .pg-composer select { border: 1px solid var(--pg-line); border-radius: 8px; padding: 5px 6px; background: var(--pg-surface); }
69
+ .pg-meta { color: var(--pg-muted); font-size: 11px; margin-bottom: 6px; font-family: var(--pg-font-mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
70
+ .pg-btn.pg-ghost { background: var(--pg-ghost); color: var(--pg-text); }
71
+ .pg-btn.pg-ghost:hover { background: var(--pg-ghost-hover); }
72
+ .pg-btn.pg-danger { background: var(--pg-danger); color: var(--pg-on-danger); }
73
+
74
+ .pg-panel { position: fixed; right: 16px; bottom: 64px; width: 340px; max-height: 70vh; overflow: auto; }
75
+ .pg-panel h3 { margin: 0 0 8px; font-family: var(--pg-font-display); font-weight: 400; font-size: 18px; color: var(--pg-heading); }
76
+ .pg-item { border-top: 1px solid var(--pg-line); padding: 8px 0; }
77
+ .pg-item .pg-text { white-space: pre-wrap; }
78
+ .pg-item .pg-meta { margin: 2px 0 0; }
79
+ .pg-item .pg-kind { display: inline-block; background: var(--pg-chip); color: var(--pg-on-chip); border-radius: 4px; padding: 0 5px; font-size: 11px; font-weight: 600; margin-right: 4px; }
80
+ .pg-empty { color: var(--pg-muted); padding: 12px 0; }
81
+ .pg-link { color: var(--pg-link); }
82
+ .pg-toast { position: fixed; left: 50%; bottom: 72px; transform: translateX(-50%); background: var(--pg-toast); color: var(--pg-on-toast); padding: 8px 14px; border-radius: 999px; z-index: 2147483200; font-weight: 500; }
83
+ `;
84
+
85
+ // src/widget.ts
86
+ var LOCATION_EVENT = "peanut-gallery:locationchange";
87
+ function normalizePageUrl(href) {
88
+ try {
89
+ const u = new URL(href);
90
+ for (const key of [...u.searchParams.keys()]) {
91
+ if (key === "peanut" || key.startsWith("utm_") || key === "fbclid" || key === "gclid") u.searchParams.delete(key);
92
+ }
93
+ const search = u.searchParams.toString();
94
+ const hash = /^#!?\//.test(u.hash) ? u.hash : "";
95
+ return u.origin + u.pathname + (search ? `?${search}` : "") + hash;
96
+ } catch {
97
+ return href;
98
+ }
99
+ }
100
+ function watchHistory() {
101
+ if (typeof history === "undefined") return;
102
+ const h = history;
103
+ if (h.__peanutGallery) return;
104
+ h.__peanutGallery = true;
105
+ for (const name of ["pushState", "replaceState"]) {
106
+ const original = h[name];
107
+ h[name] = function(...args) {
108
+ const result = original.apply(this, args);
109
+ try {
110
+ window.dispatchEvent(new Event(LOCATION_EVENT));
111
+ } catch {
112
+ }
113
+ return result;
114
+ };
115
+ }
116
+ }
117
+ var store = {
118
+ get(key) {
119
+ try {
120
+ return localStorage.getItem(key);
121
+ } catch {
122
+ return null;
123
+ }
124
+ },
125
+ set(key, value) {
126
+ try {
127
+ localStorage.setItem(key, value);
128
+ } catch {
129
+ }
130
+ }
131
+ };
132
+ var DEFAULT_KINDS = ["bug", "copy", "layout", "visual", "flow", "question", "praise"];
133
+ var PeanutGalleryWidget = class {
134
+ cfg;
135
+ host;
136
+ root;
137
+ session;
138
+ mode = "idle";
139
+ hoverEl = null;
140
+ highlight;
141
+ pinsLayer;
142
+ panel = null;
143
+ composer = null;
144
+ toolbar;
145
+ raf = 0;
146
+ destroyed = false;
147
+ observer;
148
+ /** The reviewer's recent clicks on this page (see Annotation.steps). */
149
+ steps = [];
150
+ stepsPage = "";
151
+ constructor(cfg) {
152
+ if (!cfg.repo || !/^[\w.-]+\/[\w.-]+$/.test(cfg.repo)) throw new Error('PeanutGallery: config.repo must be "owner/name"');
153
+ this.cfg = {
154
+ mode: "auto",
155
+ labels: [LABELS.root, LABELS.feedback],
156
+ activation: "always",
157
+ kinds: DEFAULT_KINDS,
158
+ prototypeUrl: typeof location !== "undefined" ? location.origin : "",
159
+ ...cfg
160
+ };
161
+ }
162
+ get storageKey() {
163
+ return `peanut-gallery:${this.cfg.repo}`;
164
+ }
165
+ /** Mount the widget. Safe to call once DOM is ready. */
166
+ mount() {
167
+ if (this.cfg.activation === "query") {
168
+ const want = new URLSearchParams(location.search).has("peanut");
169
+ const sticky = sessionStorage.getItem("peanut-gallery:active") === "1";
170
+ if (!want && !sticky) return this;
171
+ sessionStorage.setItem("peanut-gallery:active", "1");
172
+ }
173
+ this.session = this.loadSession();
174
+ this.host = document.createElement("peanut-gallery");
175
+ this.host.setAttribute("data-peanut-gallery", "");
176
+ this.root = this.host.attachShadow({ mode: "open" });
177
+ const style = document.createElement("style");
178
+ style.textContent = `:host { ${themeToCssVars(resolveTheme(this.cfg.theme))} }
179
+ ${css}`;
180
+ if (this.cfg.loadFonts && !document.querySelector(`link[href="${DEFAULT_FONTS_URL}"]`)) {
181
+ const link = document.createElement("link");
182
+ link.rel = "stylesheet";
183
+ link.href = DEFAULT_FONTS_URL;
184
+ document.head.append(link);
185
+ }
186
+ this.root.append(style);
187
+ this.highlight = el("div", "pg-highlight pg-hidden");
188
+ this.highlight.append(el("span", "pg-tag"));
189
+ this.pinsLayer = el("div");
190
+ this.toolbar = this.buildToolbar();
191
+ this.root.append(this.highlight, this.pinsLayer, this.toolbar);
192
+ document.body.append(this.host);
193
+ document.addEventListener("mousemove", this.onMouseMove, true);
194
+ document.addEventListener("click", this.recordStep, true);
195
+ document.addEventListener("click", this.onClick, true);
196
+ document.addEventListener("keydown", this.onKey, true);
197
+ window.addEventListener("scroll", this.schedulePins, true);
198
+ window.addEventListener("resize", this.schedulePins);
199
+ watchHistory();
200
+ window.addEventListener(LOCATION_EVENT, this.onLocationChange);
201
+ window.addEventListener("popstate", this.onLocationChange);
202
+ window.addEventListener("hashchange", this.onLocationChange);
203
+ if (typeof MutationObserver !== "undefined") {
204
+ this.observer = new MutationObserver((records) => {
205
+ if (records.some((r) => r.target !== this.host)) this.schedulePins();
206
+ });
207
+ this.observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "style", "hidden", "open", "aria-hidden"], characterData: true });
208
+ }
209
+ this.renderPins();
210
+ this.updateCount();
211
+ return this;
212
+ }
213
+ onLocationChange = () => {
214
+ if (this.destroyed) return;
215
+ this.closeComposer();
216
+ this.schedulePins();
217
+ if (this.panel) this.renderPanel();
218
+ };
219
+ destroy() {
220
+ this.destroyed = true;
221
+ document.removeEventListener("mousemove", this.onMouseMove, true);
222
+ document.removeEventListener("click", this.onClick, true);
223
+ document.removeEventListener("click", this.recordStep, true);
224
+ document.removeEventListener("keydown", this.onKey, true);
225
+ window.removeEventListener("scroll", this.schedulePins, true);
226
+ window.removeEventListener("resize", this.schedulePins);
227
+ window.removeEventListener(LOCATION_EVENT, this.onLocationChange);
228
+ window.removeEventListener("popstate", this.onLocationChange);
229
+ window.removeEventListener("hashchange", this.onLocationChange);
230
+ this.observer?.disconnect();
231
+ if (this.raf) cancelAnimationFrame(this.raf);
232
+ this.host?.remove();
233
+ }
234
+ /** Current annotations (all pages). */
235
+ getAnnotations() {
236
+ return [...this.session.annotations];
237
+ }
238
+ // ---------- state ----------
239
+ loadSession() {
240
+ if (this.cfg.session && Array.isArray(this.cfg.session.annotations)) return structuredClone(this.cfg.session);
241
+ try {
242
+ const raw = store.get(this.storageKey);
243
+ if (raw) {
244
+ const s = JSON.parse(raw);
245
+ if (Array.isArray(s.annotations)) return s;
246
+ }
247
+ } catch {
248
+ }
249
+ return this.freshSession();
250
+ }
251
+ freshSession() {
252
+ return {
253
+ id: uid("rs"),
254
+ prototypeUrl: this.cfg.prototypeUrl,
255
+ annotations: [],
256
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
257
+ author: this.cfg.author ?? store.get("peanut-gallery:author") ?? void 0,
258
+ userAgent: navigator.userAgent
259
+ };
260
+ }
261
+ save() {
262
+ store.set(this.storageKey, JSON.stringify(this.session));
263
+ this.updateCount();
264
+ this.renderPins();
265
+ if (this.panel) this.renderPanel();
266
+ }
267
+ pageUrl() {
268
+ return normalizePageUrl(location.href);
269
+ }
270
+ // ---------- toolbar ----------
271
+ buildToolbar() {
272
+ const bar = el("div", "pg-toolbar");
273
+ const brand = el("span", "pg-brand", "Notes");
274
+ const comment = el("button", "pg-btn", "Comment");
275
+ comment.dataset.role = "comment";
276
+ comment.onclick = () => this.setMode(this.mode === "commenting" ? "idle" : "commenting");
277
+ const review = el("button", "pg-btn", "Review");
278
+ review.dataset.role = "review";
279
+ review.onclick = () => this.togglePanel();
280
+ const count = el("span", "pg-count", "0");
281
+ count.dataset.role = "count";
282
+ review.append(count);
283
+ bar.append(brand, comment, review);
284
+ return bar;
285
+ }
286
+ updateCount() {
287
+ const c = this.toolbar.querySelector("[data-role=count]");
288
+ c.textContent = String(this.session.annotations.length);
289
+ }
290
+ setMode(m) {
291
+ this.mode = m;
292
+ const btn = this.toolbar.querySelector("[data-role=comment]");
293
+ btn.classList.toggle("pg-active", m === "commenting");
294
+ btn.textContent = m === "commenting" ? "Click an element\u2026 (Esc)" : "Comment";
295
+ document.body.style.cursor = m === "commenting" ? "crosshair" : "";
296
+ if (m === "idle") this.hideHighlight();
297
+ }
298
+ // ---------- events ----------
299
+ isOwn(target) {
300
+ return target instanceof Node && (this.host === target || this.host.contains(target));
301
+ }
302
+ onMouseMove = (e) => {
303
+ if (this.mode !== "commenting") return;
304
+ const path = e.composedPath();
305
+ if (path.includes(this.host)) return this.hideHighlight();
306
+ const target = path[0];
307
+ if (!(target instanceof Element) || target === document.documentElement || target === document.body) return this.hideHighlight();
308
+ this.hoverEl = target;
309
+ const r = target.getBoundingClientRect();
310
+ Object.assign(this.highlight.style, {
311
+ left: `${r.left + scrollX}px`,
312
+ top: `${r.top + scrollY}px`,
313
+ width: `${r.width}px`,
314
+ height: `${r.height}px`
315
+ });
316
+ this.highlight.querySelector(".pg-tag").textContent = describe(target);
317
+ this.highlight.classList.remove("pg-hidden");
318
+ };
319
+ hideHighlight() {
320
+ this.hoverEl = null;
321
+ this.highlight.classList.add("pg-hidden");
322
+ }
323
+ onClick = (e) => {
324
+ if (this.mode !== "commenting") return;
325
+ if (e.composedPath().includes(this.host)) return;
326
+ const target = this.hoverEl ?? e.target;
327
+ if (!(target instanceof Element)) return;
328
+ e.preventDefault();
329
+ e.stopPropagation();
330
+ this.setMode("idle");
331
+ this.openComposer(target, e.clientX, e.clientY);
332
+ };
333
+ /**
334
+ * Remember what the reviewer clicks while using the app (not while placing a note), so a
335
+ * note inside a modal or a menu carries the clicks that opened it. Recorded in the capture
336
+ * phase, before the app re-renders. Only the element is kept, never what was typed.
337
+ */
338
+ recordStep = (e) => {
339
+ if (this.mode === "commenting" || this.destroyed) return;
340
+ if (this.host && e.composedPath().includes(this.host)) return;
341
+ const raw = e.target;
342
+ if (!(raw instanceof Element)) return;
343
+ const target = raw.closest('button, a, summary, label, select, [role="button"], [role="tab"], [role="menuitem"], [role="option"], [role="switch"], [role="checkbox"], [data-testid]') ?? raw;
344
+ const here = this.pageUrl();
345
+ if (here !== this.stepsPage) {
346
+ this.steps = [];
347
+ this.stepsPage = here;
348
+ }
349
+ const anchor = createAnchor(target, e.clientX, e.clientY);
350
+ this.steps.push({ ...anchor, context: void 0, textFingerprint: anchor.textFingerprint.slice(0, 60) });
351
+ if (this.steps.length > MAX_STEPS) this.steps.shift();
352
+ };
353
+ currentSteps() {
354
+ if (this.stepsPage !== this.pageUrl() || !this.steps.length) return void 0;
355
+ return this.steps.map((st) => ({ ...st }));
356
+ }
357
+ onKey = (e) => {
358
+ if (e.key === "Escape") {
359
+ if (this.composer) this.closeComposer();
360
+ else if (this.mode === "commenting") this.setMode("idle");
361
+ }
362
+ };
363
+ // ---------- composer ----------
364
+ openComposer(target, clientX, clientY, existing) {
365
+ this.closeComposer();
366
+ const anchor = existing?.anchor ?? createAnchor(target, clientX, clientY);
367
+ const box = el("div", "pg-composer");
368
+ const meta = el("div", "pg-meta", `${anchor.tag} \xB7 ${anchor.selector}`);
369
+ meta.title = anchor.selector;
370
+ const ta = document.createElement("textarea");
371
+ ta.placeholder = "What should change here? Be specific \u2014 this becomes a task.";
372
+ ta.value = existing?.text ?? "";
373
+ const kind = document.createElement("select");
374
+ kind.append(new Option("type\u2026", ""));
375
+ for (const k of this.cfg.kinds) kind.append(new Option(k, k, false, existing?.kind === k));
376
+ const row = el("div", "pg-row");
377
+ const save = el("button", "pg-btn pg-primary", existing ? "Update" : "Add note");
378
+ const cancel = el("button", "pg-btn pg-ghost", "Cancel");
379
+ cancel.onclick = () => this.closeComposer();
380
+ save.onclick = () => {
381
+ const text = ta.value.trim();
382
+ if (!text) return ta.focus();
383
+ if (existing) {
384
+ existing.text = text;
385
+ existing.kind = kind.value || void 0;
386
+ } else {
387
+ this.session.annotations.push({
388
+ id: uid("an"),
389
+ text,
390
+ kind: kind.value || void 0,
391
+ anchor,
392
+ pageUrl: this.pageUrl(),
393
+ viewport: { width: innerWidth, height: innerHeight },
394
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
395
+ author: this.session.author,
396
+ ...this.currentSteps() ? { steps: this.currentSteps() } : {}
397
+ });
398
+ }
399
+ this.save();
400
+ this.closeComposer();
401
+ };
402
+ row.append(kind, el("span", "pg-spacer"));
403
+ if (existing) {
404
+ const del = el("button", "pg-btn pg-danger", "Delete");
405
+ del.onclick = () => {
406
+ this.session.annotations = this.session.annotations.filter((a) => a.id !== existing.id);
407
+ this.save();
408
+ this.closeComposer();
409
+ };
410
+ row.append(del);
411
+ }
412
+ row.append(cancel, save);
413
+ box.append(meta, ta, row);
414
+ const x = Math.min(clientX + 12, innerWidth - 312) + scrollX;
415
+ const y = Math.min(clientY + 12, innerHeight - 190) + scrollY;
416
+ box.style.left = `${Math.max(8, x)}px`;
417
+ box.style.top = `${Math.max(8, y)}px`;
418
+ this.root.append(box);
419
+ this.composer = box;
420
+ ta.focus();
421
+ }
422
+ closeComposer() {
423
+ this.composer?.remove();
424
+ this.composer = null;
425
+ }
426
+ // ---------- pins ----------
427
+ schedulePins = () => {
428
+ if (this.raf || this.destroyed) return;
429
+ this.raf = requestAnimationFrame(() => {
430
+ this.raf = 0;
431
+ this.renderPins();
432
+ });
433
+ };
434
+ pinsSignature = "";
435
+ renderPins() {
436
+ const here = this.pageUrl();
437
+ const placed = [];
438
+ this.session.annotations.forEach((a, i) => {
439
+ if (normalizePageUrl(a.pageUrl) !== here) return;
440
+ const { el: target, confidence } = resolveAnchor(a.anchor, document);
441
+ if (!target || confidence < 0.4) return;
442
+ const vis = target.checkVisibility;
443
+ if (typeof vis === "function" && !vis.call(target)) return;
444
+ const r = target.getBoundingClientRect();
445
+ const x = Math.round(r.left + scrollX + r.width * a.anchor.offset.x);
446
+ const y = Math.round(r.top + scrollY + r.height * a.anchor.offset.y);
447
+ placed.push({ a, n: i + 1, x, y, low: confidence < 0.5 });
448
+ });
449
+ const sig = placed.map((p) => `${p.a.id}:${p.n}:${p.x}:${p.y}:${p.low}:${p.a.text}`).join("|");
450
+ if (sig === this.pinsSignature && this.pinsLayer.childElementCount === placed.length) return;
451
+ this.pinsSignature = sig;
452
+ this.pinsLayer.replaceChildren(
453
+ ...placed.map(({ a, n, x, y, low }) => {
454
+ const pin = el("div", "pg-pin" + (low ? " pg-low" : ""), String(n));
455
+ pin.title = a.text;
456
+ pin.style.left = `${x}px`;
457
+ pin.style.top = `${y}px`;
458
+ pin.onclick = (ev) => {
459
+ ev.stopPropagation();
460
+ const target = resolveAnchor(a.anchor, document).el ?? document.body;
461
+ this.openComposer(target, x - scrollX, y - scrollY, a);
462
+ };
463
+ return pin;
464
+ })
465
+ );
466
+ }
467
+ // ---------- panel ----------
468
+ togglePanel() {
469
+ if (this.panel) {
470
+ this.panel.remove();
471
+ this.panel = null;
472
+ return;
473
+ }
474
+ this.panel = el("div", "pg-panel");
475
+ this.root.append(this.panel);
476
+ this.renderPanel();
477
+ }
478
+ renderPanel() {
479
+ const p = this.panel;
480
+ p.replaceChildren();
481
+ const h = el("h3", "", `Feedback (${this.session.annotations.length})`);
482
+ p.append(h);
483
+ if (!this.session.annotations.length) {
484
+ p.append(el("div", "pg-empty", "No notes yet. Click \u201CComment\u201D, then click anything on the page."));
485
+ }
486
+ const here = this.pageUrl();
487
+ this.session.annotations.forEach((a, i) => {
488
+ const item = el("div", "pg-item");
489
+ const text = el("div", "pg-text");
490
+ if (a.kind) text.append(el("span", "pg-kind", a.kind));
491
+ text.append(`${i + 1}. ${a.text}`);
492
+ const meta = el("div", "pg-meta", `${normalizePageUrl(a.pageUrl) === here ? "this page" : pathOf(a.pageUrl)} \xB7 ${a.anchor.tag}${a.anchor.textFingerprint ? ` \u201C${a.anchor.textFingerprint.slice(0, 30)}\u201D` : ""}`);
493
+ item.append(text, meta);
494
+ p.append(item);
495
+ });
496
+ const row = el("div", "pg-row");
497
+ const clear = el("button", "pg-btn pg-ghost", "Clear");
498
+ clear.onclick = () => {
499
+ if (!this.session.annotations.length || confirm("Discard all notes?")) {
500
+ this.session = this.freshSession();
501
+ this.save();
502
+ }
503
+ };
504
+ const submit = el("button", "pg-btn pg-primary", this.effectiveMode() === "issue-url" ? "Submit to GitHub" : "Submit");
505
+ submit.disabled = !this.session.annotations.length;
506
+ submit.onclick = () => this.submit(submit);
507
+ row.append(clear, el("span", "pg-spacer"), submit);
508
+ p.append(row);
509
+ }
510
+ effectiveMode() {
511
+ if (this.cfg.onSubmit) return "callback";
512
+ if (this.cfg.mode === "relay") return "relay";
513
+ if (this.cfg.mode === "issue-url") return "issue-url";
514
+ return this.cfg.relayUrl ? "relay" : "issue-url";
515
+ }
516
+ ensureAuthor() {
517
+ if (this.session.author) return;
518
+ let name;
519
+ try {
520
+ name = prompt("Your name (shown on the feedback):")?.trim();
521
+ } catch {
522
+ }
523
+ if (name) {
524
+ this.session.author = name;
525
+ store.set("peanut-gallery:author", name);
526
+ this.session.annotations.forEach((a) => a.author ??= name);
527
+ }
528
+ }
529
+ async submit(btn) {
530
+ this.ensureAuthor();
531
+ const session = { ...this.session, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
532
+ if (this.effectiveMode() === "callback") {
533
+ btn.disabled = true;
534
+ btn.textContent = "Submitting\u2026";
535
+ try {
536
+ const out = await this.cfg.onSubmit(session) || {};
537
+ if (!out.keep) this.resetAfterSubmit();
538
+ this.toast(out.issueUrl ? `Submitted: ${out.issueUrl}` : "Submitted. Thank you!");
539
+ } catch (err) {
540
+ this.toast(`Could not submit: ${err.message}`);
541
+ } finally {
542
+ btn.disabled = false;
543
+ btn.textContent = "Submit";
544
+ }
545
+ return;
546
+ }
547
+ if (this.effectiveMode() === "issue-url") {
548
+ const url = newIssueUrl(this.cfg.repo, session, this.cfg.labels);
549
+ const w = window.open(url, "_blank", "noopener");
550
+ if (!w) {
551
+ this.toast("Popup blocked \u2014 allow popups and try again.");
552
+ return;
553
+ }
554
+ this.toast("Opened GitHub. Press \u201CSubmit new issue\u201D there; notes stay here until you Clear.");
555
+ return;
556
+ }
557
+ btn.disabled = true;
558
+ btn.textContent = "Submitting\u2026";
559
+ try {
560
+ const res = await fetch(this.cfg.relayUrl, {
561
+ method: "POST",
562
+ headers: { "content-type": "application/json" },
563
+ body: JSON.stringify({ session, labels: this.cfg.labels })
564
+ });
565
+ if (!res.ok) throw new Error(`Relay responded ${res.status}`);
566
+ const data = await res.json();
567
+ this.resetAfterSubmit();
568
+ this.toast(data.issueUrl ? `Submitted: ${data.issueUrl}` : "Submitted. Thank you!");
569
+ if (data.issueUrl) window.open(data.issueUrl, "_blank", "noopener");
570
+ } catch (err) {
571
+ this.toast(`Could not submit: ${err.message}`);
572
+ btn.disabled = false;
573
+ btn.textContent = "Submit";
574
+ }
575
+ }
576
+ /** Clear the submitted notes and redraw: pins, count and the panel. */
577
+ resetAfterSubmit() {
578
+ this.session = this.freshSession();
579
+ this.save();
580
+ if (this.panel) {
581
+ this.panel.remove();
582
+ this.panel = null;
583
+ }
584
+ this.renderPins();
585
+ this.updateCount();
586
+ }
587
+ toast(msg) {
588
+ const t = el("div", "pg-toast", msg);
589
+ this.root.append(t);
590
+ setTimeout(() => t.remove(), 5e3);
591
+ }
592
+ };
593
+ function el(tag, className = "", text) {
594
+ const e = document.createElement(tag);
595
+ if (className) e.className = className;
596
+ if (text !== void 0) e.textContent = text;
597
+ return e;
598
+ }
599
+ function describe(target) {
600
+ const id = target.getAttribute("data-testid") ?? target.id;
601
+ return target.tagName.toLowerCase() + (id ? `#${id}` : "");
602
+ }
603
+ function pathOf(url) {
604
+ try {
605
+ return new URL(url).pathname;
606
+ } catch {
607
+ return url;
608
+ }
609
+ }
610
+
611
+ // src/index.ts
612
+ var instance = null;
613
+ function init(config) {
614
+ if (instance) return instance;
615
+ instance = new PeanutGalleryWidget(config);
616
+ const mount = () => instance.mount();
617
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", mount, { once: true });
618
+ else mount();
619
+ return instance;
620
+ }
621
+ function destroy() {
622
+ instance?.destroy();
623
+ instance = null;
624
+ }
625
+
626
+ export {
627
+ PeanutGalleryWidget,
628
+ init,
629
+ destroy
630
+ };