@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/README.md +5 -2
- package/dist/index.global.js +356 -29
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +356 -29
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
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.
|
|
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.
|
|
2661
|
+
this.recBar = this.buildRecBar();
|
|
2662
|
+
this.shadow.append(this.dock, this.launcher, this.recBar);
|
|
2509
2663
|
}
|
|
2510
|
-
/**
|
|
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
|
-
|
|
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,
|
|
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
|
-
/**
|
|
2600
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
2956
|
-
top.
|
|
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.
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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",
|