@loupekit/sdk 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -171,6 +171,16 @@ var STYLES = (
171
171
  .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); }
172
172
  .item .actions button:hover { border-color: var(--accent); }
173
173
  .item img.shot { width: 100%; border-radius: 6px; margin-top: 8px; border: 1px solid var(--line); }
174
+ .item video.shot { width: 100%; border-radius: 6px; margin-top: 8px; border: 1px solid var(--line); }
175
+ .item .caret { margin-left: auto; color: var(--muted); font-size: 11px; }
176
+ .item .summary { font-size: 13px; font-weight: 600; line-height: 1.35; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
177
+ .item .detail { margin-top: 6px; }
178
+ .item.collapsed .detail { display: none; }
179
+ .listhead .search {
180
+ margin-left: auto; flex: 1; max-width: 170px; font-size: 12px; text-transform: none; letter-spacing: 0;
181
+ padding: 4px 8px; border: 1px solid var(--line); border-radius: 7px; background: var(--bg-2); color: var(--ink); outline: none;
182
+ }
183
+ .listhead .search:focus { border-color: var(--accent); }
174
184
 
175
185
  /* float resize grip (bottom-right corner) */
176
186
  .resize { display: none; position: absolute; right: 0; bottom: 0; width: 16px; height: 16px; cursor: nwse-resize; z-index: 1; }
@@ -182,7 +192,7 @@ var STYLES = (
182
192
 
183
193
  /* composer popover */
184
194
  .composer {
185
- position: fixed; z-index: 2147483004; pointer-events: auto; width: 300px;
195
+ position: fixed; z-index: 2147483004; pointer-events: auto; width: 320px;
186
196
  background: var(--bg); color: var(--ink); border: 1px solid var(--line);
187
197
  border-radius: 12px; box-shadow: var(--shadow); padding: 12px; display: none;
188
198
  }
@@ -203,6 +213,25 @@ var STYLES = (
203
213
  .composer .primary { background: var(--accent); color: #fff; }
204
214
  .composer .primary:disabled { opacity: .5; cursor: default; }
205
215
  .composer .ghost { background: var(--bg-3); color: var(--ink); }
216
+ .composer input.title {
217
+ width: 100%; border: 1px solid var(--line); border-radius: 8px; padding: 8px; margin-bottom: 8px;
218
+ font-size: 13px; font-weight: 600; color: var(--ink); background: var(--bg-2); outline: none;
219
+ }
220
+ .composer input.title:focus { border-color: var(--accent); }
221
+ .composer .attach { margin-top: 8px; }
222
+ .composer .pick {
223
+ width: 100%; border: 1px dashed var(--line); background: transparent; color: var(--muted);
224
+ border-radius: 8px; padding: 7px; font-size: 12px; font-weight: 600; cursor: pointer;
225
+ }
226
+ .composer .pick:hover { border-color: var(--accent); color: var(--accent); }
227
+ .composer .chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
228
+ .composer .chip {
229
+ display: inline-flex; align-items: center; gap: 4px; max-width: 100%;
230
+ background: var(--bg-3); border-radius: 6px; padding: 2px 4px 2px 7px; font-size: 11px; color: var(--ink);
231
+ }
232
+ .composer .chip .x { border: 0; background: transparent; color: var(--muted); cursor: pointer; font-size: 13px; line-height: 1; padding: 0 2px; }
233
+ .composer .err { color: var(--pin); font-size: 11px; margin-top: 4px; }
234
+ .composer .err:empty { display: none; }
206
235
 
207
236
  /* ------------------------------------------------------------ sidebar tabs */
208
237
  .tabs { display: flex; gap: 4px; flex: none; padding: 8px 10px 0; border-bottom: 1px solid var(--line); }
@@ -2145,6 +2174,12 @@ function blobToDataUrl2(blob) {
2145
2174
  r.readAsDataURL(blob);
2146
2175
  });
2147
2176
  }
2177
+ function fileToDataUrl(file) {
2178
+ return blobToDataUrl2(file);
2179
+ }
2180
+ function attachmentKind(mime) {
2181
+ return (mime ?? "").startsWith("video/") ? "video" : "image";
2182
+ }
2148
2183
  async function captureRegionRecording(rect, opts) {
2149
2184
  const md = navigator.mediaDevices;
2150
2185
  if (!md?.getDisplayMedia || !window.MediaRecorder) return void 0;
@@ -2261,6 +2296,20 @@ var LocalStorageAdapter = class {
2261
2296
  this.writeAll(comment.projectKey, comment.url, all);
2262
2297
  return comment;
2263
2298
  }
2299
+ /**
2300
+ * Offline mode: keep the file inline. localStorage is only a few MB, so refuse
2301
+ * anything that would blow the quota rather than silently dropping it.
2302
+ */
2303
+ async upload(_projectKey, file) {
2304
+ if (file.size > 3e6) throw new Error("attachment too large for offline mode");
2305
+ return {
2306
+ url: await fileToDataUrl(file),
2307
+ name: file.name,
2308
+ mime: file.type || void 0,
2309
+ kind: attachmentKind(file.type),
2310
+ size: file.size
2311
+ };
2312
+ }
2264
2313
  async update(id, patch) {
2265
2314
  for (let i = 0; i < localStorage.length; i++) {
2266
2315
  const k = localStorage.key(i);
@@ -2342,6 +2391,17 @@ var HttpAdapter = class {
2342
2391
  if (!res.ok) throw new Error(`save failed: ${res.status}`);
2343
2392
  return await res.json();
2344
2393
  }
2394
+ /** Persist one reporter-attached file to object storage and describe it. */
2395
+ async upload(projectKey, file) {
2396
+ const data = await fileToDataUrl(file);
2397
+ return {
2398
+ url: await this.uploadBlob(projectKey, data),
2399
+ name: file.name,
2400
+ mime: file.type || void 0,
2401
+ kind: attachmentKind(file.type),
2402
+ size: file.size
2403
+ };
2404
+ }
2345
2405
  async update(id, patch) {
2346
2406
  const res = await fetch(`${this.base}/v1/comments/${encodeURIComponent(id)}`, this.opts({
2347
2407
  method: "PATCH",
@@ -2362,6 +2422,9 @@ var HttpAdapter = class {
2362
2422
  // src/app.ts
2363
2423
  var DOCK_MODES = ["left", "right", "bottom", "float"];
2364
2424
  var RECORD_MAX_MS = 2e4;
2425
+ var MAX_FILES = 10;
2426
+ var MAX_IMAGE_BYTES = 10 * 1024 * 1024;
2427
+ var MAX_VIDEO_BYTES = 25 * 1024 * 1024;
2365
2428
  var uid2 = () => crypto.randomUUID ? crypto.randomUUID() : "c_" + Math.abs(hash(String(performance.now()))).toString(36);
2366
2429
  function hash(s) {
2367
2430
  let h = 0;
@@ -2371,6 +2434,10 @@ function hash(s) {
2371
2434
  var LoupeApp = class {
2372
2435
  constructor(cfg) {
2373
2436
  this.comments = [];
2437
+ /** Free-text filter over the list (title / body / author). */
2438
+ this.search = "";
2439
+ /** Ids of list items the user expanded — items are collapsed by default. */
2440
+ this.expanded = /* @__PURE__ */ new Set();
2374
2441
  /** comment.id → currently resolved element (or null when detached). */
2375
2442
  this.resolved = /* @__PURE__ */ new Map();
2376
2443
  /** comment.id → pin element. */
@@ -2727,6 +2794,15 @@ var LoupeApp = class {
2727
2794
  listHead.append(document.createTextNode("Comments"));
2728
2795
  this.countEl = el("span", "count", "0");
2729
2796
  listHead.appendChild(this.countEl);
2797
+ const search = el("input", "search");
2798
+ search.type = "search";
2799
+ search.placeholder = "Search\u2026";
2800
+ search.value = this.search;
2801
+ search.oninput = () => {
2802
+ this.search = search.value;
2803
+ this.renderList();
2804
+ };
2805
+ listHead.appendChild(search);
2730
2806
  this.listEl = el("div", "list");
2731
2807
  const commentsView = el("div", "view comments-view");
2732
2808
  commentsView.append(tools, listHead, this.listEl, this.buildIntegrations());
@@ -2964,8 +3040,53 @@ var LoupeApp = class {
2964
3040
  "target",
2965
3041
  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"
2966
3042
  );
3043
+ const title = el("input", "title");
3044
+ title.type = "text";
3045
+ title.placeholder = "Title \u2014 one line: what's wrong, or what you need";
2967
3046
  const ta = el("textarea");
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?";
3047
+ 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";
3048
+ const files = [];
3049
+ const attach = el("div", "attach");
3050
+ const pick = el("button", "pick", "\uFF0B Attach images / videos");
3051
+ const input = document.createElement("input");
3052
+ input.type = "file";
3053
+ input.accept = "image/*,video/*";
3054
+ input.multiple = true;
3055
+ input.style.display = "none";
3056
+ const chips = el("div", "chips");
3057
+ const err = el("div", "err");
3058
+ const drawChips = () => {
3059
+ chips.innerHTML = "";
3060
+ files.forEach((f, idx) => {
3061
+ const chip = el("span", "chip", `${attachmentKind(f.type) === "video" ? "\u{1F3AC}" : "\u{1F5BC}"} ${f.name}`);
3062
+ const x2 = el("button", "x", "\xD7");
3063
+ x2.onclick = () => {
3064
+ files.splice(idx, 1);
3065
+ drawChips();
3066
+ };
3067
+ chip.appendChild(x2);
3068
+ chips.appendChild(chip);
3069
+ });
3070
+ };
3071
+ input.onchange = () => {
3072
+ err.textContent = "";
3073
+ for (const f of Array.from(input.files ?? [])) {
3074
+ if (files.length >= MAX_FILES) {
3075
+ err.textContent = `Up to ${MAX_FILES} files.`;
3076
+ break;
3077
+ }
3078
+ const cap = attachmentKind(f.type) === "video" ? MAX_VIDEO_BYTES : MAX_IMAGE_BYTES;
3079
+ if (f.size > cap) {
3080
+ err.textContent = `${f.name} is too large.`;
3081
+ continue;
3082
+ }
3083
+ files.push(f);
3084
+ }
3085
+ input.value = "";
3086
+ drawChips();
3087
+ };
3088
+ pick.onclick = () => input.click();
3089
+ attach.append(pick, input, chips, err);
2969
3090
  const row = el("div", "row");
2970
3091
  let box = null;
2971
3092
  if (target.kind !== "free" && !isRecording) {
@@ -2983,14 +3104,17 @@ var LoupeApp = class {
2983
3104
  cancel.onclick = () => this.closeComposer();
2984
3105
  const save = el("button", "primary", "Comment");
2985
3106
  save.disabled = true;
2986
- ta.oninput = () => {
2987
- save.disabled = !ta.value.trim();
3107
+ const sync = () => {
3108
+ save.disabled = !title.value.trim() || !ta.value.trim();
2988
3109
  };
2989
- save.onclick = () => this.submit(target, ta.value.trim(), box ? box.checked : false);
3110
+ title.oninput = sync;
3111
+ ta.oninput = sync;
3112
+ sync();
3113
+ save.onclick = () => this.submit(target, title.value.trim(), ta.value.trim(), box ? box.checked : false, files.slice());
2990
3114
  btns.append(cancel, save);
2991
3115
  row.append(btns);
2992
- c.append(label, ta, row);
2993
- const w = 300, h = 190;
3116
+ c.append(label, title, ta, attach, row);
3117
+ const w = 320, h = 340;
2994
3118
  const left = Math.min(Math.max(8, x + 12), window.innerWidth - w - 8);
2995
3119
  const top = Math.min(Math.max(8, y + 12), window.innerHeight - h - 8);
2996
3120
  Object.assign(c.style, { display: "block", left: left + "px", top: top + "px" });
@@ -3001,8 +3125,8 @@ var LoupeApp = class {
3001
3125
  this.pending = null;
3002
3126
  this.pendingShot = void 0;
3003
3127
  }
3004
- async submit(target, body, withShot) {
3005
- if (!body) return;
3128
+ async submit(target, title, body, withShot, files) {
3129
+ if (!title || !body) return;
3006
3130
  const saveBtn = this.composer.querySelector(".primary");
3007
3131
  if (saveBtn) {
3008
3132
  saveBtn.disabled = true;
@@ -3044,6 +3168,7 @@ var LoupeApp = class {
3044
3168
  projectKey: this.cfg.projectKey,
3045
3169
  url: this.url,
3046
3170
  author: this.cfg.user,
3171
+ title,
3047
3172
  body,
3048
3173
  status: "open",
3049
3174
  kind: target.kind,
@@ -3053,6 +3178,7 @@ var LoupeApp = class {
3053
3178
  region,
3054
3179
  screenshot,
3055
3180
  recording,
3181
+ attachments: await this.uploadAttachments(files),
3056
3182
  // Record the screen the feedback was captured on (desktop / tablet / mobile).
3057
3183
  viewport: { w: window.innerWidth, h: window.innerHeight },
3058
3184
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -3065,6 +3191,19 @@ var LoupeApp = class {
3065
3191
  this.renderList();
3066
3192
  this.flash(comment.id);
3067
3193
  }
3194
+ /** Upload the reporter's picked files. A file that fails is skipped, not fatal. */
3195
+ async uploadAttachments(files) {
3196
+ if (!files.length) return void 0;
3197
+ const out = [];
3198
+ for (const f of files) {
3199
+ try {
3200
+ out.push(await this.store.upload(this.cfg.projectKey, f));
3201
+ } catch (e) {
3202
+ console.warn("[loupe] attachment upload failed", f.name, e);
3203
+ }
3204
+ }
3205
+ return out.length ? out : void 0;
3206
+ }
3068
3207
  // ---- pins + re-anchoring --------------------------------------------------
3069
3208
  renderPins() {
3070
3209
  for (const [id, pin] of this.pins) {
@@ -3247,19 +3386,22 @@ var LoupeApp = class {
3247
3386
  // ---- comment list ---------------------------------------------------------
3248
3387
  renderList() {
3249
3388
  this.listEl.innerHTML = "";
3250
- if (!this.comments.length) {
3389
+ const q = this.search.trim().toLowerCase();
3390
+ const items = q ? this.comments.filter((c) => `${c.title ?? ""} ${c.body} ${c.author?.name ?? ""}`.toLowerCase().includes(q)) : this.comments;
3391
+ if (!items.length) {
3251
3392
  this.listEl.appendChild(el(
3252
3393
  "div",
3253
3394
  "empty",
3254
- "No comments yet. Use Inspect to pick an element, or Note to drop a comment anywhere on the page."
3395
+ 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."
3255
3396
  ));
3256
3397
  }
3257
- this.comments.forEach((c, i) => this.listEl.appendChild(this.itemView(c, i)));
3398
+ items.forEach((c, i) => this.listEl.appendChild(this.itemView(c, i)));
3258
3399
  this.updateCount();
3259
3400
  }
3260
3401
  itemView(c, i) {
3261
3402
  const detached = this.pins.get(c.id)?.classList.contains("detached") && c.status !== "done";
3262
- const item = el("div", "item");
3403
+ const open = this.expanded.has(c.id);
3404
+ const item = el("div", "item" + (open ? "" : " collapsed"));
3263
3405
  const top = el("div", "top");
3264
3406
  const num = el("span", "num" + (c.status === "done" ? " done" : detached ? " detached" : ""), String(i + 1));
3265
3407
  top.append(num);
@@ -3272,20 +3414,38 @@ var LoupeApp = class {
3272
3414
  }
3273
3415
  if (c.status === "done") top.appendChild(el("span", "badge done", "done"));
3274
3416
  else if (detached) top.appendChild(el("span", "badge detached", "element moved/removed"));
3417
+ top.appendChild(el("span", "caret", open ? "\u25BE" : "\u25B8"));
3275
3418
  item.appendChild(top);
3276
- item.appendChild(el("div", "body", c.body));
3277
- item.appendChild(el("div", "meta", describeAnchor(c)));
3419
+ const summary = c.title || (c.body.split("\n")[0] ?? "").slice(0, 140) || "(no description)";
3420
+ item.appendChild(el("div", "summary", summary));
3421
+ const detail = el("div", "detail");
3422
+ if (c.title || c.body.includes("\n")) detail.appendChild(el("div", "body", c.body));
3423
+ detail.appendChild(el("div", "meta", describeAnchor(c)));
3278
3424
  if (c.recording) {
3279
3425
  const v = el("video", "shot");
3280
3426
  v.src = c.recording;
3281
3427
  v.controls = true;
3282
3428
  v.playsInline = true;
3283
3429
  if (c.screenshot) v.poster = c.screenshot;
3284
- item.appendChild(v);
3430
+ detail.appendChild(v);
3285
3431
  } else if (c.screenshot) {
3286
3432
  const img = el("img", "shot");
3287
3433
  img.src = c.screenshot;
3288
- item.appendChild(img);
3434
+ detail.appendChild(img);
3435
+ }
3436
+ for (const a of c.attachments ?? []) {
3437
+ if (a.kind === "video") {
3438
+ const v = el("video", "shot");
3439
+ v.src = a.url;
3440
+ v.controls = true;
3441
+ v.playsInline = true;
3442
+ detail.appendChild(v);
3443
+ } else {
3444
+ const img = el("img", "shot");
3445
+ img.src = a.url;
3446
+ img.alt = a.name ?? "attachment";
3447
+ detail.appendChild(img);
3448
+ }
3289
3449
  }
3290
3450
  const actions = el("div", "actions");
3291
3451
  const doneBtn = el("button", "", c.status === "done" ? "Reopen" : "Mark done");
@@ -3297,12 +3457,6 @@ var LoupeApp = class {
3297
3457
  this.renderPins();
3298
3458
  this.renderList();
3299
3459
  };
3300
- const claudeBtn = el("button", "", "Copy for Claude");
3301
- claudeBtn.onclick = async (e) => {
3302
- e.stopPropagation();
3303
- await this.copyForClaude(c);
3304
- claudeBtn.textContent = "Copied \u2713";
3305
- };
3306
3460
  const del = el("button", "", "Delete");
3307
3461
  del.onclick = async (e) => {
3308
3462
  e.stopPropagation();
@@ -3312,44 +3466,16 @@ var LoupeApp = class {
3312
3466
  this.renderPins();
3313
3467
  this.renderList();
3314
3468
  };
3315
- actions.append(doneBtn, claudeBtn, del);
3316
- item.appendChild(actions);
3317
- item.onclick = () => this.flash(c.id);
3469
+ actions.append(doneBtn, del);
3470
+ detail.appendChild(actions);
3471
+ item.appendChild(detail);
3472
+ item.onclick = () => {
3473
+ if (open) this.expanded.delete(c.id);
3474
+ else this.expanded.add(c.id);
3475
+ this.renderList();
3476
+ };
3318
3477
  return item;
3319
3478
  }
3320
- async copyForClaude(c) {
3321
- const lines = c.kind === "free" ? [
3322
- `# Product feedback from ${c.author.name}`,
3323
- ``,
3324
- `**Note:** ${c.body}`,
3325
- `**Page:** ${c.url}`,
3326
- `**Type:** Free note \u2014 a page-level comment not tied to a specific element.`
3327
- ] : [
3328
- `# Product feedback from ${c.author.name}`,
3329
- ``,
3330
- `**Comment:** ${c.body}`,
3331
- `**Page:** ${c.url}`,
3332
- `**Element:** \`${c.anchor.cssPath}\``,
3333
- c.anchor.testid ? `**Stable id:** \`${c.anchor.testid}\`` : ``,
3334
- c.recording ? `**Screen recording (webm):** ${c.recording}` : ``,
3335
- ``,
3336
- `## Target element`,
3337
- "```html",
3338
- c.context.html,
3339
- "```",
3340
- ``,
3341
- `## Computed styles`,
3342
- "```json",
3343
- JSON.stringify(c.context.styles, null, 2),
3344
- "```"
3345
- ];
3346
- const prompt = lines.filter(Boolean).join("\n");
3347
- try {
3348
- await navigator.clipboard.writeText(prompt);
3349
- } catch {
3350
- console.log("[loupe] copy failed; prompt:\n" + prompt);
3351
- }
3352
- }
3353
3479
  flash(id) {
3354
3480
  const c = this.comments.find((x) => x.id === id);
3355
3481
  const pin = this.pins.get(id);