@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 +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/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
|
|
93
|
-
**Region**, and
|
|
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
|
|
package/dist/index.global.js
CHANGED
|
@@ -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
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
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.
|
|
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.
|
|
2688
|
+
this.recBar = this.buildRecBar();
|
|
2689
|
+
this.shadow.append(this.dock, this.launcher, this.recBar);
|
|
2536
2690
|
}
|
|
2537
|
-
/**
|
|
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
|
-
|
|
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,
|
|
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
|
-
/**
|
|
2627
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
2983
|
-
top.
|
|
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.
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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",
|