@loupekit/sdk 0.10.11-next.56 → 0.10.12-next.57

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 CHANGED
@@ -90,7 +90,8 @@ init({
90
90
  });
91
91
  ```
92
92
 
93
- A dockable control panel appears with a two-page sidebar — **Comments** (with the
93
+ A dockable control panel appears with a three-page sidebar — **Home** (stat tiles, a this-page ↔
94
+ whole-project scope switch, and the most recent feedback), **Comments** (with the
94
95
  **Inspect**, **Note**, **Region**, and **Record** tools + the comment list) and
95
96
  **Connect Claude** (MCP setup steps). Pass `repo` and `branch` to `init()` to make
96
97
  threads branch-aware — the board can then be filtered by repository and branch.
@@ -327,11 +327,63 @@ var Loupe = (() => {
327
327
  .tabs .tab:hover { color: var(--ink); background: var(--bg-2); }
328
328
  .tabs .tab.on { color: var(--accent); border-bottom-color: var(--accent); }
329
329
 
330
- /* Two sidebar pages: only the active one shows. */
330
+ /* Three sidebar pages: only the active one shows. */
331
331
  .view { display: none; }
332
+ .dock.tab-home .home-view { display: block; flex: 1; min-height: 0; overflow-y: auto; }
332
333
  .dock.tab-comments .comments-view { display: flex; flex-direction: column; flex: 1; min-height: 0; }
333
334
  .dock.tab-connect .connect-view { display: block; flex: 1; overflow-y: auto; }
334
335
 
336
+ /* ------------------------------------------------------------- Home overview */
337
+ .home-view { padding: 12px 12px 18px; }
338
+ .hstat { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
339
+ .hstat-b {
340
+ display: flex; flex-direction: column; gap: 2px; text-align: left; padding: 10px;
341
+ border: 1px solid var(--line); border-radius: 10px; background: var(--bg-2); color: var(--ink); cursor: pointer;
342
+ }
343
+ .hstat-b:hover { border-color: var(--accent); }
344
+ .hstat-b.on { border-color: var(--accent); background: var(--bg-3); }
345
+ .hstat-n { font-size: 20px; font-weight: 700; line-height: 1.1; font-variant-numeric: tabular-nums; }
346
+ .hstat-l { font-size: 11px; color: var(--muted); }
347
+ .hscope { display: flex; align-items: center; gap: 6px; margin-top: 10px; }
348
+ .hscope-b {
349
+ flex: 1; padding: 7px 8px; border: 1px solid var(--line); border-radius: 8px;
350
+ background: var(--bg-2); color: var(--muted); font-size: 12px; font-weight: 600; cursor: pointer;
351
+ }
352
+ .hscope-b.on { background: var(--accent); border-color: var(--accent); color: #fff; }
353
+ .hrefresh {
354
+ flex: none; width: 30px; height: 30px; border: 1px solid var(--line); border-radius: 8px;
355
+ background: var(--bg-2); color: var(--muted); cursor: pointer; font-size: 14px;
356
+ }
357
+ .hrefresh:hover { border-color: var(--accent); color: var(--ink); }
358
+ .hpin {
359
+ margin-top: 10px; width: 100%; padding: 9px; border: 0; border-radius: 9px;
360
+ background: var(--accent); color: #fff; font-size: 13px; font-weight: 600; cursor: pointer;
361
+ }
362
+ .hlabel { margin-top: 14px; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); font-weight: 700; }
363
+ .hfeed { margin-top: 6px; display: flex; flex-direction: column; gap: 4px; }
364
+ .hfeed-i {
365
+ display: flex; flex-direction: column; gap: 3px; text-align: left; padding: 8px;
366
+ border: 1px solid var(--line); border-radius: 8px; background: var(--bg-2); color: var(--ink); cursor: pointer;
367
+ }
368
+ .hfeed-i:hover { border-color: var(--accent); }
369
+ .hfeed-t { font-size: 12.5px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
370
+ .hfeed-m { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 11px; color: var(--muted); }
371
+ .hfeed-s { font-weight: 700; }
372
+ .hfeed-s-resolved { color: #34c281; }
373
+ .hfeed-p { font-weight: 700; }
374
+ .hfeed-p-critical { color: var(--pin); }
375
+ .hfeed-p-high { color: #e0a92c; }
376
+ .hempty { padding: 14px; text-align: center; color: var(--muted); font-size: 12px; }
377
+ .hfoot { margin-top: 14px; padding-top: 10px; border-top: 1px solid var(--line); font-size: 11px; color: var(--muted); text-align: center; }
378
+
379
+ /* Timeline grouping (project scope) + the repo filter. */
380
+ .daylabel { margin: 8px 2px 0; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); font-weight: 700; }
381
+ .listhead .reposel {
382
+ margin-left: 6px; font-size: 11px; padding: 3px 6px; border: 1px solid var(--line);
383
+ border-radius: 7px; background: var(--bg-2); color: var(--ink);
384
+ text-transform: none; letter-spacing: 0; max-width: 130px;
385
+ }
386
+
335
387
  /* recording marker + video in the list */
336
388
  .item .rectag { font-size: 10px; font-weight: 700; color: var(--pin); background: var(--bg-3); border-radius: 999px; padding: 1px 7px; white-space: nowrap; }
337
389
  .item video.shot { width: 100%; border-radius: 6px; margin-top: 8px; border: 1px solid var(--line); background: #000; display: block; }
@@ -2382,6 +2434,20 @@ var Loupe = (() => {
2382
2434
  async list(projectKey, url) {
2383
2435
  return this.readAll(projectKey, url);
2384
2436
  }
2437
+ /** Every page's comments for this project, newest first — the "All" scope. */
2438
+ async listAll(projectKey) {
2439
+ const prefix = `loupe:${projectKey}:`;
2440
+ const out = [];
2441
+ for (let i = 0; i < localStorage.length; i++) {
2442
+ const k = localStorage.key(i);
2443
+ if (!k || !k.startsWith(prefix)) continue;
2444
+ try {
2445
+ out.push(...JSON.parse(localStorage.getItem(k) || "[]"));
2446
+ } catch {
2447
+ }
2448
+ }
2449
+ return out.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
2450
+ }
2385
2451
  async save(comment) {
2386
2452
  const all = this.readAll(comment.projectKey, comment.url);
2387
2453
  all.push(comment);
@@ -2402,11 +2468,30 @@ var Loupe = (() => {
2402
2468
  size: file.size
2403
2469
  };
2404
2470
  }
2405
- async update(id, patch) {
2471
+ /**
2472
+ * Keys that hold a comment list. `loupe:dock` also lives under `loupe:` but
2473
+ * holds an OBJECT (panel state), so treating every `loupe:` key as a list made
2474
+ * update()/remove() throw as soon as the panel persisted anything.
2475
+ */
2476
+ commentKeys() {
2477
+ const keys = [];
2406
2478
  for (let i = 0; i < localStorage.length; i++) {
2407
2479
  const k = localStorage.key(i);
2408
- if (!k || !k.startsWith("loupe:")) continue;
2409
- const list = JSON.parse(localStorage.getItem(k) || "[]");
2480
+ if (k && k.startsWith("loupe:") && k !== "loupe:dock") keys.push(k);
2481
+ }
2482
+ return keys;
2483
+ }
2484
+ parseList(key) {
2485
+ try {
2486
+ const value = JSON.parse(localStorage.getItem(key) || "[]");
2487
+ return Array.isArray(value) ? value : [];
2488
+ } catch {
2489
+ return [];
2490
+ }
2491
+ }
2492
+ async update(id, patch) {
2493
+ for (const k of this.commentKeys()) {
2494
+ const list = this.parseList(k);
2410
2495
  const idx = list.findIndex((c) => c.id === id);
2411
2496
  if (idx >= 0) {
2412
2497
  list[idx] = { ...list[idx], ...patch };
@@ -2416,10 +2501,8 @@ var Loupe = (() => {
2416
2501
  }
2417
2502
  }
2418
2503
  async remove(id) {
2419
- for (let i = 0; i < localStorage.length; i++) {
2420
- const k = localStorage.key(i);
2421
- if (!k || !k.startsWith("loupe:")) continue;
2422
- const list = JSON.parse(localStorage.getItem(k) || "[]");
2504
+ for (const k of this.commentKeys()) {
2505
+ const list = this.parseList(k);
2423
2506
  const next = list.filter((c) => c.id !== id);
2424
2507
  if (next.length !== list.length) {
2425
2508
  localStorage.setItem(k, JSON.stringify(next));
@@ -2455,6 +2538,13 @@ var Loupe = (() => {
2455
2538
  if (!res.ok) throw new Error(`list failed: ${res.status}`);
2456
2539
  return await res.json();
2457
2540
  }
2541
+ /** Every comment in the project (no page filter) — the "All" scope. */
2542
+ async listAll(projectKey) {
2543
+ const q = new URLSearchParams({ projectKey });
2544
+ const res = await fetch(`${this.base}/v1/comments?${q}`, this.opts({ headers: this.headers() }));
2545
+ if (!res.ok) throw new Error(`listAll failed: ${res.status}`);
2546
+ return await res.json();
2547
+ }
2458
2548
  /** Upload an inline data-URL asset to object storage; return its URL (or the data URL on failure). */
2459
2549
  async uploadBlob(projectKey, data) {
2460
2550
  try {
@@ -2513,6 +2603,13 @@ var Loupe = (() => {
2513
2603
 
2514
2604
  // ../shared/dist/index.js
2515
2605
  var COMMENT_STAGES = ["queue", "todo", "in_progress", "in_review", "resolved"];
2606
+ var STAGE_LABELS = {
2607
+ queue: "Queue",
2608
+ todo: "To Do",
2609
+ in_progress: "In Progress",
2610
+ in_review: "In Review",
2611
+ resolved: "Resolved"
2612
+ };
2516
2613
  var LEGACY_STATUS = {
2517
2614
  open: "queue",
2518
2615
  in_progress: "in_progress",
@@ -2543,9 +2640,15 @@ var Loupe = (() => {
2543
2640
  };
2544
2641
  var DEFAULT_PRIORITY = "medium";
2545
2642
  var DEFAULT_CHANGE_TYPE = "other";
2643
+ function normalizePriority(value) {
2644
+ if (typeof value === "string" && COMMENT_PRIORITIES.includes(value)) {
2645
+ return value;
2646
+ }
2647
+ return DEFAULT_PRIORITY;
2648
+ }
2546
2649
 
2547
2650
  // src/app.ts
2548
- var SDK_VERSION = true ? "0.10.11" : "dev";
2651
+ var SDK_VERSION = true ? "0.10.12" : "dev";
2549
2652
  var DOCK_MODES = ["left", "right", "bottom", "float"];
2550
2653
  var RECORD_MAX_MS = 2e4;
2551
2654
  var MAX_FILES = 10;
@@ -2615,7 +2718,15 @@ var Loupe = (() => {
2615
2718
  this.dockMode = "right";
2616
2719
  this.open = true;
2617
2720
  this.theme = "dark";
2618
- this.tab = "comments";
2721
+ this.tab = "home";
2722
+ /** "page" = this URL only; "all" = the whole project (the timeline scope). */
2723
+ this.scope = "page";
2724
+ /** Every-page comments, fetched the first time the "All" scope is opened. */
2725
+ this.allComments = [];
2726
+ /** A clicked stat tile, which narrows the list. */
2727
+ this.statFilter = "";
2728
+ /** Repo filter, offered once the scope is "all". */
2729
+ this.repoFilter = "";
2619
2730
  /** Float-mode window geometry; (x<=0 && y<=0) → placed on first layout. */
2620
2731
  this.floatRect = { x: 0, y: 0, w: 380, h: 540 };
2621
2732
  this.floatDrag = null;
@@ -2844,6 +2955,8 @@ var Loupe = (() => {
2844
2955
  this.comments = await this.store.list(this.cfg.projectKey, this.url);
2845
2956
  this.renderPins();
2846
2957
  this.renderList();
2958
+ this.renderHome();
2959
+ if (this.scope === "all") void this.loadAllComments();
2847
2960
  this.observe();
2848
2961
  this.watchNavigation();
2849
2962
  if (this.cfg.autoOpen) this.setMode("inspect");
@@ -2941,13 +3054,13 @@ var Loupe = (() => {
2941
3054
  ctl.append(this.themeBtn, closeBtn);
2942
3055
  head.append(brand, ctl);
2943
3056
  const tabs = el("div", "tabs");
2944
- const commentsTab = el("button", "tab", "Comments");
2945
- commentsTab.dataset.tab = "comments";
2946
- commentsTab.onclick = () => this.setTab("comments");
2947
- const connectTab = el("button", "tab", "Connect Claude");
2948
- connectTab.dataset.tab = "connect";
2949
- connectTab.onclick = () => this.setTab("connect");
2950
- tabs.append(commentsTab, connectTab);
3057
+ const tabBtn = (key, label) => {
3058
+ const b = el("button", "tab", label);
3059
+ b.dataset.tab = key;
3060
+ b.onclick = () => this.setTab(key);
3061
+ return b;
3062
+ };
3063
+ tabs.append(tabBtn("home", "Home"), tabBtn("comments", "Comments"), tabBtn("connect", "Connect Claude"));
2951
3064
  const tools = el("div", "tools");
2952
3065
  const inspectBtn = this.toolBtn("\u271B", "Inspect", "inspect");
2953
3066
  inspectBtn.title = "Inspect an element and comment on it";
@@ -2984,15 +3097,119 @@ var Loupe = (() => {
2984
3097
  this.renderList();
2985
3098
  };
2986
3099
  listHead.appendChild(search);
3100
+ this.repoSel = el("select", "reposel");
3101
+ this.repoSel.title = "Filter by repository";
3102
+ this.repoSel.setAttribute("aria-label", "Filter by repository");
3103
+ this.repoSel.onchange = () => {
3104
+ this.repoFilter = this.repoSel.value;
3105
+ this.renderList();
3106
+ };
3107
+ this.repoSel.style.display = "none";
3108
+ listHead.appendChild(this.repoSel);
2987
3109
  this.listEl = el("div", "list");
3110
+ const homeView = el("div", "view home-view");
3111
+ this.homeEl = homeView;
2988
3112
  const commentsView = el("div", "view comments-view");
2989
3113
  commentsView.append(tools, listHead, this.listEl, this.buildIntegrations());
2990
3114
  const connectView = this.buildConnectPanel();
2991
3115
  const resize = el("div", "resize");
2992
3116
  resize.addEventListener("pointerdown", this.onResizeDown);
2993
- dock.append(head, tabs, commentsView, connectView, resize);
3117
+ dock.append(head, tabs, homeView, commentsView, connectView, resize);
3118
+ this.buildHomePanel();
2994
3119
  return dock;
2995
3120
  }
3121
+ /**
3122
+ * The Home overview: four stat tiles over the current scope, a scope switch
3123
+ * (this page ↔ the whole project), a one-click way into capture, and the
3124
+ * most recent feedback. Every tile is a button that narrows the list.
3125
+ */
3126
+ buildHomePanel() {
3127
+ const title = this.cfg.label ?? "Loupe";
3128
+ this.homeEl.innerHTML = `<div class="hstat" id="loupe-hstats"></div><div class="hscope"><button class="hscope-b" data-scope="page">This page</button><button class="hscope-b" data-scope="all">All</button><button class="hrefresh" title="Refresh" aria-label="Refresh">\u27F3</button></div><button class="hpin" data-role="home-pin">\u271B Pin feedback on this page</button><div class="hlabel">Recent</div><div class="hfeed" id="loupe-hfeed"></div><div class="hfoot">${escapeHtml(title)} \xB7 ${escapeHtml(this.cfg.repo ?? "no repo linked")}</div>`;
3129
+ this.homeEl.querySelectorAll(".hscope-b").forEach((b) => {
3130
+ b.onclick = () => this.setScope(b.dataset.scope === "all" ? "all" : "page");
3131
+ });
3132
+ this.homeEl.querySelector('[data-role="home-pin"]').onclick = () => {
3133
+ this.setTab("comments");
3134
+ this.setMode("inspect");
3135
+ };
3136
+ this.homeEl.querySelector(".hrefresh").onclick = () => {
3137
+ void this.reloadComments();
3138
+ if (this.scope === "all") void this.loadAllComments();
3139
+ };
3140
+ }
3141
+ /** The comments the panel is currently looking at (page scope or project scope). */
3142
+ get visibleComments() {
3143
+ return this.scope === "all" ? this.allComments : this.comments;
3144
+ }
3145
+ /** Switch the panel between "this page" and the whole project. */
3146
+ setScope(scope) {
3147
+ this.scope = scope;
3148
+ this.saveState();
3149
+ if (scope === "all" && !this.allComments.length) void this.loadAllComments();
3150
+ this.renderHome();
3151
+ this.renderList();
3152
+ }
3153
+ async loadAllComments() {
3154
+ try {
3155
+ this.allComments = await this.store.listAll(this.cfg.projectKey);
3156
+ this.renderHome();
3157
+ this.renderList();
3158
+ } catch {
3159
+ }
3160
+ }
3161
+ /**
3162
+ * The four buckets a triager actually asks about. "Stale" is open work older
3163
+ * than a week — the thing that quietly rots on a board.
3164
+ */
3165
+ homeStats() {
3166
+ const list = this.visibleComments;
3167
+ const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1e3;
3168
+ return [
3169
+ { key: "open", label: "Open", n: list.filter((c) => normalizeStatus(c.status) !== "resolved").length },
3170
+ { key: "needs_you", label: "Needs you", n: list.filter((c) => normalizeStatus(c.status) === "in_review").length },
3171
+ { key: "resolved", label: "Resolved", n: list.filter((c) => normalizeStatus(c.status) === "resolved").length },
3172
+ {
3173
+ key: "stale",
3174
+ label: "Stale",
3175
+ n: list.filter((c) => normalizeStatus(c.status) !== "resolved" && Date.parse(c.createdAt) < weekAgo).length
3176
+ }
3177
+ ];
3178
+ }
3179
+ renderHome() {
3180
+ if (!this.homeEl) return;
3181
+ const stats = this.homeStats();
3182
+ const statsEl = this.homeEl.querySelector("#loupe-hstats");
3183
+ if (statsEl) {
3184
+ statsEl.innerHTML = stats.map((s) => `<button class="hstat-b${this.statFilter === s.key ? " on" : ""}" data-stat="${s.key}"><span class="hstat-n">${s.n}</span><span class="hstat-l">${s.label}</span></button>`).join("");
3185
+ statsEl.querySelectorAll(".hstat-b").forEach((b) => {
3186
+ b.onclick = () => {
3187
+ const key = b.dataset.stat;
3188
+ this.statFilter = this.statFilter === key ? "" : key;
3189
+ this.setTab("comments");
3190
+ };
3191
+ });
3192
+ }
3193
+ this.homeEl.querySelectorAll(".hscope-b").forEach((b) => b.classList.toggle("on", b.dataset.scope === this.scope));
3194
+ const feed = this.homeEl.querySelector("#loupe-hfeed");
3195
+ if (!feed) return;
3196
+ const recent = [...this.visibleComments].sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 8);
3197
+ feed.innerHTML = recent.length ? recent.map((c) => {
3198
+ const stage = STAGE_LABELS[normalizeStatus(c.status)];
3199
+ const prio = normalizePriority(c.priority);
3200
+ return `<button class="hfeed-i" data-id="${escapeAttr(c.id)}"><span class="hfeed-t">${escapeHtml(c.title || (c.body.split("\n")[0] ?? "").slice(0, 60))}</span><span class="hfeed-m">${escapeHtml(c.author?.name ?? "")} \xB7 ${fmtAgo(c.createdAt)} \xB7 <span class="hfeed-s hfeed-s-${normalizeStatus(c.status)}">${stage}</span><span class="hfeed-p hfeed-p-${prio}">${PRIORITY_LABELS[prio]}</span></span></button>`;
3201
+ }).join("") : `<div class="hempty">Nothing here yet.</div>`;
3202
+ feed.querySelectorAll(".hfeed-i").forEach((b) => {
3203
+ b.onclick = () => {
3204
+ const id = b.dataset.id;
3205
+ this.statFilter = "";
3206
+ this.setTab("comments");
3207
+ this.expanded.add(id);
3208
+ this.renderList();
3209
+ this.flash(id);
3210
+ };
3211
+ });
3212
+ }
2996
3213
  /** The "INTEGRATES WITH" footer on the Comments page (visual only for now). */
2997
3214
  buildIntegrations() {
2998
3215
  const wrap = el("div", "integrations");
@@ -3585,9 +3802,9 @@ var Loupe = (() => {
3585
3802
  this.updateCount();
3586
3803
  this.position();
3587
3804
  }
3588
- updateCount() {
3805
+ updateCount(shown = this.comments.length) {
3806
+ this.countEl.textContent = String(shown);
3589
3807
  const n = this.comments.length;
3590
- this.countEl.textContent = String(n);
3591
3808
  this.fabBadge.textContent = n ? String(n) : "";
3592
3809
  }
3593
3810
  /**
@@ -3646,14 +3863,18 @@ var Loupe = (() => {
3646
3863
  this.saveState();
3647
3864
  this.applyDockLayout();
3648
3865
  }
3649
- /** Switch the sidebar page (Comments ↔ Connect Claude). */
3866
+ /** Switch the sidebar page (Home ↔ Comments ↔ Connect Claude). */
3650
3867
  setTab(tab) {
3651
3868
  if (this.tab === tab) return;
3652
3869
  if (tab !== "comments") this.setMode("off");
3653
3870
  this.tab = tab;
3654
3871
  this.saveState();
3655
- this.applyDockLayout();
3872
+ if (tab === "home") {
3873
+ this.renderHome();
3874
+ if (this.scope === "all" && !this.allComments.length) void this.loadAllComments();
3875
+ }
3656
3876
  if (tab === "comments") this.renderList();
3877
+ this.applyDockLayout();
3657
3878
  }
3658
3879
  toggleTheme() {
3659
3880
  this.theme = this.theme === "dark" ? "light" : "dark";
@@ -3672,7 +3893,10 @@ var Loupe = (() => {
3672
3893
  if (DOCK_MODES.includes(p?.mode)) this.dockMode = p.mode;
3673
3894
  if (typeof p?.open === "boolean") this.open = p.open;
3674
3895
  if (p?.theme === "light" || p?.theme === "dark") this.theme = p.theme;
3675
- if (p?.tab === "comments" || p?.tab === "connect") this.tab = p.tab;
3896
+ if (p?.tab === "home" || p?.tab === "comments" || p?.tab === "connect") this.tab = p.tab;
3897
+ if (p?.scope === "page" || p?.scope === "all") this.scope = p.scope;
3898
+ if (typeof p?.statFilter === "string") this.statFilter = p.statFilter;
3899
+ if (typeof p?.repoFilter === "string") this.repoFilter = p.repoFilter;
3676
3900
  if (p?.float && typeof p.float.w === "number") this.floatRect = { ...this.floatRect, ...p.float };
3677
3901
  if (typeof p?.markersHidden === "boolean") this.markersHidden = p.markersHidden;
3678
3902
  } catch {
@@ -3686,7 +3910,10 @@ var Loupe = (() => {
3686
3910
  theme: this.theme,
3687
3911
  tab: this.tab,
3688
3912
  float: this.floatRect,
3689
- markersHidden: this.markersHidden
3913
+ markersHidden: this.markersHidden,
3914
+ scope: this.scope,
3915
+ statFilter: this.statFilter,
3916
+ repoFilter: this.repoFilter
3690
3917
  }));
3691
3918
  } catch {
3692
3919
  }
@@ -3699,8 +3926,10 @@ var Loupe = (() => {
3699
3926
  const d = this.dock;
3700
3927
  d.classList.toggle("open", this.open);
3701
3928
  for (const m of DOCK_MODES) d.classList.toggle("mode-" + m, this.dockMode === m);
3929
+ d.classList.toggle("tab-home", this.tab === "home");
3702
3930
  d.classList.toggle("tab-comments", this.tab === "comments");
3703
3931
  d.classList.toggle("tab-connect", this.tab === "connect");
3932
+ this.renderHome();
3704
3933
  d.querySelectorAll(".tabs .tab").forEach((b) => b.classList.toggle("on", b.dataset.tab === this.tab));
3705
3934
  if (this.dockMode === "float") {
3706
3935
  const vw = window.innerWidth, vh = window.innerHeight;
@@ -3746,16 +3975,46 @@ var Loupe = (() => {
3746
3975
  renderList() {
3747
3976
  this.listEl.innerHTML = "";
3748
3977
  const q = this.search.trim().toLowerCase();
3749
- const items = q ? this.comments.filter((c) => `${c.title ?? ""} ${c.body} ${c.author?.name ?? ""}`.toLowerCase().includes(q)) : this.comments;
3978
+ const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1e3;
3979
+ let items = this.visibleComments.filter((c) => {
3980
+ const stage = normalizeStatus(c.status);
3981
+ if (this.statFilter === "open" && stage === "resolved") return false;
3982
+ if (this.statFilter === "needs_you" && stage !== "in_review") return false;
3983
+ if (this.statFilter === "resolved" && stage !== "resolved") return false;
3984
+ if (this.statFilter === "stale" && (stage === "resolved" || !(Date.parse(c.createdAt) < weekAgo))) return false;
3985
+ if (this.repoFilter && c.repo !== this.repoFilter) return false;
3986
+ return !q || `${c.title ?? ""} ${c.body} ${c.author?.name ?? ""}`.toLowerCase().includes(q);
3987
+ });
3988
+ if (this.scope === "all") items = [...items].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
3989
+ this.renderRepoFilter();
3750
3990
  if (!items.length) {
3751
3991
  this.listEl.appendChild(el(
3752
3992
  "div",
3753
3993
  "empty",
3754
- 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."
3994
+ this.statFilter ? "Nothing in this bucket." : q ? "No comments match your search." : this.scope === "all" ? "No feedback in this project yet." : "No comments yet. Use Inspect to pick an element, or Note to drop a comment anywhere on the page."
3755
3995
  ));
3756
3996
  }
3757
- items.forEach((c, i) => this.listEl.appendChild(this.itemView(c, i)));
3758
- this.updateCount();
3997
+ let lastDay = "";
3998
+ items.forEach((c, i) => {
3999
+ if (this.scope === "all") {
4000
+ const day = dayLabel(c.createdAt);
4001
+ if (day !== lastDay) {
4002
+ this.listEl.appendChild(el("div", "daylabel", day));
4003
+ lastDay = day;
4004
+ }
4005
+ }
4006
+ this.listEl.appendChild(this.itemView(c, i));
4007
+ });
4008
+ this.updateCount(items.length);
4009
+ }
4010
+ /** Repo filter — only offered in the project scope, where it means something. */
4011
+ renderRepoFilter() {
4012
+ if (!this.repoSel) return;
4013
+ const repos = [...new Set(this.visibleComments.map((c) => c.repo).filter((r) => !!r))].sort();
4014
+ this.repoSel.style.display = this.scope === "all" && repos.length > 1 ? "" : "none";
4015
+ const current = this.repoFilter;
4016
+ this.repoSel.innerHTML = `<option value="">All repos</option>` + repos.map((r) => `<option value="${escapeHtml(r)}">${escapeHtml(r)}</option>`).join("");
4017
+ this.repoSel.value = current;
3759
4018
  }
3760
4019
  itemView(c, i) {
3761
4020
  const detached = this.pins.get(c.id)?.classList.contains("detached") && !isResolved(c);
@@ -3898,6 +4157,28 @@ var Loupe = (() => {
3898
4157
  function clamp(n) {
3899
4158
  return Math.max(0, Math.min(1, n));
3900
4159
  }
4160
+ function fmtAgo(iso) {
4161
+ const then = Date.parse(iso);
4162
+ if (Number.isNaN(then)) return "";
4163
+ const s = Math.max(0, Math.round((Date.now() - then) / 1e3));
4164
+ if (s < 60) return "just now";
4165
+ const m = Math.round(s / 60);
4166
+ if (m < 60) return `${m}m ago`;
4167
+ const h = Math.round(m / 60);
4168
+ if (h < 24) return `${h}h ago`;
4169
+ return `${Math.round(h / 24)}d ago`;
4170
+ }
4171
+ function dayLabel(iso) {
4172
+ const d = new Date(iso);
4173
+ if (Number.isNaN(d.getTime())) return "Earlier";
4174
+ const today = /* @__PURE__ */ new Date();
4175
+ const startOf = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
4176
+ const days = Math.round((startOf(today) - startOf(d)) / 864e5);
4177
+ if (days <= 0) return "Today";
4178
+ if (days === 1) return "Yesterday";
4179
+ if (days < 7) return `${days} days ago`;
4180
+ return d.toLocaleDateString(void 0, { month: "short", day: "numeric", year: "numeric" });
4181
+ }
3901
4182
  function clampPx(n, min, max) {
3902
4183
  return Math.max(min, Math.min(max, n));
3903
4184
  }
@@ -3911,6 +4192,9 @@ var Loupe = (() => {
3911
4192
  function escapeHtml(s) {
3912
4193
  return s.replace(/[&<>"']/g, (m) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[m]);
3913
4194
  }
4195
+ function escapeAttr(s) {
4196
+ return escapeHtml(s);
4197
+ }
3914
4198
  var svg = (inner) => `<svg viewBox="0 0 16 16" width="15" height="15" fill="none" aria-hidden="true">${inner}</svg>`;
3915
4199
  var DOCK_FRAME = `<rect x="1.5" y="2.5" width="13" height="11" rx="1.6" stroke="currentColor" stroke-width="1.3"/>`;
3916
4200
  var I_DOCK_LEFT = svg(`${DOCK_FRAME}<rect x="2.2" y="3.2" width="4" height="9.6" rx="1" fill="currentColor"/>`);