@loupekit/sdk 0.6.0 → 0.7.0

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/dist/index.js CHANGED
@@ -204,6 +204,62 @@ var STYLES = (
204
204
  .composer .primary:disabled { opacity: .5; cursor: default; }
205
205
  .composer .ghost { background: var(--bg-3); color: var(--ink); }
206
206
 
207
+ /* ------------------------------------------------------------ sidebar tabs */
208
+ .tabs { display: flex; gap: 4px; flex: none; padding: 8px 10px 0; border-bottom: 1px solid var(--line); }
209
+ .tabs .tab {
210
+ flex: 1; padding: 8px 10px; border: 0; border-bottom: 2px solid transparent;
211
+ background: transparent; color: var(--muted); font-size: 12px; font-weight: 700; cursor: pointer;
212
+ border-radius: 6px 6px 0 0;
213
+ }
214
+ .tabs .tab:hover { color: var(--ink); background: var(--bg-2); }
215
+ .tabs .tab.on { color: var(--accent); border-bottom-color: var(--accent); }
216
+
217
+ /* Two sidebar pages: only the active one shows. */
218
+ .view { display: none; }
219
+ .dock.tab-comments .comments-view { display: flex; flex-direction: column; flex: 1; min-height: 0; }
220
+ .dock.tab-connect .connect-view { display: block; flex: 1; overflow-y: auto; }
221
+
222
+ /* recording marker + video in the list */
223
+ .item .rectag { font-size: 10px; font-weight: 700; color: var(--pin); background: var(--bg-3); border-radius: 999px; padding: 1px 7px; white-space: nowrap; }
224
+ .item video.shot { width: 100%; border-radius: 6px; margin-top: 8px; border: 1px solid var(--line); background: #000; display: block; }
225
+
226
+ /* ------------------------------------------------ "integrates with" footer */
227
+ .integrations { flex: none; padding: 12px 12px 14px; border-top: 1px solid var(--line); text-align: center; }
228
+ .integrations .ilabel { font-size: 10px; letter-spacing: .12em; color: var(--muted); font-weight: 700; margin-bottom: 8px; }
229
+ .integrations .irow { display: flex; align-items: center; justify-content: center; gap: 14px; }
230
+ .integrations .ibtn { color: var(--muted); display: inline-flex; opacity: .8; cursor: default; }
231
+ .integrations .ibtn:hover { color: var(--accent); opacity: 1; }
232
+ .integrations .ibtn svg { display: block; }
233
+
234
+ /* ------------------------------------------------------- Connect Claude page */
235
+ .connect-view { padding: 16px 14px 20px; }
236
+ .connect-hero { text-align: center; margin-bottom: 18px; }
237
+ .connect-hero .chero-logo { font-size: 34px; line-height: 1; color: var(--accent); }
238
+ .connect-hero .chero-title { font-size: 18px; font-weight: 800; letter-spacing: -.02em; margin-top: 8px; }
239
+ .connect-hero .chero-sub { font-size: 12.5px; color: var(--muted); line-height: 1.45; margin-top: 6px; }
240
+ .accentink { color: var(--accent); }
241
+ .connect-steps { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 14px; }
242
+ .connect-steps .cstep-t { font-size: 13px; font-weight: 700; }
243
+ .connect-steps .cstep-d { font-size: 12px; color: var(--muted); line-height: 1.45; margin-top: 3px; }
244
+ .connect-steps .cstep-code {
245
+ margin: 8px 0 0; padding: 10px; background: var(--bg-2); border: 1px solid var(--line);
246
+ border-radius: 8px; font-family: ui-monospace, Menlo, monospace; font-size: 11px; line-height: 1.4;
247
+ color: var(--ink); white-space: pre; overflow-x: auto;
248
+ }
249
+
250
+ /* ------------------------------------------------------- recording indicator */
251
+ .recbar {
252
+ position: fixed; z-index: 2147483005; top: 16px; left: 50%; transform: translateX(-50%);
253
+ display: none; align-items: center; gap: 8px; padding: 8px 14px; border-radius: 999px;
254
+ background: var(--bg); color: var(--ink); border: 1px solid var(--pin);
255
+ box-shadow: var(--shadow); font-size: 12px; font-weight: 600; cursor: pointer;
256
+ }
257
+ .recbar.show { display: inline-flex; }
258
+ .recbar b { color: var(--pin); }
259
+ .recbar .recdot { width: 9px; height: 9px; border-radius: 50%; background: var(--pin); animation: loupe-recpulse 1.1s infinite; }
260
+ @keyframes loupe-recpulse { 0%,100% { opacity: 1; } 50% { opacity: .25; } }
261
+ @media (prefers-reduced-motion: reduce) { .recbar .recdot { animation: none; } }
262
+
207
263
  /* Mobile: left/right/float docking is a desktop affordance. On small screens the
208
264
  panel collapses to a bottom sheet that OVERLAYS the page (pushing a side dock
209
265
  here would squeeze the page to a useless sliver), regardless of the chosen dock
@@ -2073,6 +2129,91 @@ function regionContainer(rect) {
2073
2129
  }
2074
2130
  return document.body;
2075
2131
  }
2132
+ function pickRecordingMime() {
2133
+ const MR = window.MediaRecorder;
2134
+ if (!MR?.isTypeSupported) return "";
2135
+ for (const t of ["video/webm;codecs=vp9", "video/webm;codecs=vp8", "video/webm"]) {
2136
+ if (MR.isTypeSupported(t)) return t;
2137
+ }
2138
+ return "";
2139
+ }
2140
+ function blobToDataUrl2(blob) {
2141
+ return new Promise((resolve, reject) => {
2142
+ const r = new FileReader();
2143
+ r.onload = () => resolve(String(r.result));
2144
+ r.onerror = () => reject(r.error);
2145
+ r.readAsDataURL(blob);
2146
+ });
2147
+ }
2148
+ async function captureRegionRecording(rect, opts) {
2149
+ const md = navigator.mediaDevices;
2150
+ if (!md?.getDisplayMedia || !window.MediaRecorder) return void 0;
2151
+ let stream;
2152
+ try {
2153
+ stream = await md.getDisplayMedia({ video: { frameRate: 30 }, audio: false, preferCurrentTab: true });
2154
+ } catch {
2155
+ return void 0;
2156
+ }
2157
+ try {
2158
+ return await recordCropped(stream, rect, opts);
2159
+ } catch (err) {
2160
+ console.warn("[loupe] screen recording failed", err);
2161
+ return void 0;
2162
+ } finally {
2163
+ stream.getTracks().forEach((t) => t.stop());
2164
+ }
2165
+ }
2166
+ async function recordCropped(stream, rect, opts) {
2167
+ const maxMs = opts?.maxMs ?? 2e4;
2168
+ const video = document.createElement("video");
2169
+ video.srcObject = stream;
2170
+ video.muted = true;
2171
+ video.playsInline = true;
2172
+ await video.play().catch(() => void 0);
2173
+ const track = stream.getVideoTracks()[0];
2174
+ const s = track?.getSettings?.() ?? {};
2175
+ const vw = s.width || video.videoWidth || window.innerWidth;
2176
+ const vh = s.height || video.videoHeight || window.innerHeight;
2177
+ const sx = vw / window.innerWidth;
2178
+ const sy = vh / window.innerHeight;
2179
+ const cw = Math.max(2, Math.round(rect.w * sx));
2180
+ const ch = Math.max(2, Math.round(rect.h * sy));
2181
+ const canvas = document.createElement("canvas");
2182
+ canvas.width = cw;
2183
+ canvas.height = ch;
2184
+ const ctx = canvas.getContext("2d");
2185
+ let raf = 0;
2186
+ const draw = () => {
2187
+ try {
2188
+ ctx.drawImage(video, rect.x * sx, rect.y * sy, cw, ch, 0, 0, cw, ch);
2189
+ } catch {
2190
+ }
2191
+ raf = requestAnimationFrame(draw);
2192
+ };
2193
+ draw();
2194
+ const out = canvas.captureStream(30);
2195
+ const mime = pickRecordingMime();
2196
+ const rec = new MediaRecorder(out, mime ? { mimeType: mime } : void 0);
2197
+ const chunks = [];
2198
+ rec.ondataavailable = (e) => {
2199
+ if (e.data && e.data.size) chunks.push(e.data);
2200
+ };
2201
+ const stop = () => {
2202
+ if (rec.state !== "inactive") rec.stop();
2203
+ };
2204
+ opts?.register?.(stop);
2205
+ const timer = window.setTimeout(stop, maxMs);
2206
+ track?.addEventListener("ended", stop);
2207
+ const done = new Promise((resolve) => {
2208
+ rec.onstop = () => resolve();
2209
+ });
2210
+ rec.start();
2211
+ await done;
2212
+ window.clearTimeout(timer);
2213
+ cancelAnimationFrame(raf);
2214
+ if (!chunks.length) return void 0;
2215
+ return blobToDataUrl2(new Blob(chunks, { type: mime || "video/webm" }));
2216
+ }
2076
2217
  function cropRegion(dataUrl, rect, ox, oy, scale, redact) {
2077
2218
  return new Promise((resolve, reject) => {
2078
2219
  const img = new Image();
@@ -2173,17 +2314,25 @@ var HttpAdapter = class {
2173
2314
  if (!res.ok) throw new Error(`list failed: ${res.status}`);
2174
2315
  return await res.json();
2175
2316
  }
2317
+ /** Upload an inline data-URL asset to object storage; return its URL (or the data URL on failure). */
2318
+ async uploadBlob(projectKey, data) {
2319
+ try {
2320
+ const up = await fetch(`${this.base}/v1/blobs`, this.opts({
2321
+ method: "POST",
2322
+ headers: this.headers(),
2323
+ body: JSON.stringify({ projectKey, data })
2324
+ }));
2325
+ if (up.ok) return (await up.json()).url;
2326
+ } catch {
2327
+ }
2328
+ return data;
2329
+ }
2176
2330
  async save(comment) {
2177
2331
  if (comment.screenshot?.startsWith("data:")) {
2178
- try {
2179
- const up = await fetch(`${this.base}/v1/blobs`, this.opts({
2180
- method: "POST",
2181
- headers: this.headers(),
2182
- body: JSON.stringify({ projectKey: comment.projectKey, data: comment.screenshot })
2183
- }));
2184
- if (up.ok) comment = { ...comment, screenshot: (await up.json()).url };
2185
- } catch {
2186
- }
2332
+ comment = { ...comment, screenshot: await this.uploadBlob(comment.projectKey, comment.screenshot) };
2333
+ }
2334
+ if (comment.recording?.startsWith("data:")) {
2335
+ comment = { ...comment, recording: await this.uploadBlob(comment.projectKey, comment.recording) };
2187
2336
  }
2188
2337
  const res = await fetch(`${this.base}/v1/comments`, this.opts({
2189
2338
  method: "POST",
@@ -2212,6 +2361,7 @@ var HttpAdapter = class {
2212
2361
 
2213
2362
  // src/app.ts
2214
2363
  var DOCK_MODES = ["left", "right", "bottom", "float"];
2364
+ var RECORD_MAX_MS = 2e4;
2215
2365
  var uid2 = () => crypto.randomUUID ? crypto.randomUUID() : "c_" + Math.abs(hash(String(performance.now()))).toString(36);
2216
2366
  function hash(s) {
2217
2367
  let h = 0;
@@ -2236,6 +2386,7 @@ var LoupeApp = class {
2236
2386
  this.dockMode = "right";
2237
2387
  this.open = true;
2238
2388
  this.theme = "dark";
2389
+ this.tab = "comments";
2239
2390
  /** Float-mode window geometry; (x<=0 && y<=0) → placed on first layout. */
2240
2391
  this.floatRect = { x: 0, y: 0, w: 380, h: 540 };
2241
2392
  this.floatDrag = null;
@@ -2282,7 +2433,7 @@ var LoupeApp = class {
2282
2433
  };
2283
2434
  // ---- region ("free-size screenshot") selection ----------------------------
2284
2435
  this.onRegionDown = (e) => {
2285
- if (this.mode !== "region" || e.button !== 0) return;
2436
+ if (this.mode !== "region" && this.mode !== "record" || e.button !== 0) return;
2286
2437
  const t = e.target;
2287
2438
  if (t && (t.id === "loupe-root" || t.closest?.("#loupe-root"))) return;
2288
2439
  e.preventDefault();
@@ -2302,6 +2453,7 @@ var LoupeApp = class {
2302
2453
  e.preventDefault();
2303
2454
  e.stopPropagation();
2304
2455
  const start = this.dragStart;
2456
+ const wasRecord = this.mode === "record";
2305
2457
  this.cancelDrag();
2306
2458
  const vp = {
2307
2459
  x: Math.min(start.x, e.clientX),
@@ -2314,7 +2466,8 @@ var LoupeApp = class {
2314
2466
  return;
2315
2467
  }
2316
2468
  this.setMode("off");
2317
- this.finishRegion(vp);
2469
+ if (wasRecord) void this.finishRecording(vp);
2470
+ else this.finishRegion(vp);
2318
2471
  };
2319
2472
  // ---- free note (drop a comment anywhere, no element / no screenshot) -------
2320
2473
  this.onFreeClick = (e) => {
@@ -2505,9 +2658,14 @@ var LoupeApp = class {
2505
2658
  this.shadow.appendChild(this.composer);
2506
2659
  this.dock = this.buildDock();
2507
2660
  this.launcher = this.buildLauncher();
2508
- this.shadow.append(this.dock, this.launcher);
2661
+ this.recBar = this.buildRecBar();
2662
+ this.shadow.append(this.dock, this.launcher, this.recBar);
2509
2663
  }
2510
- /** The dockable control panel: header (brand + dock controls) → tools → list. */
2664
+ /**
2665
+ * The dockable control panel:
2666
+ * header (brand + dock controls) → tabs → [Comments view: tools + list + integrations]
2667
+ * and a [Connect view] with the Claude/MCP onboarding steps.
2668
+ */
2511
2669
  buildDock() {
2512
2670
  const dock = el("div", "dock");
2513
2671
  const head = el("div", "dhead");
@@ -2543,6 +2701,14 @@ var LoupeApp = class {
2543
2701
  closeBtn.onclick = () => this.closeDock();
2544
2702
  ctl.append(this.themeBtn, closeBtn);
2545
2703
  head.append(brand, ctl);
2704
+ const tabs = el("div", "tabs");
2705
+ const commentsTab = el("button", "tab", "Comments");
2706
+ commentsTab.dataset.tab = "comments";
2707
+ commentsTab.onclick = () => this.setTab("comments");
2708
+ const connectTab = el("button", "tab", "Connect Claude");
2709
+ connectTab.dataset.tab = "connect";
2710
+ connectTab.onclick = () => this.setTab("connect");
2711
+ tabs.append(commentsTab, connectTab);
2546
2712
  const tools = el("div", "tools");
2547
2713
  const inspectBtn = this.toolBtn("\u271B", "Inspect", "inspect");
2548
2714
  inspectBtn.title = "Inspect an element and comment on it";
@@ -2553,17 +2719,92 @@ var LoupeApp = class {
2553
2719
  const regionBtn = this.toolBtn(REGION_ICON, "Region", "region");
2554
2720
  regionBtn.title = "Drag a free-size box, screenshot it, and comment";
2555
2721
  regionBtn.onclick = () => this.setMode(this.mode === "region" ? "off" : "region");
2556
- tools.append(inspectBtn, freeBtn, regionBtn);
2722
+ const recordBtn = this.toolBtn(RECORD_ICON, "Record", "record");
2723
+ recordBtn.title = "Drag a box, record a screen video of it, and comment";
2724
+ recordBtn.onclick = () => this.setMode(this.mode === "record" ? "off" : "record");
2725
+ tools.append(inspectBtn, freeBtn, regionBtn, recordBtn);
2557
2726
  const listHead = el("div", "listhead");
2558
2727
  listHead.append(document.createTextNode("Comments"));
2559
2728
  this.countEl = el("span", "count", "0");
2560
2729
  listHead.appendChild(this.countEl);
2561
2730
  this.listEl = el("div", "list");
2731
+ const commentsView = el("div", "view comments-view");
2732
+ commentsView.append(tools, listHead, this.listEl, this.buildIntegrations());
2733
+ const connectView = this.buildConnectPanel();
2562
2734
  const resize = el("div", "resize");
2563
2735
  resize.addEventListener("pointerdown", this.onResizeDown);
2564
- dock.append(head, tools, listHead, this.listEl, resize);
2736
+ dock.append(head, tabs, commentsView, connectView, resize);
2565
2737
  return dock;
2566
2738
  }
2739
+ /** The "INTEGRATES WITH" footer on the Comments page (visual only for now). */
2740
+ buildIntegrations() {
2741
+ const wrap = el("div", "integrations");
2742
+ wrap.append(el("div", "ilabel", "INTEGRATES WITH"));
2743
+ const row = el("div", "irow");
2744
+ const icons = [
2745
+ ["GitHub", I_GITHUB],
2746
+ ["Slack", I_SLACK],
2747
+ ["Telegram", I_TELEGRAM],
2748
+ ["Linear", I_LINEAR]
2749
+ ];
2750
+ for (const [name, icon] of icons) {
2751
+ const b = el("span", "ibtn");
2752
+ b.title = `${name} \u2014 connect (coming soon)`;
2753
+ b.setAttribute("aria-label", name);
2754
+ b.innerHTML = icon;
2755
+ row.appendChild(b);
2756
+ }
2757
+ wrap.appendChild(row);
2758
+ return wrap;
2759
+ }
2760
+ /** The "Connect Claude" onboarding page: how to wire the MCP server to this project. */
2761
+ buildConnectPanel() {
2762
+ const view = el("div", "view connect-view");
2763
+ const key = this.cfg.projectKey;
2764
+ const apiHint = this.cfg.apiBase ?? "http://localhost:8787";
2765
+ const config = JSON.stringify(
2766
+ {
2767
+ mcpServers: {
2768
+ loupe: {
2769
+ command: "npx",
2770
+ args: ["-y", "@loupekit/mcp"],
2771
+ env: { LOUPE_API: apiHint, LOUPE_PROJECT_KEY: key, LOUPE_ADMIN_KEY: "<your project secret>" }
2772
+ }
2773
+ }
2774
+ },
2775
+ null,
2776
+ 2
2777
+ );
2778
+ const steps = [
2779
+ ["Pin your feedback", "Use Inspect, Region, Record, or Note to leave comments right on the live app."],
2780
+ ["Add the Loupe MCP server", "Drop this into your Claude Code config so Claude can read this project's backlog:"],
2781
+ ["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."]
2782
+ ];
2783
+ const hero = el("div", "connect-hero");
2784
+ 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>`;
2785
+ view.appendChild(hero);
2786
+ const list = el("ol", "connect-steps");
2787
+ steps.forEach(([t, d], i) => {
2788
+ const li = el("li");
2789
+ li.innerHTML = `<div class="cstep-t">${i + 1}. ${escapeHtml(t)}</div><div class="cstep-d">${escapeHtml(d)}</div>`;
2790
+ if (i === 1) {
2791
+ const pre = el("pre", "cstep-code");
2792
+ pre.textContent = config;
2793
+ li.appendChild(pre);
2794
+ }
2795
+ list.appendChild(li);
2796
+ });
2797
+ view.appendChild(list);
2798
+ return view;
2799
+ }
2800
+ /** The floating "recording…" pill with a Stop button (shown only while recording). */
2801
+ buildRecBar() {
2802
+ const bar = el("button", "recbar");
2803
+ bar.title = "Stop recording";
2804
+ bar.setAttribute("aria-label", "Stop recording");
2805
+ bar.onclick = () => this.stopRecording?.();
2806
+ return bar;
2807
+ }
2567
2808
  buildLauncher() {
2568
2809
  const b = el("button", "launcher");
2569
2810
  b.title = `Open ${this.cfg.label ?? "Loupe"}`;
@@ -2596,8 +2837,12 @@ var LoupeApp = class {
2596
2837
  document.removeEventListener("mousemove", this.onRegionMove, true);
2597
2838
  document.removeEventListener("mouseup", this.onRegionUp, true);
2598
2839
  }
2599
- /** Capture the selected viewport rect, then open the composer for a region comment. */
2600
- async finishRegion(vp) {
2840
+ /**
2841
+ * Anchor a dragged viewport rect to the element under its center so it survives
2842
+ * reflow. `rel` (element-relative fractions) is preferred; document coords are the
2843
+ * fallback. Shared by both the Region (screenshot) and Record (video) tools.
2844
+ */
2845
+ regionFromViewport(vp) {
2601
2846
  const centerEl = this.pick(vp.x + vp.w / 2, vp.y + vp.h / 2);
2602
2847
  let rel;
2603
2848
  if (centerEl) {
@@ -2611,9 +2856,14 @@ var LoupeApp = class {
2611
2856
  };
2612
2857
  }
2613
2858
  }
2614
- this.selbox.style.display = "none";
2615
2859
  const region = { x: vp.x + window.scrollX, y: vp.y + window.scrollY, w: vp.w, h: vp.h, rel };
2616
- const target = { kind: "region", region, element: centerEl };
2860
+ return { region, element: centerEl };
2861
+ }
2862
+ /** Capture the selected viewport rect, then open the composer for a region comment. */
2863
+ async finishRegion(vp) {
2864
+ this.selbox.style.display = "none";
2865
+ const { region, element } = this.regionFromViewport(vp);
2866
+ const target = { kind: "region", region, element };
2617
2867
  this.openComposer(target, vp.x + vp.w, vp.y);
2618
2868
  const capture = this.cfg.captureRegion ?? captureRegionScreenshot;
2619
2869
  this.pendingShot = capture(vp);
@@ -2621,6 +2871,43 @@ var LoupeApp = class {
2621
2871
  if (shot && this.pending === target) target.screenshot = shot;
2622
2872
  }).catch(() => void 0);
2623
2873
  }
2874
+ /**
2875
+ * Record a screen video of the selected rect (same drag-select as Region), then
2876
+ * open the composer with the recording attached. Unlike the screenshot flow this
2877
+ * is interactive — the browser share prompt and a Stop button drive it — so the
2878
+ * composer opens only once recording has finished.
2879
+ */
2880
+ async finishRecording(vp) {
2881
+ this.selbox.style.display = "none";
2882
+ const { region, element } = this.regionFromViewport(vp);
2883
+ const capture = this.cfg.captureRecording ?? captureRegionRecording;
2884
+ this.showRecBar();
2885
+ let recording;
2886
+ try {
2887
+ recording = await capture(vp, {
2888
+ maxMs: RECORD_MAX_MS,
2889
+ register: (stop) => {
2890
+ this.stopRecording = stop;
2891
+ }
2892
+ });
2893
+ } catch {
2894
+ recording = void 0;
2895
+ }
2896
+ this.hideRecBar();
2897
+ if (!recording) return;
2898
+ const target = { kind: "region", region, element, recording };
2899
+ const x = Math.min(vp.x + vp.w, window.innerWidth - 320);
2900
+ this.openComposer(target, x, vp.y);
2901
+ }
2902
+ showRecBar() {
2903
+ this.stopRecording = void 0;
2904
+ this.recBar.innerHTML = `<span class="recdot"></span><span>Recording\u2026 <b>Stop</b></span>`;
2905
+ this.recBar.classList.add("show");
2906
+ }
2907
+ hideRecBar() {
2908
+ this.recBar.classList.remove("show");
2909
+ this.stopRecording = void 0;
2910
+ }
2624
2911
  /** elementFromPoint, ignoring our own UI. */
2625
2912
  pick(x, y) {
2626
2913
  const hitHl = this.hl.style.display;
@@ -2632,8 +2919,12 @@ var LoupeApp = class {
2632
2919
  return elAt;
2633
2920
  }
2634
2921
  setMode(mode) {
2635
- if (mode !== "off" && !this.open) {
2636
- this.open = true;
2922
+ if (mode !== "off") {
2923
+ if (!this.open) this.open = true;
2924
+ if (this.tab !== "comments") {
2925
+ this.tab = "comments";
2926
+ this.saveState();
2927
+ }
2637
2928
  this.applyDockLayout();
2638
2929
  }
2639
2930
  this.mode = mode;
@@ -2644,6 +2935,7 @@ var LoupeApp = class {
2644
2935
  this.dock.querySelector('[data-role="inspect"]')?.classList.toggle("on", mode === "inspect");
2645
2936
  this.dock.querySelector('[data-role="free"]')?.classList.toggle("on", mode === "free");
2646
2937
  this.dock.querySelector('[data-role="region"]')?.classList.toggle("on", mode === "region");
2938
+ this.dock.querySelector('[data-role="record"]')?.classList.toggle("on", mode === "record");
2647
2939
  this.dock.classList.toggle("inspecting", mode !== "off");
2648
2940
  document.removeEventListener("mousemove", this.onMove, true);
2649
2941
  document.removeEventListener("click", this.onClick, true);
@@ -2657,25 +2949,26 @@ var LoupeApp = class {
2657
2949
  document.addEventListener("click", this.onClick, true);
2658
2950
  } else if (mode === "free") {
2659
2951
  document.addEventListener("click", this.onFreeClick, true);
2660
- } else if (mode === "region") {
2952
+ } else if (mode === "region" || mode === "record") {
2661
2953
  document.addEventListener("mousedown", this.onRegionDown, true);
2662
2954
  }
2663
2955
  }
2664
2956
  // ---- composer -------------------------------------------------------------
2665
2957
  openComposer(target, x, y) {
2666
2958
  this.pending = target;
2959
+ const isRecording = target.kind === "region" && !!target.recording;
2667
2960
  const c = this.composer;
2668
2961
  c.innerHTML = "";
2669
2962
  const label = el(
2670
2963
  "div",
2671
2964
  "target",
2672
- 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"
2965
+ 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"
2673
2966
  );
2674
2967
  const ta = el("textarea");
2675
- 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?";
2968
+ 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?";
2676
2969
  const row = el("div", "row");
2677
2970
  let box = null;
2678
- if (target.kind !== "free") {
2971
+ if (target.kind !== "free" && !isRecording) {
2679
2972
  const chk = el("label", "chk");
2680
2973
  box = document.createElement("input");
2681
2974
  box.type = "checkbox";
@@ -2717,6 +3010,7 @@ var LoupeApp = class {
2717
3010
  }
2718
3011
  let anchor, context, offset;
2719
3012
  let screenshot;
3013
+ let recording;
2720
3014
  let region;
2721
3015
  let anchoredEl = null;
2722
3016
  if (target.kind === "element") {
@@ -2732,7 +3026,8 @@ var LoupeApp = class {
2732
3026
  context = { html: "", styles: {} };
2733
3027
  offset = target.offset;
2734
3028
  } else {
2735
- screenshot = withShot ? target.screenshot ?? await this.pendingShot : void 0;
3029
+ recording = target.recording;
3030
+ screenshot = !recording && withShot ? target.screenshot ?? await this.pendingShot : void 0;
2736
3031
  region = target.region;
2737
3032
  offset = { x: 0, y: 0 };
2738
3033
  if (target.element) {
@@ -2757,6 +3052,7 @@ var LoupeApp = class {
2757
3052
  offset,
2758
3053
  region,
2759
3054
  screenshot,
3055
+ recording,
2760
3056
  // Record the screen the feedback was captured on (desktop / tablet / mobile).
2761
3057
  viewport: { w: window.innerWidth, h: window.innerHeight },
2762
3058
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -2855,6 +3151,15 @@ var LoupeApp = class {
2855
3151
  this.saveState();
2856
3152
  this.applyDockLayout();
2857
3153
  }
3154
+ /** Switch the sidebar page (Comments ↔ Connect Claude). */
3155
+ setTab(tab) {
3156
+ if (this.tab === tab) return;
3157
+ if (tab !== "comments") this.setMode("off");
3158
+ this.tab = tab;
3159
+ this.saveState();
3160
+ this.applyDockLayout();
3161
+ if (tab === "comments") this.renderList();
3162
+ }
2858
3163
  toggleTheme() {
2859
3164
  this.theme = this.theme === "dark" ? "light" : "dark";
2860
3165
  this.saveState();
@@ -2872,6 +3177,7 @@ var LoupeApp = class {
2872
3177
  if (DOCK_MODES.includes(p?.mode)) this.dockMode = p.mode;
2873
3178
  if (typeof p?.open === "boolean") this.open = p.open;
2874
3179
  if (p?.theme === "light" || p?.theme === "dark") this.theme = p.theme;
3180
+ if (p?.tab === "comments" || p?.tab === "connect") this.tab = p.tab;
2875
3181
  if (p?.float && typeof p.float.w === "number") this.floatRect = { ...this.floatRect, ...p.float };
2876
3182
  } catch {
2877
3183
  }
@@ -2882,6 +3188,7 @@ var LoupeApp = class {
2882
3188
  mode: this.dockMode,
2883
3189
  open: this.open,
2884
3190
  theme: this.theme,
3191
+ tab: this.tab,
2885
3192
  float: this.floatRect
2886
3193
  }));
2887
3194
  } catch {
@@ -2895,6 +3202,9 @@ var LoupeApp = class {
2895
3202
  const d = this.dock;
2896
3203
  d.classList.toggle("open", this.open);
2897
3204
  for (const m of DOCK_MODES) d.classList.toggle("mode-" + m, this.dockMode === m);
3205
+ d.classList.toggle("tab-comments", this.tab === "comments");
3206
+ d.classList.toggle("tab-connect", this.tab === "connect");
3207
+ d.querySelectorAll(".tabs .tab").forEach((b) => b.classList.toggle("on", b.dataset.tab === this.tab));
2898
3208
  if (this.dockMode === "float") {
2899
3209
  const vw = window.innerWidth, vh = window.innerHeight;
2900
3210
  let { x, y, w, h } = this.floatRect;
@@ -2952,8 +3262,8 @@ var LoupeApp = class {
2952
3262
  const item = el("div", "item");
2953
3263
  const top = el("div", "top");
2954
3264
  const num = el("span", "num" + (c.status === "done" ? " done" : detached ? " detached" : ""), String(i + 1));
2955
- const who = el("span", "who", c.author.name);
2956
- top.append(num, who);
3265
+ top.append(num);
3266
+ if (c.recording) top.appendChild(el("span", "rectag", "\u23FA recording"));
2957
3267
  const vw = c.viewport?.w;
2958
3268
  if (vw) {
2959
3269
  const kind = vw < 768 ? "mobile" : vw < 1024 ? "tablet" : "desktop";
@@ -2965,7 +3275,14 @@ var LoupeApp = class {
2965
3275
  item.appendChild(top);
2966
3276
  item.appendChild(el("div", "body", c.body));
2967
3277
  item.appendChild(el("div", "meta", describeAnchor(c)));
2968
- if (c.screenshot) {
3278
+ if (c.recording) {
3279
+ const v = el("video", "shot");
3280
+ v.src = c.recording;
3281
+ v.controls = true;
3282
+ v.playsInline = true;
3283
+ if (c.screenshot) v.poster = c.screenshot;
3284
+ item.appendChild(v);
3285
+ } else if (c.screenshot) {
2969
3286
  const img = el("img", "shot");
2970
3287
  img.src = c.screenshot;
2971
3288
  item.appendChild(img);
@@ -3014,6 +3331,7 @@ var LoupeApp = class {
3014
3331
  `**Page:** ${c.url}`,
3015
3332
  `**Element:** \`${c.anchor.cssPath}\``,
3016
3333
  c.anchor.testid ? `**Stable id:** \`${c.anchor.testid}\`` : ``,
3334
+ c.recording ? `**Screen recording (webm):** ${c.recording}` : ``,
3017
3335
  ``,
3018
3336
  `## Target element`,
3019
3337
  "```html",
@@ -3070,6 +3388,7 @@ var LoupeApp = class {
3070
3388
  if (elx) elx.scrollIntoView({ behavior: "smooth", block: "center" });
3071
3389
  }
3072
3390
  destroy() {
3391
+ this.stopRecording?.();
3073
3392
  this.setMode("off");
3074
3393
  this.mo?.disconnect();
3075
3394
  if (this.tick) clearInterval(this.tick);
@@ -3097,6 +3416,9 @@ function clamp(n) {
3097
3416
  function clampPx(n, min, max) {
3098
3417
  return Math.max(min, Math.min(max, n));
3099
3418
  }
3419
+ function escapeHtml(s) {
3420
+ return s.replace(/[&<>"']/g, (m) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[m]);
3421
+ }
3100
3422
  var svg = (inner) => `<svg viewBox="0 0 16 16" width="15" height="15" fill="none" aria-hidden="true">${inner}</svg>`;
3101
3423
  var DOCK_FRAME = `<rect x="1.5" y="2.5" width="13" height="11" rx="1.6" stroke="currentColor" stroke-width="1.3"/>`;
3102
3424
  var I_DOCK_LEFT = svg(`${DOCK_FRAME}<rect x="2.2" y="3.2" width="4" height="9.6" rx="1" fill="currentColor"/>`);
@@ -3116,6 +3438,11 @@ var I_CLOSE = svg(
3116
3438
  );
3117
3439
  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>`;
3118
3440
  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>`;
3441
+ 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>`;
3442
+ 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>`;
3443
+ 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>`;
3444
+ 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>`;
3445
+ 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>`;
3119
3446
  function pageAnchor(point) {
3120
3447
  return {
3121
3448
  tag: "page",