@loupekit/sdk 0.4.3 → 0.5.0-next.17

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 CHANGED
@@ -19,6 +19,7 @@ comments re-anchor across redeploys and flow to Claude Code as an actionable bac
19
19
 
20
20
  <p>
21
21
  <a href="https://mohamed-ashraf-elsaed.github.io/loupe/"><b>Website</b></a> ·
22
+ <a href="https://mohamed-ashraf-elsaed.github.io/loupe/guide/"><b>Docs</b></a> ·
22
23
  <a href="https://github.com/mohamed-ashraf-elsaed/loupe"><b>GitHub</b></a> ·
23
24
  <a href="https://github.com/mohamed-ashraf-elsaed/loupe/blob/main/CHANGELOG.md"><b>Changelog</b></a> ·
24
25
  <a href="https://www.npmjs.com/package/@loupekit/mcp"><b>MCP server</b></a>
@@ -56,7 +57,9 @@ the moment of the comment, so the feedback stays actionable even after the UI ch
56
57
  | | |
57
58
  | --- | --- |
58
59
  | 🎯 **Click-to-comment inspector** | Hover-highlight any element, click to pin a comment. |
60
+ | 💬 **Free comments** | Drop a page-level note anywhere with the **Note** mode — no element, no screenshot. |
59
61
  | ▭ **Free-region screenshots** | Drag a free-size box, screenshot exactly that area, comment on it. The region anchors to the element under its center, so it tracks responsive reflow and scrolling. |
62
+ | 🧲 **Draggable toolbar** | Drag the `◎` logo anywhere; the position persists and the bar expands edge-aware (vertical on a side, horizontal on top/bottom). |
60
63
  | 🔁 **Redeploy-surviving re-anchoring** | A multi-signal fingerprint (stable id/testid, CSS path, XPath, text, attributes, position) re-locates the element on the current page; if it can't, the pin **detaches** instead of pointing at the wrong thing. |
61
64
  | 📸 **Screenshot capture** | `[data-loupe-redact]` regions are painted over **before any pixels leave the browser**. |
62
65
  | 🧩 **Shadow-DOM isolation** | The widget's CSS never leaks into your page and vice-versa. |
@@ -86,8 +89,9 @@ init({
86
89
  });
87
90
  ```
88
91
 
89
- A floating toolbar appears with **Inspect & comment**, **Region shot**, and **Comments**.
90
- Call `destroy()` to tear it down. `init()` is idempotent safe to call more than once.
92
+ A floating toolbar appears with **Inspect & comment**, **Note** (a free page-level comment),
93
+ **Region shot**, and **Comments**. Drag the `◎` logo to move the bar anywhere; click it to
94
+ collapse. Call `destroy()` to tear it down. `init()` is idempotent — safe to call more than once.
91
95
 
92
96
  ### Offline mode (no backend)
93
97
 
@@ -86,6 +86,8 @@ var Loupe = (() => {
86
86
  .pin:hover { transform: translate(-4px, -4px) scale(1.12); }
87
87
  .pin.detached { background: #9aa0af; }
88
88
  .pin.done { background: #10935a; }
89
+ .pin.free { background: var(--accent); border-radius: 50% 50% 2px 50%; }
90
+ .pin.free.done { background: #10935a; }
89
91
  .pin.active { outline: 3px solid rgba(74,85,214,.4); }
90
92
 
91
93
  /* toolbar */
@@ -112,11 +114,24 @@ var Loupe = (() => {
112
114
  .toolbar button.on { background: var(--accent); color: #fff; }
113
115
  .toolbar .sep { width: 1px; height: 20px; background: #333846; margin: 0 2px; }
114
116
  .toolbar .brand {
115
- font-weight: 700; letter-spacing: -.01em; cursor: pointer; user-select: none;
117
+ font-weight: 700; letter-spacing: -.01em; cursor: grab; user-select: none; touch-action: none;
116
118
  }
119
+ .toolbar.dragging { transition: none; box-shadow: 0 12px 40px rgba(0,0,0,.5); }
120
+ .toolbar.dragging .brand { cursor: grabbing; }
117
121
  /* collapsed \u2192 show only the brand/logo; click it again to expand */
118
122
  .toolbar.collapsed { gap: 0; }
119
123
  .toolbar.collapsed > *:not(.brand) { display: none; }
124
+
125
+ /* free-move: the bar is fixed-positioned by JS and expands *inward* from its
126
+ docked edge \u2014 vertical (a column) against a left/right edge, horizontal
127
+ otherwise \u2014 so it's always fully on-screen. */
128
+ .toolbar.floating { transform: none; }
129
+ .toolbar.orient-h.flow-rev { flex-direction: row-reverse; }
130
+ .toolbar.orient-v { flex-direction: column; align-items: stretch; }
131
+ .toolbar.orient-v.flow-rev { flex-direction: column-reverse; }
132
+ .toolbar.orient-v button, .toolbar.orient-v .brand { justify-content: flex-start; }
133
+ .toolbar.orient-v .sep { width: 22px; height: 1px; margin: 2px auto; }
134
+ .toolbar.orient-v .count { margin-left: auto; }
120
135
  .toolbar .count {
121
136
  background: var(--pin); color: #fff; font-size: 11px; font-weight: 700;
122
137
  border-radius: 999px; padding: 1px 7px; margin-left: 2px;
@@ -2196,6 +2211,12 @@ var Loupe = (() => {
2196
2211
  this.dragStart = null;
2197
2212
  /** region comment whose outline is currently highlighted (tracks scroll). */
2198
2213
  this.activeRegionId = null;
2214
+ /** Free-move toolbar position as viewport fractions, or null → default (bottom-center). */
2215
+ this.barPos = null;
2216
+ /** In-flight drag of the toolbar by its ◎ handle. */
2217
+ this.barDrag = null;
2218
+ /** True right after a drag so the trailing click doesn't also toggle collapse. */
2219
+ this.justDragged = false;
2199
2220
  this.raf = 0;
2200
2221
  // ---- inspector ------------------------------------------------------------
2201
2222
  this.onMove = (e) => {
@@ -2270,11 +2291,36 @@ var Loupe = (() => {
2270
2291
  this.setMode("off");
2271
2292
  this.finishRegion(vp);
2272
2293
  };
2294
+ // ---- free note (drop a comment anywhere, no element / no screenshot) -------
2295
+ this.onFreeClick = (e) => {
2296
+ if (this.mode !== "free") return;
2297
+ const t = e.target;
2298
+ if (t && (t.id === "loupe-root" || t.closest?.("#loupe-root"))) return;
2299
+ e.preventDefault();
2300
+ e.stopPropagation();
2301
+ const docX = e.clientX + window.scrollX;
2302
+ const docY = e.clientY + window.scrollY;
2303
+ const docW = Math.max(1, document.documentElement.scrollWidth);
2304
+ const docH = Math.max(1, document.documentElement.scrollHeight);
2305
+ const offset = { x: clamp(docX / docW), y: clamp(docY / docH) };
2306
+ this.setMode("off");
2307
+ this.openComposer({ kind: "free", offset, point: { x: docX, y: docY } }, e.clientX, e.clientY);
2308
+ };
2273
2309
  /** Reposition every pin, re-resolving anchors whose element has gone. */
2274
2310
  this.position = () => {
2275
2311
  for (const c of this.comments) {
2276
2312
  const pin = this.pins.get(c.id);
2277
2313
  if (!pin) continue;
2314
+ if (c.kind === "free") {
2315
+ const docW = Math.max(1, document.documentElement.scrollWidth);
2316
+ const docH = Math.max(1, document.documentElement.scrollHeight);
2317
+ const px = c.offset.x * docW - window.scrollX;
2318
+ const py = c.offset.y * docH - window.scrollY;
2319
+ const onScreen = px > -24 && px < window.innerWidth + 24 && py > -24 && py < window.innerHeight + 24;
2320
+ Object.assign(pin.style, { left: px + "px", top: py + "px", display: onScreen ? "grid" : "none" });
2321
+ pin.classList.remove("detached");
2322
+ continue;
2323
+ }
2278
2324
  let elx = this.resolved.get(c.id) ?? null;
2279
2325
  if (!elx || !elx.isConnected) {
2280
2326
  const r = resolveAnchor(c.anchor);
@@ -2319,6 +2365,58 @@ var Loupe = (() => {
2319
2365
  }
2320
2366
  }
2321
2367
  };
2368
+ // ---- draggable, edge-aware floating toolbar --------------------------------
2369
+ this.onResizeBar = () => this.layoutBar();
2370
+ this.onBarPointerDown = (e) => {
2371
+ if (typeof e.button === "number" && e.button !== 0) return;
2372
+ const brand = this.brandEl();
2373
+ const r = brand.getBoundingClientRect();
2374
+ this.barDrag = { px: e.clientX, py: e.clientY, ix: r.left, iy: r.top };
2375
+ this.justDragged = false;
2376
+ try {
2377
+ brand.setPointerCapture(e.pointerId);
2378
+ } catch {
2379
+ }
2380
+ brand.addEventListener("pointermove", this.onBarPointerMove);
2381
+ brand.addEventListener("pointerup", this.onBarPointerUp);
2382
+ brand.addEventListener("pointercancel", this.onBarPointerUp);
2383
+ };
2384
+ this.onBarPointerMove = (e) => {
2385
+ if (!this.barDrag) return;
2386
+ const dx = e.clientX - this.barDrag.px;
2387
+ const dy = e.clientY - this.barDrag.py;
2388
+ if (!this.justDragged && Math.hypot(dx, dy) < 5) return;
2389
+ this.justDragged = true;
2390
+ e.preventDefault();
2391
+ const vw = window.innerWidth, vh = window.innerHeight;
2392
+ const bar = this.toolbar;
2393
+ const brand = this.brandEl();
2394
+ const iw = brand.offsetWidth || 44, ih = brand.offsetHeight || 44;
2395
+ const ix = clampPx(this.barDrag.ix + dx, 6, vw - iw - 6);
2396
+ const iy = clampPx(this.barDrag.iy + dy, 6, vh - ih - 6);
2397
+ bar.classList.add("floating", "dragging");
2398
+ bar.classList.remove("orient-v", "flow-rev");
2399
+ bar.classList.add("orient-h");
2400
+ Object.assign(bar.style, { transform: "none", left: ix + "px", top: iy + "px", right: "auto", bottom: "auto" });
2401
+ this.barPos = { fx: ix / vw, fy: iy / vh };
2402
+ };
2403
+ this.onBarPointerUp = (e) => {
2404
+ const brand = this.brandEl();
2405
+ brand.removeEventListener("pointermove", this.onBarPointerMove);
2406
+ brand.removeEventListener("pointerup", this.onBarPointerUp);
2407
+ brand.removeEventListener("pointercancel", this.onBarPointerUp);
2408
+ try {
2409
+ brand.releasePointerCapture(e.pointerId);
2410
+ } catch {
2411
+ }
2412
+ const dragged = this.justDragged;
2413
+ this.barDrag = null;
2414
+ this.toolbar.classList.remove("dragging");
2415
+ if (dragged) {
2416
+ this.saveBarPos();
2417
+ this.layoutBar();
2418
+ }
2419
+ };
2322
2420
  this.cfg = cfg;
2323
2421
  this.store = cfg.apiBase ? new HttpAdapter(cfg.apiBase, cfg.user, cfg.userHmac, cfg.headers, cfg.credentials) : new LocalStorageAdapter();
2324
2422
  }
@@ -2326,7 +2424,9 @@ var Loupe = (() => {
2326
2424
  return location.pathname + location.search;
2327
2425
  }
2328
2426
  async start() {
2427
+ this.loadBarPos();
2329
2428
  this.buildDom();
2429
+ this.layoutBar();
2330
2430
  this.lastUrl = this.url;
2331
2431
  this.comments = await this.store.list(this.cfg.projectKey, this.url);
2332
2432
  this.renderPins();
@@ -2391,11 +2491,21 @@ var Loupe = (() => {
2391
2491
  const bar = el("div", "toolbar");
2392
2492
  const brand = el("span", "brand");
2393
2493
  brand.innerHTML = `<span class="ico">\u25CE</span><span class="label">Loupe</span>`;
2394
- brand.title = "Collapse / expand the Loupe bar";
2494
+ brand.title = "Drag to move \xB7 click to collapse / expand";
2395
2495
  brand.setAttribute("role", "button");
2396
- brand.onclick = () => this.toggleCollapsed();
2496
+ brand.onclick = () => {
2497
+ if (this.justDragged) {
2498
+ this.justDragged = false;
2499
+ return;
2500
+ }
2501
+ this.toggleCollapsed();
2502
+ };
2503
+ brand.addEventListener("pointerdown", this.onBarPointerDown);
2397
2504
  const inspectBtn = this.toolBtn("\u271B", "Inspect & comment", "inspect");
2398
2505
  inspectBtn.onclick = () => this.setMode(this.mode === "inspect" ? "off" : "inspect");
2506
+ const freeBtn = this.toolBtn(NOTE_ICON, "Note", "free");
2507
+ freeBtn.title = "Drop a note anywhere on the page \u2014 no element, no screenshot";
2508
+ freeBtn.onclick = () => this.setMode(this.mode === "free" ? "off" : "free");
2399
2509
  const regionBtn = this.toolBtn(REGION_ICON, "Region shot", "region");
2400
2510
  regionBtn.title = "Drag a free-size box, screenshot it, and comment";
2401
2511
  regionBtn.onclick = () => this.setMode(this.mode === "region" ? "off" : "region");
@@ -2403,7 +2513,7 @@ var Loupe = (() => {
2403
2513
  this.countEl = el("span", "count", "0");
2404
2514
  listBtn.appendChild(this.countEl);
2405
2515
  listBtn.onclick = () => this.togglePanel();
2406
- bar.append(brand, sep(), inspectBtn, regionBtn, listBtn);
2516
+ bar.append(brand, sep(), inspectBtn, freeBtn, regionBtn, listBtn);
2407
2517
  return bar;
2408
2518
  }
2409
2519
  /** A toolbar button with a uniform icon + label layout. `icon` may be an SVG string. */
@@ -2472,9 +2582,11 @@ var Loupe = (() => {
2472
2582
  this.cancelDrag();
2473
2583
  document.body.style.cursor = mode === "off" ? "" : "crosshair";
2474
2584
  this.toolbar.querySelector('[data-role="inspect"]')?.classList.toggle("on", mode === "inspect");
2585
+ this.toolbar.querySelector('[data-role="free"]')?.classList.toggle("on", mode === "free");
2475
2586
  this.toolbar.querySelector('[data-role="region"]')?.classList.toggle("on", mode === "region");
2476
2587
  document.removeEventListener("mousemove", this.onMove, true);
2477
2588
  document.removeEventListener("click", this.onClick, true);
2589
+ document.removeEventListener("click", this.onFreeClick, true);
2478
2590
  document.removeEventListener("mousedown", this.onRegionDown, true);
2479
2591
  if (mode === "off") return;
2480
2592
  document.addEventListener("keydown", this.onKey, true);
@@ -2482,6 +2594,8 @@ var Loupe = (() => {
2482
2594
  if (mode === "inspect") {
2483
2595
  document.addEventListener("mousemove", this.onMove, true);
2484
2596
  document.addEventListener("click", this.onClick, true);
2597
+ } else if (mode === "free") {
2598
+ document.addEventListener("click", this.onFreeClick, true);
2485
2599
  } else if (mode === "region") {
2486
2600
  document.addEventListener("mousedown", this.onRegionDown, true);
2487
2601
  }
@@ -2494,16 +2608,22 @@ var Loupe = (() => {
2494
2608
  const label = el(
2495
2609
  "div",
2496
2610
  "target",
2497
- target.kind === "element" ? describe(target.element) : `Region \xB7 ${Math.round(target.region.w)}\xD7${Math.round(target.region.h)} px`
2611
+ target.kind === "element" ? describe(target.element) : target.kind === "region" ? `Region \xB7 ${Math.round(target.region.w)}\xD7${Math.round(target.region.h)} px` : "Free note \xB7 anywhere on the page"
2498
2612
  );
2499
2613
  const ta = el("textarea");
2500
- ta.placeholder = target.kind === "region" ? "What's the issue in this area?" : "What should change here?";
2614
+ ta.placeholder = target.kind === "region" ? "What's the issue in this area?" : target.kind === "free" ? "Leave a note about this page\u2026" : "What should change here?";
2501
2615
  const row = el("div", "row");
2502
- const chk = el("label", "chk");
2503
- const box = document.createElement("input");
2504
- box.type = "checkbox";
2505
- box.checked = true;
2506
- chk.append(box, document.createTextNode("Attach screenshot"));
2616
+ let box = null;
2617
+ if (target.kind !== "free") {
2618
+ const chk = el("label", "chk");
2619
+ box = document.createElement("input");
2620
+ box.type = "checkbox";
2621
+ box.checked = true;
2622
+ chk.append(box, document.createTextNode("Attach screenshot"));
2623
+ row.append(chk);
2624
+ } else {
2625
+ row.style.justifyContent = "flex-end";
2626
+ }
2507
2627
  const btns = el("div", "btns");
2508
2628
  const cancel = el("button", "ghost", "Cancel");
2509
2629
  cancel.onclick = () => this.closeComposer();
@@ -2512,9 +2632,9 @@ var Loupe = (() => {
2512
2632
  ta.oninput = () => {
2513
2633
  save.disabled = !ta.value.trim();
2514
2634
  };
2515
- save.onclick = () => this.submit(target, ta.value.trim(), box.checked);
2635
+ save.onclick = () => this.submit(target, ta.value.trim(), box ? box.checked : false);
2516
2636
  btns.append(cancel, save);
2517
- row.append(chk, btns);
2637
+ row.append(btns);
2518
2638
  c.append(label, ta, row);
2519
2639
  const w = 300, h = 190;
2520
2640
  const left = Math.min(Math.max(8, x + 12), window.innerWidth - w - 8);
@@ -2545,6 +2665,11 @@ var Loupe = (() => {
2545
2665
  context = captureElementContext(target.element);
2546
2666
  offset = this.targetOffset;
2547
2667
  anchoredEl = target.element;
2668
+ } else if (target.kind === "free") {
2669
+ screenshot = void 0;
2670
+ anchor = pageAnchor(target.point);
2671
+ context = { html: "", styles: {} };
2672
+ offset = target.offset;
2548
2673
  } else {
2549
2674
  screenshot = withShot ? target.screenshot ?? await this.pendingShot : void 0;
2550
2675
  region = target.region;
@@ -2604,6 +2729,7 @@ var Loupe = (() => {
2604
2729
  }
2605
2730
  pin.textContent = String(i + 1);
2606
2731
  pin.classList.toggle("done", c.status === "done");
2732
+ pin.classList.toggle("free", c.kind === "free");
2607
2733
  });
2608
2734
  this.countEl.textContent = String(this.comments.length);
2609
2735
  this.position();
@@ -2631,6 +2757,7 @@ var Loupe = (() => {
2631
2757
  };
2632
2758
  window.addEventListener("scroll", reposition, true);
2633
2759
  window.addEventListener("resize", reposition);
2760
+ window.addEventListener("resize", this.onResizeBar);
2634
2761
  let debounce = 0;
2635
2762
  this.mo = new MutationObserver(() => {
2636
2763
  clearTimeout(debounce);
@@ -2664,6 +2791,86 @@ var Loupe = (() => {
2664
2791
  this.overlay.style.display = "";
2665
2792
  this.renderPins();
2666
2793
  }
2794
+ this.layoutBar();
2795
+ }
2796
+ loadBarPos() {
2797
+ try {
2798
+ const s = localStorage.getItem("loupe:bar");
2799
+ if (s) {
2800
+ const p = JSON.parse(s);
2801
+ if (typeof p?.fx === "number" && typeof p?.fy === "number") this.barPos = p;
2802
+ }
2803
+ } catch {
2804
+ }
2805
+ }
2806
+ saveBarPos() {
2807
+ try {
2808
+ if (this.barPos) localStorage.setItem("loupe:bar", JSON.stringify(this.barPos));
2809
+ } catch {
2810
+ }
2811
+ }
2812
+ brandEl() {
2813
+ return this.toolbar.querySelector(".brand");
2814
+ }
2815
+ /**
2816
+ * Position the toolbar for its stored spot and make it expand *inward* so it's
2817
+ * always fully on-screen: docked to a left/right edge → vertical; to a
2818
+ * top/bottom edge or a corner → horizontal. No stored spot → default
2819
+ * (bottom-center, governed by CSS).
2820
+ */
2821
+ layoutBar() {
2822
+ const bar = this.toolbar;
2823
+ if (!this.barPos) {
2824
+ bar.classList.remove("floating", "orient-h", "orient-v", "flow-rev");
2825
+ for (const p of ["left", "top", "right", "bottom", "transform"]) bar.style[p] = "";
2826
+ return;
2827
+ }
2828
+ const vw = window.innerWidth, vh = window.innerHeight;
2829
+ const brand = this.brandEl();
2830
+ const iw = brand.offsetWidth || 44, ih = brand.offsetHeight || 44;
2831
+ const ix = clampPx(this.barPos.fx * vw, 6, Math.max(6, vw - iw - 6));
2832
+ const iy = clampPx(this.barPos.fy * vh, 6, Math.max(6, vh - ih - 6));
2833
+ const cx = ix + iw / 2, cy = iy + ih / 2;
2834
+ const horizontalSide = cx < vw / 2 ? "left" : "right";
2835
+ const verticalSide = cy < vh / 2 ? "top" : "bottom";
2836
+ const vertical = Math.min(cx, vw - cx) < Math.min(cy, vh - cy);
2837
+ bar.classList.add("floating");
2838
+ bar.classList.remove("dragging");
2839
+ bar.classList.toggle("orient-v", vertical);
2840
+ bar.classList.toggle("orient-h", !vertical);
2841
+ bar.classList.toggle("flow-rev", vertical ? verticalSide === "bottom" : horizontalSide === "right");
2842
+ bar.style.transform = "none";
2843
+ if (horizontalSide === "left") {
2844
+ bar.style.left = ix + "px";
2845
+ bar.style.right = "auto";
2846
+ } else {
2847
+ bar.style.right = vw - (ix + iw) + "px";
2848
+ bar.style.left = "auto";
2849
+ }
2850
+ if (verticalSide === "top") {
2851
+ bar.style.top = iy + "px";
2852
+ bar.style.bottom = "auto";
2853
+ } else {
2854
+ bar.style.bottom = vh - (iy + ih) + "px";
2855
+ bar.style.top = "auto";
2856
+ }
2857
+ const r = bar.getBoundingClientRect();
2858
+ if (r.right > vw - 6 && bar.style.left !== "auto") {
2859
+ bar.style.left = "auto";
2860
+ bar.style.right = "6px";
2861
+ }
2862
+ if (r.left < 6 && bar.style.right !== "auto") {
2863
+ bar.style.right = "auto";
2864
+ bar.style.left = "6px";
2865
+ }
2866
+ if (r.bottom > vh - 6 && bar.style.top !== "auto") {
2867
+ bar.style.top = "auto";
2868
+ bar.style.bottom = "6px";
2869
+ }
2870
+ if (r.top < 6 && bar.style.bottom !== "auto") {
2871
+ bar.style.bottom = "auto";
2872
+ bar.style.top = "6px";
2873
+ }
2667
2874
  }
2668
2875
  renderPanel() {
2669
2876
  const p = this.panel;
@@ -2676,7 +2883,7 @@ var Loupe = (() => {
2676
2883
  p.appendChild(head);
2677
2884
  const list = el("div", "list");
2678
2885
  if (!this.comments.length) {
2679
- list.appendChild(el("div", "empty", "No comments yet. Click \u201CInspect & comment\u201D, then click any element."));
2886
+ list.appendChild(el("div", "empty", "No comments yet. Click \u201CInspect & comment\u201D and pick an element, or \u201CNote\u201D to drop a comment anywhere."));
2680
2887
  }
2681
2888
  this.comments.forEach((c, i) => list.appendChild(this.itemView(c, i)));
2682
2889
  p.appendChild(list);
@@ -2735,7 +2942,13 @@ var Loupe = (() => {
2735
2942
  return item;
2736
2943
  }
2737
2944
  async copyForClaude(c) {
2738
- const prompt = [
2945
+ const lines = c.kind === "free" ? [
2946
+ `# Product feedback from ${c.author.name}`,
2947
+ ``,
2948
+ `**Note:** ${c.body}`,
2949
+ `**Page:** ${c.url}`,
2950
+ `**Type:** Free note \u2014 a page-level comment not tied to a specific element.`
2951
+ ] : [
2739
2952
  `# Product feedback from ${c.author.name}`,
2740
2953
  ``,
2741
2954
  `**Comment:** ${c.body}`,
@@ -2752,7 +2965,8 @@ var Loupe = (() => {
2752
2965
  "```json",
2753
2966
  JSON.stringify(c.context.styles, null, 2),
2754
2967
  "```"
2755
- ].filter(Boolean).join("\n");
2968
+ ];
2969
+ const prompt = lines.filter(Boolean).join("\n");
2756
2970
  try {
2757
2971
  await navigator.clipboard.writeText(prompt);
2758
2972
  } catch {
@@ -2762,6 +2976,19 @@ var Loupe = (() => {
2762
2976
  flash(id) {
2763
2977
  const c = this.comments.find((x) => x.id === id);
2764
2978
  const pin = this.pins.get(id);
2979
+ if (c?.kind === "free") {
2980
+ for (const p of this.pins.values()) p.classList.remove("active");
2981
+ pin?.classList.add("active");
2982
+ const docW = Math.max(1, document.documentElement.scrollWidth);
2983
+ const docH = Math.max(1, document.documentElement.scrollHeight);
2984
+ window.scrollTo({
2985
+ top: Math.max(0, c.offset.y * docH - window.innerHeight / 2),
2986
+ left: Math.max(0, c.offset.x * docW - window.innerWidth / 2),
2987
+ behavior: "smooth"
2988
+ });
2989
+ this.position();
2990
+ return;
2991
+ }
2765
2992
  if (c?.kind === "region" && c.region) {
2766
2993
  for (const p of this.pins.values()) p.classList.remove("active");
2767
2994
  pin?.classList.add("active");
@@ -2788,6 +3015,7 @@ var Loupe = (() => {
2788
3015
  this.mo?.disconnect();
2789
3016
  if (this.tick) clearInterval(this.tick);
2790
3017
  window.clearTimeout(this.regionTimer);
3018
+ window.removeEventListener("resize", this.onResizeBar);
2791
3019
  document.removeEventListener("keydown", this.onKey, true);
2792
3020
  this.root?.remove();
2793
3021
  }
@@ -2804,7 +3032,24 @@ var Loupe = (() => {
2804
3032
  function clamp(n) {
2805
3033
  return Math.max(0, Math.min(1, n));
2806
3034
  }
3035
+ function clampPx(n, min, max) {
3036
+ return Math.max(min, Math.min(max, n));
3037
+ }
2807
3038
  var REGION_ICON = `<svg class="ico" width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true"><rect x="1.5" y="2.5" width="12" height="10" rx="1.5" stroke="currentColor" stroke-width="1.4" stroke-dasharray="2.4 1.8"/></svg>`;
3039
+ var NOTE_ICON = `<svg class="ico" width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true"><path d="M2 2.5h11v7.5H6l-3 2.5v-2.5H2z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/></svg>`;
3040
+ function pageAnchor(point) {
3041
+ return {
3042
+ tag: "page",
3043
+ cssPath: "page",
3044
+ xpath: "",
3045
+ testid: null,
3046
+ text: "",
3047
+ attrs: {},
3048
+ nthOfType: 1,
3049
+ rect: { x: Math.round(point.x), y: Math.round(point.y), w: 0, h: 0 },
3050
+ viewport: { w: window.innerWidth, h: window.innerHeight }
3051
+ };
3052
+ }
2808
3053
  function regionAnchor(region) {
2809
3054
  return {
2810
3055
  tag: "region",
@@ -2830,6 +3075,7 @@ var Loupe = (() => {
2830
3075
  return txt ? `${tag} \xB7 \u201C${txt}\u201D` : tag;
2831
3076
  }
2832
3077
  function describeAnchor(c) {
3078
+ if (c.kind === "free") return "Free note \xB7 page-level";
2833
3079
  return c.anchor.testid ? `[data-testid="${c.anchor.testid}"]` : c.anchor.cssPath;
2834
3080
  }
2835
3081