@loupekit/sdk 0.8.1 → 0.9.0-next.30

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.
@@ -198,6 +198,16 @@ var Loupe = (() => {
198
198
  .item .actions button { font-size: 11px; border: 1px solid var(--line); background: var(--bg-3); border-radius: 6px; padding: 4px 8px; cursor: pointer; color: var(--ink); }
199
199
  .item .actions button:hover { border-color: var(--accent); }
200
200
  .item img.shot { width: 100%; border-radius: 6px; margin-top: 8px; border: 1px solid var(--line); }
201
+ .item video.shot { width: 100%; border-radius: 6px; margin-top: 8px; border: 1px solid var(--line); }
202
+ .item .caret { margin-left: auto; color: var(--muted); font-size: 11px; }
203
+ .item .summary { font-size: 13px; font-weight: 600; line-height: 1.35; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
204
+ .item .detail { margin-top: 6px; }
205
+ .item.collapsed .detail { display: none; }
206
+ .listhead .search {
207
+ margin-left: auto; flex: 1; max-width: 170px; font-size: 12px; text-transform: none; letter-spacing: 0;
208
+ padding: 4px 8px; border: 1px solid var(--line); border-radius: 7px; background: var(--bg-2); color: var(--ink); outline: none;
209
+ }
210
+ .listhead .search:focus { border-color: var(--accent); }
201
211
 
202
212
  /* float resize grip (bottom-right corner) */
203
213
  .resize { display: none; position: absolute; right: 0; bottom: 0; width: 16px; height: 16px; cursor: nwse-resize; z-index: 1; }
@@ -209,7 +219,7 @@ var Loupe = (() => {
209
219
 
210
220
  /* composer popover */
211
221
  .composer {
212
- position: fixed; z-index: 2147483004; pointer-events: auto; width: 300px;
222
+ position: fixed; z-index: 2147483004; pointer-events: auto; width: 320px;
213
223
  background: var(--bg); color: var(--ink); border: 1px solid var(--line);
214
224
  border-radius: 12px; box-shadow: var(--shadow); padding: 12px; display: none;
215
225
  }
@@ -230,6 +240,25 @@ var Loupe = (() => {
230
240
  .composer .primary { background: var(--accent); color: #fff; }
231
241
  .composer .primary:disabled { opacity: .5; cursor: default; }
232
242
  .composer .ghost { background: var(--bg-3); color: var(--ink); }
243
+ .composer input.title {
244
+ width: 100%; border: 1px solid var(--line); border-radius: 8px; padding: 8px; margin-bottom: 8px;
245
+ font-size: 13px; font-weight: 600; color: var(--ink); background: var(--bg-2); outline: none;
246
+ }
247
+ .composer input.title:focus { border-color: var(--accent); }
248
+ .composer .attach { margin-top: 8px; }
249
+ .composer .pick {
250
+ width: 100%; border: 1px dashed var(--line); background: transparent; color: var(--muted);
251
+ border-radius: 8px; padding: 7px; font-size: 12px; font-weight: 600; cursor: pointer;
252
+ }
253
+ .composer .pick:hover { border-color: var(--accent); color: var(--accent); }
254
+ .composer .chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
255
+ .composer .chip {
256
+ display: inline-flex; align-items: center; gap: 4px; max-width: 100%;
257
+ background: var(--bg-3); border-radius: 6px; padding: 2px 4px 2px 7px; font-size: 11px; color: var(--ink);
258
+ }
259
+ .composer .chip .x { border: 0; background: transparent; color: var(--muted); cursor: pointer; font-size: 13px; line-height: 1; padding: 0 2px; }
260
+ .composer .err { color: var(--pin); font-size: 11px; margin-top: 4px; }
261
+ .composer .err:empty { display: none; }
233
262
 
234
263
  /* ------------------------------------------------------------ sidebar tabs */
235
264
  .tabs { display: flex; gap: 4px; flex: none; padding: 8px 10px 0; border-bottom: 1px solid var(--line); }
@@ -2172,6 +2201,12 @@ var Loupe = (() => {
2172
2201
  r.readAsDataURL(blob);
2173
2202
  });
2174
2203
  }
2204
+ function fileToDataUrl(file) {
2205
+ return blobToDataUrl2(file);
2206
+ }
2207
+ function attachmentKind(mime) {
2208
+ return (mime ?? "").startsWith("video/") ? "video" : "image";
2209
+ }
2175
2210
  async function captureRegionRecording(rect, opts) {
2176
2211
  const md = navigator.mediaDevices;
2177
2212
  if (!md?.getDisplayMedia || !window.MediaRecorder) return void 0;
@@ -2288,6 +2323,20 @@ var Loupe = (() => {
2288
2323
  this.writeAll(comment.projectKey, comment.url, all);
2289
2324
  return comment;
2290
2325
  }
2326
+ /**
2327
+ * Offline mode: keep the file inline. localStorage is only a few MB, so refuse
2328
+ * anything that would blow the quota rather than silently dropping it.
2329
+ */
2330
+ async upload(_projectKey, file) {
2331
+ if (file.size > 3e6) throw new Error("attachment too large for offline mode");
2332
+ return {
2333
+ url: await fileToDataUrl(file),
2334
+ name: file.name,
2335
+ mime: file.type || void 0,
2336
+ kind: attachmentKind(file.type),
2337
+ size: file.size
2338
+ };
2339
+ }
2291
2340
  async update(id, patch) {
2292
2341
  for (let i = 0; i < localStorage.length; i++) {
2293
2342
  const k = localStorage.key(i);
@@ -2369,6 +2418,17 @@ var Loupe = (() => {
2369
2418
  if (!res.ok) throw new Error(`save failed: ${res.status}`);
2370
2419
  return await res.json();
2371
2420
  }
2421
+ /** Persist one reporter-attached file to object storage and describe it. */
2422
+ async upload(projectKey, file) {
2423
+ const data = await fileToDataUrl(file);
2424
+ return {
2425
+ url: await this.uploadBlob(projectKey, data),
2426
+ name: file.name,
2427
+ mime: file.type || void 0,
2428
+ kind: attachmentKind(file.type),
2429
+ size: file.size
2430
+ };
2431
+ }
2372
2432
  async update(id, patch) {
2373
2433
  const res = await fetch(`${this.base}/v1/comments/${encodeURIComponent(id)}`, this.opts({
2374
2434
  method: "PATCH",
@@ -2389,6 +2449,9 @@ var Loupe = (() => {
2389
2449
  // src/app.ts
2390
2450
  var DOCK_MODES = ["left", "right", "bottom", "float"];
2391
2451
  var RECORD_MAX_MS = 2e4;
2452
+ var MAX_FILES = 10;
2453
+ var MAX_IMAGE_BYTES = 10 * 1024 * 1024;
2454
+ var MAX_VIDEO_BYTES = 25 * 1024 * 1024;
2392
2455
  var uid2 = () => crypto.randomUUID ? crypto.randomUUID() : "c_" + Math.abs(hash(String(performance.now()))).toString(36);
2393
2456
  function hash(s) {
2394
2457
  let h = 0;
@@ -2398,6 +2461,10 @@ var Loupe = (() => {
2398
2461
  var LoupeApp = class {
2399
2462
  constructor(cfg) {
2400
2463
  this.comments = [];
2464
+ /** Free-text filter over the list (title / body / author). */
2465
+ this.search = "";
2466
+ /** Ids of list items the user expanded — items are collapsed by default. */
2467
+ this.expanded = /* @__PURE__ */ new Set();
2401
2468
  /** comment.id → currently resolved element (or null when detached). */
2402
2469
  this.resolved = /* @__PURE__ */ new Map();
2403
2470
  /** comment.id → pin element. */
@@ -2754,6 +2821,15 @@ var Loupe = (() => {
2754
2821
  listHead.append(document.createTextNode("Comments"));
2755
2822
  this.countEl = el("span", "count", "0");
2756
2823
  listHead.appendChild(this.countEl);
2824
+ const search = el("input", "search");
2825
+ search.type = "search";
2826
+ search.placeholder = "Search\u2026";
2827
+ search.value = this.search;
2828
+ search.oninput = () => {
2829
+ this.search = search.value;
2830
+ this.renderList();
2831
+ };
2832
+ listHead.appendChild(search);
2757
2833
  this.listEl = el("div", "list");
2758
2834
  const commentsView = el("div", "view comments-view");
2759
2835
  commentsView.append(tools, listHead, this.listEl, this.buildIntegrations());
@@ -2991,8 +3067,53 @@ var Loupe = (() => {
2991
3067
  "target",
2992
3068
  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"
2993
3069
  );
3070
+ const title = el("input", "title");
3071
+ title.type = "text";
3072
+ title.placeholder = "Title \u2014 one line: what's wrong, or what you need";
2994
3073
  const ta = el("textarea");
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?";
3074
+ ta.placeholder = isRecording ? "Describe the issue in this recording\u2026" : target.kind === "region" ? "Describe the issue in this area\u2026" : target.kind === "free" ? "Describe this note\u2026" : "Describe what should change here\u2026";
3075
+ const files = [];
3076
+ const attach = el("div", "attach");
3077
+ const pick = el("button", "pick", "\uFF0B Attach images / videos");
3078
+ const input = document.createElement("input");
3079
+ input.type = "file";
3080
+ input.accept = "image/*,video/*";
3081
+ input.multiple = true;
3082
+ input.style.display = "none";
3083
+ const chips = el("div", "chips");
3084
+ const err = el("div", "err");
3085
+ const drawChips = () => {
3086
+ chips.innerHTML = "";
3087
+ files.forEach((f, idx) => {
3088
+ const chip = el("span", "chip", `${attachmentKind(f.type) === "video" ? "\u{1F3AC}" : "\u{1F5BC}"} ${f.name}`);
3089
+ const x2 = el("button", "x", "\xD7");
3090
+ x2.onclick = () => {
3091
+ files.splice(idx, 1);
3092
+ drawChips();
3093
+ };
3094
+ chip.appendChild(x2);
3095
+ chips.appendChild(chip);
3096
+ });
3097
+ };
3098
+ input.onchange = () => {
3099
+ err.textContent = "";
3100
+ for (const f of Array.from(input.files ?? [])) {
3101
+ if (files.length >= MAX_FILES) {
3102
+ err.textContent = `Up to ${MAX_FILES} files.`;
3103
+ break;
3104
+ }
3105
+ const cap = attachmentKind(f.type) === "video" ? MAX_VIDEO_BYTES : MAX_IMAGE_BYTES;
3106
+ if (f.size > cap) {
3107
+ err.textContent = `${f.name} is too large.`;
3108
+ continue;
3109
+ }
3110
+ files.push(f);
3111
+ }
3112
+ input.value = "";
3113
+ drawChips();
3114
+ };
3115
+ pick.onclick = () => input.click();
3116
+ attach.append(pick, input, chips, err);
2996
3117
  const row = el("div", "row");
2997
3118
  let box = null;
2998
3119
  if (target.kind !== "free" && !isRecording) {
@@ -3010,14 +3131,17 @@ var Loupe = (() => {
3010
3131
  cancel.onclick = () => this.closeComposer();
3011
3132
  const save = el("button", "primary", "Comment");
3012
3133
  save.disabled = true;
3013
- ta.oninput = () => {
3014
- save.disabled = !ta.value.trim();
3134
+ const sync = () => {
3135
+ save.disabled = !title.value.trim() || !ta.value.trim();
3015
3136
  };
3016
- save.onclick = () => this.submit(target, ta.value.trim(), box ? box.checked : false);
3137
+ title.oninput = sync;
3138
+ ta.oninput = sync;
3139
+ sync();
3140
+ save.onclick = () => this.submit(target, title.value.trim(), ta.value.trim(), box ? box.checked : false, files.slice());
3017
3141
  btns.append(cancel, save);
3018
3142
  row.append(btns);
3019
- c.append(label, ta, row);
3020
- const w = 300, h = 190;
3143
+ c.append(label, title, ta, attach, row);
3144
+ const w = 320, h = 340;
3021
3145
  const left = Math.min(Math.max(8, x + 12), window.innerWidth - w - 8);
3022
3146
  const top = Math.min(Math.max(8, y + 12), window.innerHeight - h - 8);
3023
3147
  Object.assign(c.style, { display: "block", left: left + "px", top: top + "px" });
@@ -3028,8 +3152,8 @@ var Loupe = (() => {
3028
3152
  this.pending = null;
3029
3153
  this.pendingShot = void 0;
3030
3154
  }
3031
- async submit(target, body, withShot) {
3032
- if (!body) return;
3155
+ async submit(target, title, body, withShot, files) {
3156
+ if (!title || !body) return;
3033
3157
  const saveBtn = this.composer.querySelector(".primary");
3034
3158
  if (saveBtn) {
3035
3159
  saveBtn.disabled = true;
@@ -3071,6 +3195,7 @@ var Loupe = (() => {
3071
3195
  projectKey: this.cfg.projectKey,
3072
3196
  url: this.url,
3073
3197
  author: this.cfg.user,
3198
+ title,
3074
3199
  body,
3075
3200
  status: "open",
3076
3201
  kind: target.kind,
@@ -3080,6 +3205,7 @@ var Loupe = (() => {
3080
3205
  region,
3081
3206
  screenshot,
3082
3207
  recording,
3208
+ attachments: await this.uploadAttachments(files),
3083
3209
  // Record the screen the feedback was captured on (desktop / tablet / mobile).
3084
3210
  viewport: { w: window.innerWidth, h: window.innerHeight },
3085
3211
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -3092,6 +3218,19 @@ var Loupe = (() => {
3092
3218
  this.renderList();
3093
3219
  this.flash(comment.id);
3094
3220
  }
3221
+ /** Upload the reporter's picked files. A file that fails is skipped, not fatal. */
3222
+ async uploadAttachments(files) {
3223
+ if (!files.length) return void 0;
3224
+ const out = [];
3225
+ for (const f of files) {
3226
+ try {
3227
+ out.push(await this.store.upload(this.cfg.projectKey, f));
3228
+ } catch (e) {
3229
+ console.warn("[loupe] attachment upload failed", f.name, e);
3230
+ }
3231
+ }
3232
+ return out.length ? out : void 0;
3233
+ }
3095
3234
  // ---- pins + re-anchoring --------------------------------------------------
3096
3235
  renderPins() {
3097
3236
  for (const [id, pin] of this.pins) {
@@ -3274,19 +3413,22 @@ var Loupe = (() => {
3274
3413
  // ---- comment list ---------------------------------------------------------
3275
3414
  renderList() {
3276
3415
  this.listEl.innerHTML = "";
3277
- if (!this.comments.length) {
3416
+ const q = this.search.trim().toLowerCase();
3417
+ const items = q ? this.comments.filter((c) => `${c.title ?? ""} ${c.body} ${c.author?.name ?? ""}`.toLowerCase().includes(q)) : this.comments;
3418
+ if (!items.length) {
3278
3419
  this.listEl.appendChild(el(
3279
3420
  "div",
3280
3421
  "empty",
3281
- "No comments yet. Use Inspect to pick an element, or Note to drop a comment anywhere on the page."
3422
+ q ? "No comments match your search." : "No comments yet. Use Inspect to pick an element, or Note to drop a comment anywhere on the page."
3282
3423
  ));
3283
3424
  }
3284
- this.comments.forEach((c, i) => this.listEl.appendChild(this.itemView(c, i)));
3425
+ items.forEach((c, i) => this.listEl.appendChild(this.itemView(c, i)));
3285
3426
  this.updateCount();
3286
3427
  }
3287
3428
  itemView(c, i) {
3288
3429
  const detached = this.pins.get(c.id)?.classList.contains("detached") && c.status !== "done";
3289
- const item = el("div", "item");
3430
+ const open = this.expanded.has(c.id);
3431
+ const item = el("div", "item" + (open ? "" : " collapsed"));
3290
3432
  const top = el("div", "top");
3291
3433
  const num = el("span", "num" + (c.status === "done" ? " done" : detached ? " detached" : ""), String(i + 1));
3292
3434
  top.append(num);
@@ -3299,20 +3441,38 @@ var Loupe = (() => {
3299
3441
  }
3300
3442
  if (c.status === "done") top.appendChild(el("span", "badge done", "done"));
3301
3443
  else if (detached) top.appendChild(el("span", "badge detached", "element moved/removed"));
3444
+ top.appendChild(el("span", "caret", open ? "\u25BE" : "\u25B8"));
3302
3445
  item.appendChild(top);
3303
- item.appendChild(el("div", "body", c.body));
3304
- item.appendChild(el("div", "meta", describeAnchor(c)));
3446
+ const summary = c.title || (c.body.split("\n")[0] ?? "").slice(0, 140) || "(no description)";
3447
+ item.appendChild(el("div", "summary", summary));
3448
+ const detail = el("div", "detail");
3449
+ if (c.title || c.body.includes("\n")) detail.appendChild(el("div", "body", c.body));
3450
+ detail.appendChild(el("div", "meta", describeAnchor(c)));
3305
3451
  if (c.recording) {
3306
3452
  const v = el("video", "shot");
3307
3453
  v.src = c.recording;
3308
3454
  v.controls = true;
3309
3455
  v.playsInline = true;
3310
3456
  if (c.screenshot) v.poster = c.screenshot;
3311
- item.appendChild(v);
3457
+ detail.appendChild(v);
3312
3458
  } else if (c.screenshot) {
3313
3459
  const img = el("img", "shot");
3314
3460
  img.src = c.screenshot;
3315
- item.appendChild(img);
3461
+ detail.appendChild(img);
3462
+ }
3463
+ for (const a of c.attachments ?? []) {
3464
+ if (a.kind === "video") {
3465
+ const v = el("video", "shot");
3466
+ v.src = a.url;
3467
+ v.controls = true;
3468
+ v.playsInline = true;
3469
+ detail.appendChild(v);
3470
+ } else {
3471
+ const img = el("img", "shot");
3472
+ img.src = a.url;
3473
+ img.alt = a.name ?? "attachment";
3474
+ detail.appendChild(img);
3475
+ }
3316
3476
  }
3317
3477
  const actions = el("div", "actions");
3318
3478
  const doneBtn = el("button", "", c.status === "done" ? "Reopen" : "Mark done");
@@ -3324,12 +3484,6 @@ var Loupe = (() => {
3324
3484
  this.renderPins();
3325
3485
  this.renderList();
3326
3486
  };
3327
- const claudeBtn = el("button", "", "Copy for Claude");
3328
- claudeBtn.onclick = async (e) => {
3329
- e.stopPropagation();
3330
- await this.copyForClaude(c);
3331
- claudeBtn.textContent = "Copied \u2713";
3332
- };
3333
3487
  const del = el("button", "", "Delete");
3334
3488
  del.onclick = async (e) => {
3335
3489
  e.stopPropagation();
@@ -3339,44 +3493,16 @@ var Loupe = (() => {
3339
3493
  this.renderPins();
3340
3494
  this.renderList();
3341
3495
  };
3342
- actions.append(doneBtn, claudeBtn, del);
3343
- item.appendChild(actions);
3344
- item.onclick = () => this.flash(c.id);
3496
+ actions.append(doneBtn, del);
3497
+ detail.appendChild(actions);
3498
+ item.appendChild(detail);
3499
+ item.onclick = () => {
3500
+ if (open) this.expanded.delete(c.id);
3501
+ else this.expanded.add(c.id);
3502
+ this.renderList();
3503
+ };
3345
3504
  return item;
3346
3505
  }
3347
- async copyForClaude(c) {
3348
- const lines = c.kind === "free" ? [
3349
- `# Product feedback from ${c.author.name}`,
3350
- ``,
3351
- `**Note:** ${c.body}`,
3352
- `**Page:** ${c.url}`,
3353
- `**Type:** Free note \u2014 a page-level comment not tied to a specific element.`
3354
- ] : [
3355
- `# Product feedback from ${c.author.name}`,
3356
- ``,
3357
- `**Comment:** ${c.body}`,
3358
- `**Page:** ${c.url}`,
3359
- `**Element:** \`${c.anchor.cssPath}\``,
3360
- c.anchor.testid ? `**Stable id:** \`${c.anchor.testid}\`` : ``,
3361
- c.recording ? `**Screen recording (webm):** ${c.recording}` : ``,
3362
- ``,
3363
- `## Target element`,
3364
- "```html",
3365
- c.context.html,
3366
- "```",
3367
- ``,
3368
- `## Computed styles`,
3369
- "```json",
3370
- JSON.stringify(c.context.styles, null, 2),
3371
- "```"
3372
- ];
3373
- const prompt = lines.filter(Boolean).join("\n");
3374
- try {
3375
- await navigator.clipboard.writeText(prompt);
3376
- } catch {
3377
- console.log("[loupe] copy failed; prompt:\n" + prompt);
3378
- }
3379
- }
3380
3506
  flash(id) {
3381
3507
  const c = this.comments.find((x) => x.id === id);
3382
3508
  const pin = this.pins.get(id);