@loupekit/sdk 0.6.0 → 0.7.0-next.24

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
@@ -59,6 +59,7 @@ the moment of the comment, so the feedback stays actionable even after the UI ch
59
59
  | 🎯 **Click-to-comment inspector** | Hover-highlight any element, click to pin a comment. |
60
60
  | 💬 **Free comments** | Drop a page-level note anywhere with the **Note** mode — no element, no screenshot. |
61
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
+ | ⏺ **Screen recording** | The **Record** tool drags the same box, then captures a screen video of it (via `getDisplayMedia` + canvas crop) as `.webm` — duration-capped with a Stop button. Recordings play back inline in the widget and the dashboard. |
62
63
  | 🧲 **Dockable control** | A DevTools-style panel: dock it to the left / right / bottom edge (which pushes your page over so it's never covered) or float it as a movable, resizable window. Light/dark theme, collapses to a small `◎` launcher, and becomes a bottom sheet on mobile. Position & theme persist. |
63
64
  | 🔁 **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. |
64
65
  | 📸 **Screenshot capture** | `[data-loupe-redact]` regions are painted over **before any pixels leave the browser**. |
@@ -89,8 +90,9 @@ init({
89
90
  });
90
91
  ```
91
92
 
92
- A dockable control panel appears with **Inspect**, **Note** (a free page-level comment),
93
- **Region**, and the comment list. Use the header's dock controls to dock it left / right /
93
+ A dockable control panel appears with a two-page sidebar — **Comments** (with the
94
+ **Inspect**, **Note**, **Region**, and **Record** tools + the comment list) and
95
+ **Connect Claude** (MCP setup steps). Use the header's dock controls to dock it left / right /
94
96
  bottom (which pushes your page over) or float it, toggle light/dark, or close it to a small
95
97
  `◎` launcher. Call `destroy()` to tear it down. `init()` is idempotent — safe to call more
96
98
  than once. Pass `label` to change the brand name shown in the header.
@@ -130,6 +132,7 @@ and reordered, yet the pin follows it:
130
132
  | `autoOpen` | `boolean` | Start with the inspector already active. |
131
133
  | `captureScreenshot` | `(el: Element) => Promise<string \| undefined>` | Override element screenshot capture (the extension backs this with `captureVisibleTab`). |
132
134
  | `captureRegion` | `(rect: RegionRect) => Promise<string \| undefined>` | Override free-region capture. `rect` is in viewport coordinates. |
135
+ | `captureRecording` | `(rect, opts?) => Promise<string \| undefined>` | Override screen-recording capture (returns a webm data URL). Defaults to `getDisplayMedia` + canvas crop; `opts` carries a duration cap and a `register(stop)` hook. |
133
136
  | `headers` | `Record<string, string>` | Extra headers merged into every backend request (e.g. a CSRF token). |
134
137
  | `credentials` | `RequestCredentials` | `credentials` mode for backend requests — set `"include"` for cross-origin cookie auth. |
135
138
 
@@ -231,6 +231,62 @@ var Loupe = (() => {
231
231
  .composer .primary:disabled { opacity: .5; cursor: default; }
232
232
  .composer .ghost { background: var(--bg-3); color: var(--ink); }
233
233
 
234
+ /* ------------------------------------------------------------ sidebar tabs */
235
+ .tabs { display: flex; gap: 4px; flex: none; padding: 8px 10px 0; border-bottom: 1px solid var(--line); }
236
+ .tabs .tab {
237
+ flex: 1; padding: 8px 10px; border: 0; border-bottom: 2px solid transparent;
238
+ background: transparent; color: var(--muted); font-size: 12px; font-weight: 700; cursor: pointer;
239
+ border-radius: 6px 6px 0 0;
240
+ }
241
+ .tabs .tab:hover { color: var(--ink); background: var(--bg-2); }
242
+ .tabs .tab.on { color: var(--accent); border-bottom-color: var(--accent); }
243
+
244
+ /* Two sidebar pages: only the active one shows. */
245
+ .view { display: none; }
246
+ .dock.tab-comments .comments-view { display: flex; flex-direction: column; flex: 1; min-height: 0; }
247
+ .dock.tab-connect .connect-view { display: block; flex: 1; overflow-y: auto; }
248
+
249
+ /* recording marker + video in the list */
250
+ .item .rectag { font-size: 10px; font-weight: 700; color: var(--pin); background: var(--bg-3); border-radius: 999px; padding: 1px 7px; white-space: nowrap; }
251
+ .item video.shot { width: 100%; border-radius: 6px; margin-top: 8px; border: 1px solid var(--line); background: #000; display: block; }
252
+
253
+ /* ------------------------------------------------ "integrates with" footer */
254
+ .integrations { flex: none; padding: 12px 12px 14px; border-top: 1px solid var(--line); text-align: center; }
255
+ .integrations .ilabel { font-size: 10px; letter-spacing: .12em; color: var(--muted); font-weight: 700; margin-bottom: 8px; }
256
+ .integrations .irow { display: flex; align-items: center; justify-content: center; gap: 14px; }
257
+ .integrations .ibtn { color: var(--muted); display: inline-flex; opacity: .8; cursor: default; }
258
+ .integrations .ibtn:hover { color: var(--accent); opacity: 1; }
259
+ .integrations .ibtn svg { display: block; }
260
+
261
+ /* ------------------------------------------------------- Connect Claude page */
262
+ .connect-view { padding: 16px 14px 20px; }
263
+ .connect-hero { text-align: center; margin-bottom: 18px; }
264
+ .connect-hero .chero-logo { font-size: 34px; line-height: 1; color: var(--accent); }
265
+ .connect-hero .chero-title { font-size: 18px; font-weight: 800; letter-spacing: -.02em; margin-top: 8px; }
266
+ .connect-hero .chero-sub { font-size: 12.5px; color: var(--muted); line-height: 1.45; margin-top: 6px; }
267
+ .accentink { color: var(--accent); }
268
+ .connect-steps { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 14px; }
269
+ .connect-steps .cstep-t { font-size: 13px; font-weight: 700; }
270
+ .connect-steps .cstep-d { font-size: 12px; color: var(--muted); line-height: 1.45; margin-top: 3px; }
271
+ .connect-steps .cstep-code {
272
+ margin: 8px 0 0; padding: 10px; background: var(--bg-2); border: 1px solid var(--line);
273
+ border-radius: 8px; font-family: ui-monospace, Menlo, monospace; font-size: 11px; line-height: 1.4;
274
+ color: var(--ink); white-space: pre; overflow-x: auto;
275
+ }
276
+
277
+ /* ------------------------------------------------------- recording indicator */
278
+ .recbar {
279
+ position: fixed; z-index: 2147483005; top: 16px; left: 50%; transform: translateX(-50%);
280
+ display: none; align-items: center; gap: 8px; padding: 8px 14px; border-radius: 999px;
281
+ background: var(--bg); color: var(--ink); border: 1px solid var(--pin);
282
+ box-shadow: var(--shadow); font-size: 12px; font-weight: 600; cursor: pointer;
283
+ }
284
+ .recbar.show { display: inline-flex; }
285
+ .recbar b { color: var(--pin); }
286
+ .recbar .recdot { width: 9px; height: 9px; border-radius: 50%; background: var(--pin); animation: loupe-recpulse 1.1s infinite; }
287
+ @keyframes loupe-recpulse { 0%,100% { opacity: 1; } 50% { opacity: .25; } }
288
+ @media (prefers-reduced-motion: reduce) { .recbar .recdot { animation: none; } }
289
+
234
290
  /* Mobile: left/right/float docking is a desktop affordance. On small screens the
235
291
  panel collapses to a bottom sheet that OVERLAYS the page (pushing a side dock
236
292
  here would squeeze the page to a useless sliver), regardless of the chosen dock
@@ -2100,6 +2156,91 @@ var Loupe = (() => {
2100
2156
  }
2101
2157
  return document.body;
2102
2158
  }
2159
+ function pickRecordingMime() {
2160
+ const MR = window.MediaRecorder;
2161
+ if (!MR?.isTypeSupported) return "";
2162
+ for (const t of ["video/webm;codecs=vp9", "video/webm;codecs=vp8", "video/webm"]) {
2163
+ if (MR.isTypeSupported(t)) return t;
2164
+ }
2165
+ return "";
2166
+ }
2167
+ function blobToDataUrl2(blob) {
2168
+ return new Promise((resolve, reject) => {
2169
+ const r = new FileReader();
2170
+ r.onload = () => resolve(String(r.result));
2171
+ r.onerror = () => reject(r.error);
2172
+ r.readAsDataURL(blob);
2173
+ });
2174
+ }
2175
+ async function captureRegionRecording(rect, opts) {
2176
+ const md = navigator.mediaDevices;
2177
+ if (!md?.getDisplayMedia || !window.MediaRecorder) return void 0;
2178
+ let stream;
2179
+ try {
2180
+ stream = await md.getDisplayMedia({ video: { frameRate: 30 }, audio: false, preferCurrentTab: true });
2181
+ } catch {
2182
+ return void 0;
2183
+ }
2184
+ try {
2185
+ return await recordCropped(stream, rect, opts);
2186
+ } catch (err) {
2187
+ console.warn("[loupe] screen recording failed", err);
2188
+ return void 0;
2189
+ } finally {
2190
+ stream.getTracks().forEach((t) => t.stop());
2191
+ }
2192
+ }
2193
+ async function recordCropped(stream, rect, opts) {
2194
+ const maxMs = opts?.maxMs ?? 2e4;
2195
+ const video = document.createElement("video");
2196
+ video.srcObject = stream;
2197
+ video.muted = true;
2198
+ video.playsInline = true;
2199
+ await video.play().catch(() => void 0);
2200
+ const track = stream.getVideoTracks()[0];
2201
+ const s = track?.getSettings?.() ?? {};
2202
+ const vw = s.width || video.videoWidth || window.innerWidth;
2203
+ const vh = s.height || video.videoHeight || window.innerHeight;
2204
+ const sx = vw / window.innerWidth;
2205
+ const sy = vh / window.innerHeight;
2206
+ const cw = Math.max(2, Math.round(rect.w * sx));
2207
+ const ch = Math.max(2, Math.round(rect.h * sy));
2208
+ const canvas = document.createElement("canvas");
2209
+ canvas.width = cw;
2210
+ canvas.height = ch;
2211
+ const ctx = canvas.getContext("2d");
2212
+ let raf = 0;
2213
+ const draw = () => {
2214
+ try {
2215
+ ctx.drawImage(video, rect.x * sx, rect.y * sy, cw, ch, 0, 0, cw, ch);
2216
+ } catch {
2217
+ }
2218
+ raf = requestAnimationFrame(draw);
2219
+ };
2220
+ draw();
2221
+ const out = canvas.captureStream(30);
2222
+ const mime = pickRecordingMime();
2223
+ const rec = new MediaRecorder(out, mime ? { mimeType: mime } : void 0);
2224
+ const chunks = [];
2225
+ rec.ondataavailable = (e) => {
2226
+ if (e.data && e.data.size) chunks.push(e.data);
2227
+ };
2228
+ const stop = () => {
2229
+ if (rec.state !== "inactive") rec.stop();
2230
+ };
2231
+ opts?.register?.(stop);
2232
+ const timer = window.setTimeout(stop, maxMs);
2233
+ track?.addEventListener("ended", stop);
2234
+ const done = new Promise((resolve) => {
2235
+ rec.onstop = () => resolve();
2236
+ });
2237
+ rec.start();
2238
+ await done;
2239
+ window.clearTimeout(timer);
2240
+ cancelAnimationFrame(raf);
2241
+ if (!chunks.length) return void 0;
2242
+ return blobToDataUrl2(new Blob(chunks, { type: mime || "video/webm" }));
2243
+ }
2103
2244
  function cropRegion(dataUrl, rect, ox, oy, scale, redact) {
2104
2245
  return new Promise((resolve, reject) => {
2105
2246
  const img = new Image();
@@ -2200,17 +2341,25 @@ var Loupe = (() => {
2200
2341
  if (!res.ok) throw new Error(`list failed: ${res.status}`);
2201
2342
  return await res.json();
2202
2343
  }
2344
+ /** Upload an inline data-URL asset to object storage; return its URL (or the data URL on failure). */
2345
+ async uploadBlob(projectKey, data) {
2346
+ try {
2347
+ const up = await fetch(`${this.base}/v1/blobs`, this.opts({
2348
+ method: "POST",
2349
+ headers: this.headers(),
2350
+ body: JSON.stringify({ projectKey, data })
2351
+ }));
2352
+ if (up.ok) return (await up.json()).url;
2353
+ } catch {
2354
+ }
2355
+ return data;
2356
+ }
2203
2357
  async save(comment) {
2204
2358
  if (comment.screenshot?.startsWith("data:")) {
2205
- try {
2206
- const up = await fetch(`${this.base}/v1/blobs`, this.opts({
2207
- method: "POST",
2208
- headers: this.headers(),
2209
- body: JSON.stringify({ projectKey: comment.projectKey, data: comment.screenshot })
2210
- }));
2211
- if (up.ok) comment = { ...comment, screenshot: (await up.json()).url };
2212
- } catch {
2213
- }
2359
+ comment = { ...comment, screenshot: await this.uploadBlob(comment.projectKey, comment.screenshot) };
2360
+ }
2361
+ if (comment.recording?.startsWith("data:")) {
2362
+ comment = { ...comment, recording: await this.uploadBlob(comment.projectKey, comment.recording) };
2214
2363
  }
2215
2364
  const res = await fetch(`${this.base}/v1/comments`, this.opts({
2216
2365
  method: "POST",
@@ -2239,6 +2388,7 @@ var Loupe = (() => {
2239
2388
 
2240
2389
  // src/app.ts
2241
2390
  var DOCK_MODES = ["left", "right", "bottom", "float"];
2391
+ var RECORD_MAX_MS = 2e4;
2242
2392
  var uid2 = () => crypto.randomUUID ? crypto.randomUUID() : "c_" + Math.abs(hash(String(performance.now()))).toString(36);
2243
2393
  function hash(s) {
2244
2394
  let h = 0;
@@ -2263,6 +2413,7 @@ var Loupe = (() => {
2263
2413
  this.dockMode = "right";
2264
2414
  this.open = true;
2265
2415
  this.theme = "dark";
2416
+ this.tab = "comments";
2266
2417
  /** Float-mode window geometry; (x<=0 && y<=0) → placed on first layout. */
2267
2418
  this.floatRect = { x: 0, y: 0, w: 380, h: 540 };
2268
2419
  this.floatDrag = null;
@@ -2309,7 +2460,7 @@ var Loupe = (() => {
2309
2460
  };
2310
2461
  // ---- region ("free-size screenshot") selection ----------------------------
2311
2462
  this.onRegionDown = (e) => {
2312
- if (this.mode !== "region" || e.button !== 0) return;
2463
+ if (this.mode !== "region" && this.mode !== "record" || e.button !== 0) return;
2313
2464
  const t = e.target;
2314
2465
  if (t && (t.id === "loupe-root" || t.closest?.("#loupe-root"))) return;
2315
2466
  e.preventDefault();
@@ -2329,6 +2480,7 @@ var Loupe = (() => {
2329
2480
  e.preventDefault();
2330
2481
  e.stopPropagation();
2331
2482
  const start = this.dragStart;
2483
+ const wasRecord = this.mode === "record";
2332
2484
  this.cancelDrag();
2333
2485
  const vp = {
2334
2486
  x: Math.min(start.x, e.clientX),
@@ -2341,7 +2493,8 @@ var Loupe = (() => {
2341
2493
  return;
2342
2494
  }
2343
2495
  this.setMode("off");
2344
- this.finishRegion(vp);
2496
+ if (wasRecord) void this.finishRecording(vp);
2497
+ else this.finishRegion(vp);
2345
2498
  };
2346
2499
  // ---- free note (drop a comment anywhere, no element / no screenshot) -------
2347
2500
  this.onFreeClick = (e) => {
@@ -2532,9 +2685,14 @@ var Loupe = (() => {
2532
2685
  this.shadow.appendChild(this.composer);
2533
2686
  this.dock = this.buildDock();
2534
2687
  this.launcher = this.buildLauncher();
2535
- this.shadow.append(this.dock, this.launcher);
2688
+ this.recBar = this.buildRecBar();
2689
+ this.shadow.append(this.dock, this.launcher, this.recBar);
2536
2690
  }
2537
- /** The dockable control panel: header (brand + dock controls) → tools → list. */
2691
+ /**
2692
+ * The dockable control panel:
2693
+ * header (brand + dock controls) → tabs → [Comments view: tools + list + integrations]
2694
+ * and a [Connect view] with the Claude/MCP onboarding steps.
2695
+ */
2538
2696
  buildDock() {
2539
2697
  const dock = el("div", "dock");
2540
2698
  const head = el("div", "dhead");
@@ -2570,6 +2728,14 @@ var Loupe = (() => {
2570
2728
  closeBtn.onclick = () => this.closeDock();
2571
2729
  ctl.append(this.themeBtn, closeBtn);
2572
2730
  head.append(brand, ctl);
2731
+ const tabs = el("div", "tabs");
2732
+ const commentsTab = el("button", "tab", "Comments");
2733
+ commentsTab.dataset.tab = "comments";
2734
+ commentsTab.onclick = () => this.setTab("comments");
2735
+ const connectTab = el("button", "tab", "Connect Claude");
2736
+ connectTab.dataset.tab = "connect";
2737
+ connectTab.onclick = () => this.setTab("connect");
2738
+ tabs.append(commentsTab, connectTab);
2573
2739
  const tools = el("div", "tools");
2574
2740
  const inspectBtn = this.toolBtn("\u271B", "Inspect", "inspect");
2575
2741
  inspectBtn.title = "Inspect an element and comment on it";
@@ -2580,17 +2746,92 @@ var Loupe = (() => {
2580
2746
  const regionBtn = this.toolBtn(REGION_ICON, "Region", "region");
2581
2747
  regionBtn.title = "Drag a free-size box, screenshot it, and comment";
2582
2748
  regionBtn.onclick = () => this.setMode(this.mode === "region" ? "off" : "region");
2583
- tools.append(inspectBtn, freeBtn, regionBtn);
2749
+ const recordBtn = this.toolBtn(RECORD_ICON, "Record", "record");
2750
+ recordBtn.title = "Drag a box, record a screen video of it, and comment";
2751
+ recordBtn.onclick = () => this.setMode(this.mode === "record" ? "off" : "record");
2752
+ tools.append(inspectBtn, freeBtn, regionBtn, recordBtn);
2584
2753
  const listHead = el("div", "listhead");
2585
2754
  listHead.append(document.createTextNode("Comments"));
2586
2755
  this.countEl = el("span", "count", "0");
2587
2756
  listHead.appendChild(this.countEl);
2588
2757
  this.listEl = el("div", "list");
2758
+ const commentsView = el("div", "view comments-view");
2759
+ commentsView.append(tools, listHead, this.listEl, this.buildIntegrations());
2760
+ const connectView = this.buildConnectPanel();
2589
2761
  const resize = el("div", "resize");
2590
2762
  resize.addEventListener("pointerdown", this.onResizeDown);
2591
- dock.append(head, tools, listHead, this.listEl, resize);
2763
+ dock.append(head, tabs, commentsView, connectView, resize);
2592
2764
  return dock;
2593
2765
  }
2766
+ /** The "INTEGRATES WITH" footer on the Comments page (visual only for now). */
2767
+ buildIntegrations() {
2768
+ const wrap = el("div", "integrations");
2769
+ wrap.append(el("div", "ilabel", "INTEGRATES WITH"));
2770
+ const row = el("div", "irow");
2771
+ const icons = [
2772
+ ["GitHub", I_GITHUB],
2773
+ ["Slack", I_SLACK],
2774
+ ["Telegram", I_TELEGRAM],
2775
+ ["Linear", I_LINEAR]
2776
+ ];
2777
+ for (const [name, icon] of icons) {
2778
+ const b = el("span", "ibtn");
2779
+ b.title = `${name} \u2014 connect (coming soon)`;
2780
+ b.setAttribute("aria-label", name);
2781
+ b.innerHTML = icon;
2782
+ row.appendChild(b);
2783
+ }
2784
+ wrap.appendChild(row);
2785
+ return wrap;
2786
+ }
2787
+ /** The "Connect Claude" onboarding page: how to wire the MCP server to this project. */
2788
+ buildConnectPanel() {
2789
+ const view = el("div", "view connect-view");
2790
+ const key = this.cfg.projectKey;
2791
+ const apiHint = this.cfg.apiBase ?? "http://localhost:8787";
2792
+ const config = JSON.stringify(
2793
+ {
2794
+ mcpServers: {
2795
+ loupe: {
2796
+ command: "npx",
2797
+ args: ["-y", "@loupekit/mcp"],
2798
+ env: { LOUPE_API: apiHint, LOUPE_PROJECT_KEY: key, LOUPE_ADMIN_KEY: "<your project secret>" }
2799
+ }
2800
+ }
2801
+ },
2802
+ null,
2803
+ 2
2804
+ );
2805
+ const steps = [
2806
+ ["Pin your feedback", "Use Inspect, Region, Record, or Note to leave comments right on the live app."],
2807
+ ["Add the Loupe MCP server", "Drop this into your Claude Code config so Claude can read this project's backlog:"],
2808
+ ["Let Claude fix it", "Claude reads each comment (with the screenshot, HTML & CSS), rewrites the UI, and calls propose_change \u2014 the modified HTML/CSS then shows up for your dev team in the dashboard."]
2809
+ ];
2810
+ const hero = el("div", "connect-hero");
2811
+ hero.innerHTML = `<div class="chero-logo">\u25CE</div><div class="chero-title">Hand your feedback to <span class="accentink">Claude</span></div><div class="chero-sub">Every pinned comment becomes an actionable, fully-contextualized task Claude Code can act on.</div>`;
2812
+ view.appendChild(hero);
2813
+ const list = el("ol", "connect-steps");
2814
+ steps.forEach(([t, d], i) => {
2815
+ const li = el("li");
2816
+ li.innerHTML = `<div class="cstep-t">${i + 1}. ${escapeHtml(t)}</div><div class="cstep-d">${escapeHtml(d)}</div>`;
2817
+ if (i === 1) {
2818
+ const pre = el("pre", "cstep-code");
2819
+ pre.textContent = config;
2820
+ li.appendChild(pre);
2821
+ }
2822
+ list.appendChild(li);
2823
+ });
2824
+ view.appendChild(list);
2825
+ return view;
2826
+ }
2827
+ /** The floating "recording…" pill with a Stop button (shown only while recording). */
2828
+ buildRecBar() {
2829
+ const bar = el("button", "recbar");
2830
+ bar.title = "Stop recording";
2831
+ bar.setAttribute("aria-label", "Stop recording");
2832
+ bar.onclick = () => this.stopRecording?.();
2833
+ return bar;
2834
+ }
2594
2835
  buildLauncher() {
2595
2836
  const b = el("button", "launcher");
2596
2837
  b.title = `Open ${this.cfg.label ?? "Loupe"}`;
@@ -2623,8 +2864,12 @@ var Loupe = (() => {
2623
2864
  document.removeEventListener("mousemove", this.onRegionMove, true);
2624
2865
  document.removeEventListener("mouseup", this.onRegionUp, true);
2625
2866
  }
2626
- /** Capture the selected viewport rect, then open the composer for a region comment. */
2627
- async finishRegion(vp) {
2867
+ /**
2868
+ * Anchor a dragged viewport rect to the element under its center so it survives
2869
+ * reflow. `rel` (element-relative fractions) is preferred; document coords are the
2870
+ * fallback. Shared by both the Region (screenshot) and Record (video) tools.
2871
+ */
2872
+ regionFromViewport(vp) {
2628
2873
  const centerEl = this.pick(vp.x + vp.w / 2, vp.y + vp.h / 2);
2629
2874
  let rel;
2630
2875
  if (centerEl) {
@@ -2638,9 +2883,14 @@ var Loupe = (() => {
2638
2883
  };
2639
2884
  }
2640
2885
  }
2641
- this.selbox.style.display = "none";
2642
2886
  const region = { x: vp.x + window.scrollX, y: vp.y + window.scrollY, w: vp.w, h: vp.h, rel };
2643
- const target = { kind: "region", region, element: centerEl };
2887
+ return { region, element: centerEl };
2888
+ }
2889
+ /** Capture the selected viewport rect, then open the composer for a region comment. */
2890
+ async finishRegion(vp) {
2891
+ this.selbox.style.display = "none";
2892
+ const { region, element } = this.regionFromViewport(vp);
2893
+ const target = { kind: "region", region, element };
2644
2894
  this.openComposer(target, vp.x + vp.w, vp.y);
2645
2895
  const capture = this.cfg.captureRegion ?? captureRegionScreenshot;
2646
2896
  this.pendingShot = capture(vp);
@@ -2648,6 +2898,43 @@ var Loupe = (() => {
2648
2898
  if (shot && this.pending === target) target.screenshot = shot;
2649
2899
  }).catch(() => void 0);
2650
2900
  }
2901
+ /**
2902
+ * Record a screen video of the selected rect (same drag-select as Region), then
2903
+ * open the composer with the recording attached. Unlike the screenshot flow this
2904
+ * is interactive — the browser share prompt and a Stop button drive it — so the
2905
+ * composer opens only once recording has finished.
2906
+ */
2907
+ async finishRecording(vp) {
2908
+ this.selbox.style.display = "none";
2909
+ const { region, element } = this.regionFromViewport(vp);
2910
+ const capture = this.cfg.captureRecording ?? captureRegionRecording;
2911
+ this.showRecBar();
2912
+ let recording;
2913
+ try {
2914
+ recording = await capture(vp, {
2915
+ maxMs: RECORD_MAX_MS,
2916
+ register: (stop) => {
2917
+ this.stopRecording = stop;
2918
+ }
2919
+ });
2920
+ } catch {
2921
+ recording = void 0;
2922
+ }
2923
+ this.hideRecBar();
2924
+ if (!recording) return;
2925
+ const target = { kind: "region", region, element, recording };
2926
+ const x = Math.min(vp.x + vp.w, window.innerWidth - 320);
2927
+ this.openComposer(target, x, vp.y);
2928
+ }
2929
+ showRecBar() {
2930
+ this.stopRecording = void 0;
2931
+ this.recBar.innerHTML = `<span class="recdot"></span><span>Recording\u2026 <b>Stop</b></span>`;
2932
+ this.recBar.classList.add("show");
2933
+ }
2934
+ hideRecBar() {
2935
+ this.recBar.classList.remove("show");
2936
+ this.stopRecording = void 0;
2937
+ }
2651
2938
  /** elementFromPoint, ignoring our own UI. */
2652
2939
  pick(x, y) {
2653
2940
  const hitHl = this.hl.style.display;
@@ -2659,8 +2946,12 @@ var Loupe = (() => {
2659
2946
  return elAt;
2660
2947
  }
2661
2948
  setMode(mode) {
2662
- if (mode !== "off" && !this.open) {
2663
- this.open = true;
2949
+ if (mode !== "off") {
2950
+ if (!this.open) this.open = true;
2951
+ if (this.tab !== "comments") {
2952
+ this.tab = "comments";
2953
+ this.saveState();
2954
+ }
2664
2955
  this.applyDockLayout();
2665
2956
  }
2666
2957
  this.mode = mode;
@@ -2671,6 +2962,7 @@ var Loupe = (() => {
2671
2962
  this.dock.querySelector('[data-role="inspect"]')?.classList.toggle("on", mode === "inspect");
2672
2963
  this.dock.querySelector('[data-role="free"]')?.classList.toggle("on", mode === "free");
2673
2964
  this.dock.querySelector('[data-role="region"]')?.classList.toggle("on", mode === "region");
2965
+ this.dock.querySelector('[data-role="record"]')?.classList.toggle("on", mode === "record");
2674
2966
  this.dock.classList.toggle("inspecting", mode !== "off");
2675
2967
  document.removeEventListener("mousemove", this.onMove, true);
2676
2968
  document.removeEventListener("click", this.onClick, true);
@@ -2684,25 +2976,26 @@ var Loupe = (() => {
2684
2976
  document.addEventListener("click", this.onClick, true);
2685
2977
  } else if (mode === "free") {
2686
2978
  document.addEventListener("click", this.onFreeClick, true);
2687
- } else if (mode === "region") {
2979
+ } else if (mode === "region" || mode === "record") {
2688
2980
  document.addEventListener("mousedown", this.onRegionDown, true);
2689
2981
  }
2690
2982
  }
2691
2983
  // ---- composer -------------------------------------------------------------
2692
2984
  openComposer(target, x, y) {
2693
2985
  this.pending = target;
2986
+ const isRecording = target.kind === "region" && !!target.recording;
2694
2987
  const c = this.composer;
2695
2988
  c.innerHTML = "";
2696
2989
  const label = el(
2697
2990
  "div",
2698
2991
  "target",
2699
- 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"
2992
+ target.kind === "element" ? describe(target.element) : target.kind === "region" ? isRecording ? `\u23FA Recording \xB7 ${Math.round(target.region.w)}\xD7${Math.round(target.region.h)} px` : `Region \xB7 ${Math.round(target.region.w)}\xD7${Math.round(target.region.h)} px` : "Free note \xB7 anywhere on the page"
2700
2993
  );
2701
2994
  const ta = el("textarea");
2702
- 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?";
2995
+ ta.placeholder = isRecording ? "What's the issue in this recording?" : target.kind === "region" ? "What's the issue in this area?" : target.kind === "free" ? "Leave a note about this page\u2026" : "What should change here?";
2703
2996
  const row = el("div", "row");
2704
2997
  let box = null;
2705
- if (target.kind !== "free") {
2998
+ if (target.kind !== "free" && !isRecording) {
2706
2999
  const chk = el("label", "chk");
2707
3000
  box = document.createElement("input");
2708
3001
  box.type = "checkbox";
@@ -2744,6 +3037,7 @@ var Loupe = (() => {
2744
3037
  }
2745
3038
  let anchor, context, offset;
2746
3039
  let screenshot;
3040
+ let recording;
2747
3041
  let region;
2748
3042
  let anchoredEl = null;
2749
3043
  if (target.kind === "element") {
@@ -2759,7 +3053,8 @@ var Loupe = (() => {
2759
3053
  context = { html: "", styles: {} };
2760
3054
  offset = target.offset;
2761
3055
  } else {
2762
- screenshot = withShot ? target.screenshot ?? await this.pendingShot : void 0;
3056
+ recording = target.recording;
3057
+ screenshot = !recording && withShot ? target.screenshot ?? await this.pendingShot : void 0;
2763
3058
  region = target.region;
2764
3059
  offset = { x: 0, y: 0 };
2765
3060
  if (target.element) {
@@ -2784,6 +3079,7 @@ var Loupe = (() => {
2784
3079
  offset,
2785
3080
  region,
2786
3081
  screenshot,
3082
+ recording,
2787
3083
  // Record the screen the feedback was captured on (desktop / tablet / mobile).
2788
3084
  viewport: { w: window.innerWidth, h: window.innerHeight },
2789
3085
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -2882,6 +3178,15 @@ var Loupe = (() => {
2882
3178
  this.saveState();
2883
3179
  this.applyDockLayout();
2884
3180
  }
3181
+ /** Switch the sidebar page (Comments ↔ Connect Claude). */
3182
+ setTab(tab) {
3183
+ if (this.tab === tab) return;
3184
+ if (tab !== "comments") this.setMode("off");
3185
+ this.tab = tab;
3186
+ this.saveState();
3187
+ this.applyDockLayout();
3188
+ if (tab === "comments") this.renderList();
3189
+ }
2885
3190
  toggleTheme() {
2886
3191
  this.theme = this.theme === "dark" ? "light" : "dark";
2887
3192
  this.saveState();
@@ -2899,6 +3204,7 @@ var Loupe = (() => {
2899
3204
  if (DOCK_MODES.includes(p?.mode)) this.dockMode = p.mode;
2900
3205
  if (typeof p?.open === "boolean") this.open = p.open;
2901
3206
  if (p?.theme === "light" || p?.theme === "dark") this.theme = p.theme;
3207
+ if (p?.tab === "comments" || p?.tab === "connect") this.tab = p.tab;
2902
3208
  if (p?.float && typeof p.float.w === "number") this.floatRect = { ...this.floatRect, ...p.float };
2903
3209
  } catch {
2904
3210
  }
@@ -2909,6 +3215,7 @@ var Loupe = (() => {
2909
3215
  mode: this.dockMode,
2910
3216
  open: this.open,
2911
3217
  theme: this.theme,
3218
+ tab: this.tab,
2912
3219
  float: this.floatRect
2913
3220
  }));
2914
3221
  } catch {
@@ -2922,6 +3229,9 @@ var Loupe = (() => {
2922
3229
  const d = this.dock;
2923
3230
  d.classList.toggle("open", this.open);
2924
3231
  for (const m of DOCK_MODES) d.classList.toggle("mode-" + m, this.dockMode === m);
3232
+ d.classList.toggle("tab-comments", this.tab === "comments");
3233
+ d.classList.toggle("tab-connect", this.tab === "connect");
3234
+ d.querySelectorAll(".tabs .tab").forEach((b) => b.classList.toggle("on", b.dataset.tab === this.tab));
2925
3235
  if (this.dockMode === "float") {
2926
3236
  const vw = window.innerWidth, vh = window.innerHeight;
2927
3237
  let { x, y, w, h } = this.floatRect;
@@ -2979,8 +3289,8 @@ var Loupe = (() => {
2979
3289
  const item = el("div", "item");
2980
3290
  const top = el("div", "top");
2981
3291
  const num = el("span", "num" + (c.status === "done" ? " done" : detached ? " detached" : ""), String(i + 1));
2982
- const who = el("span", "who", c.author.name);
2983
- top.append(num, who);
3292
+ top.append(num);
3293
+ if (c.recording) top.appendChild(el("span", "rectag", "\u23FA recording"));
2984
3294
  const vw = c.viewport?.w;
2985
3295
  if (vw) {
2986
3296
  const kind = vw < 768 ? "mobile" : vw < 1024 ? "tablet" : "desktop";
@@ -2992,7 +3302,14 @@ var Loupe = (() => {
2992
3302
  item.appendChild(top);
2993
3303
  item.appendChild(el("div", "body", c.body));
2994
3304
  item.appendChild(el("div", "meta", describeAnchor(c)));
2995
- if (c.screenshot) {
3305
+ if (c.recording) {
3306
+ const v = el("video", "shot");
3307
+ v.src = c.recording;
3308
+ v.controls = true;
3309
+ v.playsInline = true;
3310
+ if (c.screenshot) v.poster = c.screenshot;
3311
+ item.appendChild(v);
3312
+ } else if (c.screenshot) {
2996
3313
  const img = el("img", "shot");
2997
3314
  img.src = c.screenshot;
2998
3315
  item.appendChild(img);
@@ -3041,6 +3358,7 @@ var Loupe = (() => {
3041
3358
  `**Page:** ${c.url}`,
3042
3359
  `**Element:** \`${c.anchor.cssPath}\``,
3043
3360
  c.anchor.testid ? `**Stable id:** \`${c.anchor.testid}\`` : ``,
3361
+ c.recording ? `**Screen recording (webm):** ${c.recording}` : ``,
3044
3362
  ``,
3045
3363
  `## Target element`,
3046
3364
  "```html",
@@ -3097,6 +3415,7 @@ var Loupe = (() => {
3097
3415
  if (elx) elx.scrollIntoView({ behavior: "smooth", block: "center" });
3098
3416
  }
3099
3417
  destroy() {
3418
+ this.stopRecording?.();
3100
3419
  this.setMode("off");
3101
3420
  this.mo?.disconnect();
3102
3421
  if (this.tick) clearInterval(this.tick);
@@ -3124,6 +3443,9 @@ var Loupe = (() => {
3124
3443
  function clampPx(n, min, max) {
3125
3444
  return Math.max(min, Math.min(max, n));
3126
3445
  }
3446
+ function escapeHtml(s) {
3447
+ return s.replace(/[&<>"']/g, (m) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[m]);
3448
+ }
3127
3449
  var svg = (inner) => `<svg viewBox="0 0 16 16" width="15" height="15" fill="none" aria-hidden="true">${inner}</svg>`;
3128
3450
  var DOCK_FRAME = `<rect x="1.5" y="2.5" width="13" height="11" rx="1.6" stroke="currentColor" stroke-width="1.3"/>`;
3129
3451
  var I_DOCK_LEFT = svg(`${DOCK_FRAME}<rect x="2.2" y="3.2" width="4" height="9.6" rx="1" fill="currentColor"/>`);
@@ -3143,6 +3465,11 @@ var Loupe = (() => {
3143
3465
  );
3144
3466
  var REGION_ICON = `<svg 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>`;
3145
3467
  var NOTE_ICON = `<svg 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>`;
3468
+ var RECORD_ICON = `<svg 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"/><circle cx="7.5" cy="7.5" r="2.4" fill="currentColor"/></svg>`;
3469
+ var I_GITHUB = `<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 .2a8 8 0 0 0-2.5 15.6c.4.07.55-.17.55-.38v-1.3c-2.2.48-2.67-1.07-2.67-1.07-.36-.92-.88-1.16-.88-1.16-.72-.5.05-.48.05-.48.8.056 1.22.82 1.22.82.71 1.22 1.87.87 2.33.66.07-.52.28-.87.5-1.07-1.76-.2-3.6-.88-3.6-3.9 0-.86.3-1.57.82-2.12-.08-.2-.36-1 .08-2.1 0 0 .67-.21 2.2.8a7.6 7.6 0 0 1 4 0c1.53-1.02 2.2-.8 2.2-.8.44 1.1.16 1.9.08 2.1.5.55.82 1.26.82 2.12 0 3.03-1.85 3.7-3.61 3.9.28.24.54.72.54 1.46v2.16c0 .21.14.46.55.38A8 8 0 0 0 8 .2z"/></svg>`;
3470
+ var I_SLACK = `<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M3.4 10.1a1.6 1.6 0 1 1-1.6-1.6h1.6v1.6zm.8 0a1.6 1.6 0 0 1 3.2 0v4a1.6 1.6 0 1 1-3.2 0v-4zM5.8 3.4a1.6 1.6 0 1 1 1.6-1.6v1.6H5.8zm0 .8a1.6 1.6 0 0 1 0 3.2h-4a1.6 1.6 0 1 1 0-3.2h4zm6.7 1.6a1.6 1.6 0 1 1 1.6 1.6h-1.6V5.8zm-.8 0a1.6 1.6 0 0 1-3.2 0v-4a1.6 1.6 0 1 1 3.2 0v4zm-1.6 6.7a1.6 1.6 0 1 1-1.6 1.6v-1.6h1.6zm0-.8a1.6 1.6 0 0 1 0-3.2h4a1.6 1.6 0 1 1 0 3.2h-4z"/></svg>`;
3471
+ var I_TELEGRAM = `<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zm3.7 5.4-1.24 5.85c-.09.41-.34.51-.69.32l-1.9-1.4-.92.88c-.1.1-.19.19-.38.19l.14-1.93 3.5-3.17c.15-.13-.03-.2-.24-.07l-4.32 2.72-1.86-.58c-.4-.13-.41-.4.09-.6l7.26-2.8c.34-.12.63.08.52.6z"/></svg>`;
3472
+ var I_LINEAR = `<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M.6 9.2a7.4 7.4 0 0 0 6.2 6.2c.3.04.44-.33.22-.55L1.15 9a.33.33 0 0 0-.55.22zM.5 6.9c-.01.13.04.25.13.34l8.13 8.13c.09.09.21.14.34.13a7.4 7.4 0 0 0 1.3-.26c.28-.08.36-.43.15-.63L1.4 5.45c-.2-.2-.55-.13-.63.15-.12.42-.21.86-.26 1.3zM2.05 4c-.1.13-.09.32.03.44l9.48 9.48c.12.12.31.13.44.03.3-.23.58-.48.85-.75.15-.15.15-.4 0-.55L3.35 3.15a.39.39 0 0 0-.55 0c-.27.27-.52.55-.75.85zM4.5 1.9a.35.35 0 0 0-.04.53l9.11 9.11c.16.16.42.13.53-.04A7.4 7.4 0 1 0 4.5 1.9z"/></svg>`;
3146
3473
  function pageAnchor(point) {
3147
3474
  return {
3148
3475
  tag: "page",