@loupekit/sdk 0.10.11 → 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/dist/index.js CHANGED
@@ -300,11 +300,63 @@ var STYLES = (
300
300
  .tabs .tab:hover { color: var(--ink); background: var(--bg-2); }
301
301
  .tabs .tab.on { color: var(--accent); border-bottom-color: var(--accent); }
302
302
 
303
- /* Two sidebar pages: only the active one shows. */
303
+ /* Three sidebar pages: only the active one shows. */
304
304
  .view { display: none; }
305
+ .dock.tab-home .home-view { display: block; flex: 1; min-height: 0; overflow-y: auto; }
305
306
  .dock.tab-comments .comments-view { display: flex; flex-direction: column; flex: 1; min-height: 0; }
306
307
  .dock.tab-connect .connect-view { display: block; flex: 1; overflow-y: auto; }
307
308
 
309
+ /* ------------------------------------------------------------- Home overview */
310
+ .home-view { padding: 12px 12px 18px; }
311
+ .hstat { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
312
+ .hstat-b {
313
+ display: flex; flex-direction: column; gap: 2px; text-align: left; padding: 10px;
314
+ border: 1px solid var(--line); border-radius: 10px; background: var(--bg-2); color: var(--ink); cursor: pointer;
315
+ }
316
+ .hstat-b:hover { border-color: var(--accent); }
317
+ .hstat-b.on { border-color: var(--accent); background: var(--bg-3); }
318
+ .hstat-n { font-size: 20px; font-weight: 700; line-height: 1.1; font-variant-numeric: tabular-nums; }
319
+ .hstat-l { font-size: 11px; color: var(--muted); }
320
+ .hscope { display: flex; align-items: center; gap: 6px; margin-top: 10px; }
321
+ .hscope-b {
322
+ flex: 1; padding: 7px 8px; border: 1px solid var(--line); border-radius: 8px;
323
+ background: var(--bg-2); color: var(--muted); font-size: 12px; font-weight: 600; cursor: pointer;
324
+ }
325
+ .hscope-b.on { background: var(--accent); border-color: var(--accent); color: #fff; }
326
+ .hrefresh {
327
+ flex: none; width: 30px; height: 30px; border: 1px solid var(--line); border-radius: 8px;
328
+ background: var(--bg-2); color: var(--muted); cursor: pointer; font-size: 14px;
329
+ }
330
+ .hrefresh:hover { border-color: var(--accent); color: var(--ink); }
331
+ .hpin {
332
+ margin-top: 10px; width: 100%; padding: 9px; border: 0; border-radius: 9px;
333
+ background: var(--accent); color: #fff; font-size: 13px; font-weight: 600; cursor: pointer;
334
+ }
335
+ .hlabel { margin-top: 14px; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); font-weight: 700; }
336
+ .hfeed { margin-top: 6px; display: flex; flex-direction: column; gap: 4px; }
337
+ .hfeed-i {
338
+ display: flex; flex-direction: column; gap: 3px; text-align: left; padding: 8px;
339
+ border: 1px solid var(--line); border-radius: 8px; background: var(--bg-2); color: var(--ink); cursor: pointer;
340
+ }
341
+ .hfeed-i:hover { border-color: var(--accent); }
342
+ .hfeed-t { font-size: 12.5px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
343
+ .hfeed-m { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 11px; color: var(--muted); }
344
+ .hfeed-s { font-weight: 700; }
345
+ .hfeed-s-resolved { color: #34c281; }
346
+ .hfeed-p { font-weight: 700; }
347
+ .hfeed-p-critical { color: var(--pin); }
348
+ .hfeed-p-high { color: #e0a92c; }
349
+ .hempty { padding: 14px; text-align: center; color: var(--muted); font-size: 12px; }
350
+ .hfoot { margin-top: 14px; padding-top: 10px; border-top: 1px solid var(--line); font-size: 11px; color: var(--muted); text-align: center; }
351
+
352
+ /* Timeline grouping (project scope) + the repo filter. */
353
+ .daylabel { margin: 8px 2px 0; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); font-weight: 700; }
354
+ .listhead .reposel {
355
+ margin-left: 6px; font-size: 11px; padding: 3px 6px; border: 1px solid var(--line);
356
+ border-radius: 7px; background: var(--bg-2); color: var(--ink);
357
+ text-transform: none; letter-spacing: 0; max-width: 130px;
358
+ }
359
+
308
360
  /* recording marker + video in the list */
309
361
  .item .rectag { font-size: 10px; font-weight: 700; color: var(--pin); background: var(--bg-3); border-radius: 999px; padding: 1px 7px; white-space: nowrap; }
310
362
  .item video.shot { width: 100%; border-radius: 6px; margin-top: 8px; border: 1px solid var(--line); background: #000; display: block; }
@@ -2355,6 +2407,20 @@ var LocalStorageAdapter = class {
2355
2407
  async list(projectKey, url) {
2356
2408
  return this.readAll(projectKey, url);
2357
2409
  }
2410
+ /** Every page's comments for this project, newest first — the "All" scope. */
2411
+ async listAll(projectKey) {
2412
+ const prefix = `loupe:${projectKey}:`;
2413
+ const out = [];
2414
+ for (let i = 0; i < localStorage.length; i++) {
2415
+ const k = localStorage.key(i);
2416
+ if (!k || !k.startsWith(prefix)) continue;
2417
+ try {
2418
+ out.push(...JSON.parse(localStorage.getItem(k) || "[]"));
2419
+ } catch {
2420
+ }
2421
+ }
2422
+ return out.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
2423
+ }
2358
2424
  async save(comment) {
2359
2425
  const all = this.readAll(comment.projectKey, comment.url);
2360
2426
  all.push(comment);
@@ -2375,11 +2441,30 @@ var LocalStorageAdapter = class {
2375
2441
  size: file.size
2376
2442
  };
2377
2443
  }
2378
- async update(id, patch) {
2444
+ /**
2445
+ * Keys that hold a comment list. `loupe:dock` also lives under `loupe:` but
2446
+ * holds an OBJECT (panel state), so treating every `loupe:` key as a list made
2447
+ * update()/remove() throw as soon as the panel persisted anything.
2448
+ */
2449
+ commentKeys() {
2450
+ const keys = [];
2379
2451
  for (let i = 0; i < localStorage.length; i++) {
2380
2452
  const k = localStorage.key(i);
2381
- if (!k || !k.startsWith("loupe:")) continue;
2382
- const list = JSON.parse(localStorage.getItem(k) || "[]");
2453
+ if (k && k.startsWith("loupe:") && k !== "loupe:dock") keys.push(k);
2454
+ }
2455
+ return keys;
2456
+ }
2457
+ parseList(key) {
2458
+ try {
2459
+ const value = JSON.parse(localStorage.getItem(key) || "[]");
2460
+ return Array.isArray(value) ? value : [];
2461
+ } catch {
2462
+ return [];
2463
+ }
2464
+ }
2465
+ async update(id, patch) {
2466
+ for (const k of this.commentKeys()) {
2467
+ const list = this.parseList(k);
2383
2468
  const idx = list.findIndex((c) => c.id === id);
2384
2469
  if (idx >= 0) {
2385
2470
  list[idx] = { ...list[idx], ...patch };
@@ -2389,10 +2474,8 @@ var LocalStorageAdapter = class {
2389
2474
  }
2390
2475
  }
2391
2476
  async remove(id) {
2392
- for (let i = 0; i < localStorage.length; i++) {
2393
- const k = localStorage.key(i);
2394
- if (!k || !k.startsWith("loupe:")) continue;
2395
- const list = JSON.parse(localStorage.getItem(k) || "[]");
2477
+ for (const k of this.commentKeys()) {
2478
+ const list = this.parseList(k);
2396
2479
  const next = list.filter((c) => c.id !== id);
2397
2480
  if (next.length !== list.length) {
2398
2481
  localStorage.setItem(k, JSON.stringify(next));
@@ -2428,6 +2511,13 @@ var HttpAdapter = class {
2428
2511
  if (!res.ok) throw new Error(`list failed: ${res.status}`);
2429
2512
  return await res.json();
2430
2513
  }
2514
+ /** Every comment in the project (no page filter) — the "All" scope. */
2515
+ async listAll(projectKey) {
2516
+ const q = new URLSearchParams({ projectKey });
2517
+ const res = await fetch(`${this.base}/v1/comments?${q}`, this.opts({ headers: this.headers() }));
2518
+ if (!res.ok) throw new Error(`listAll failed: ${res.status}`);
2519
+ return await res.json();
2520
+ }
2431
2521
  /** Upload an inline data-URL asset to object storage; return its URL (or the data URL on failure). */
2432
2522
  async uploadBlob(projectKey, data) {
2433
2523
  try {
@@ -2486,6 +2576,13 @@ var HttpAdapter = class {
2486
2576
 
2487
2577
  // ../shared/dist/index.js
2488
2578
  var COMMENT_STAGES = ["queue", "todo", "in_progress", "in_review", "resolved"];
2579
+ var STAGE_LABELS = {
2580
+ queue: "Queue",
2581
+ todo: "To Do",
2582
+ in_progress: "In Progress",
2583
+ in_review: "In Review",
2584
+ resolved: "Resolved"
2585
+ };
2489
2586
  var LEGACY_STATUS = {
2490
2587
  open: "queue",
2491
2588
  in_progress: "in_progress",
@@ -2516,9 +2613,15 @@ var CHANGE_TYPE_LABELS = {
2516
2613
  };
2517
2614
  var DEFAULT_PRIORITY = "medium";
2518
2615
  var DEFAULT_CHANGE_TYPE = "other";
2616
+ function normalizePriority(value) {
2617
+ if (typeof value === "string" && COMMENT_PRIORITIES.includes(value)) {
2618
+ return value;
2619
+ }
2620
+ return DEFAULT_PRIORITY;
2621
+ }
2519
2622
 
2520
2623
  // src/app.ts
2521
- var SDK_VERSION = true ? "0.10.11" : "dev";
2624
+ var SDK_VERSION = true ? "0.10.12" : "dev";
2522
2625
  var DOCK_MODES = ["left", "right", "bottom", "float"];
2523
2626
  var RECORD_MAX_MS = 2e4;
2524
2627
  var MAX_FILES = 10;
@@ -2588,7 +2691,15 @@ var LoupeApp = class {
2588
2691
  this.dockMode = "right";
2589
2692
  this.open = true;
2590
2693
  this.theme = "dark";
2591
- this.tab = "comments";
2694
+ this.tab = "home";
2695
+ /** "page" = this URL only; "all" = the whole project (the timeline scope). */
2696
+ this.scope = "page";
2697
+ /** Every-page comments, fetched the first time the "All" scope is opened. */
2698
+ this.allComments = [];
2699
+ /** A clicked stat tile, which narrows the list. */
2700
+ this.statFilter = "";
2701
+ /** Repo filter, offered once the scope is "all". */
2702
+ this.repoFilter = "";
2592
2703
  /** Float-mode window geometry; (x<=0 && y<=0) → placed on first layout. */
2593
2704
  this.floatRect = { x: 0, y: 0, w: 380, h: 540 };
2594
2705
  this.floatDrag = null;
@@ -2817,6 +2928,8 @@ var LoupeApp = class {
2817
2928
  this.comments = await this.store.list(this.cfg.projectKey, this.url);
2818
2929
  this.renderPins();
2819
2930
  this.renderList();
2931
+ this.renderHome();
2932
+ if (this.scope === "all") void this.loadAllComments();
2820
2933
  this.observe();
2821
2934
  this.watchNavigation();
2822
2935
  if (this.cfg.autoOpen) this.setMode("inspect");
@@ -2914,13 +3027,13 @@ var LoupeApp = class {
2914
3027
  ctl.append(this.themeBtn, closeBtn);
2915
3028
  head.append(brand, ctl);
2916
3029
  const tabs = el("div", "tabs");
2917
- const commentsTab = el("button", "tab", "Comments");
2918
- commentsTab.dataset.tab = "comments";
2919
- commentsTab.onclick = () => this.setTab("comments");
2920
- const connectTab = el("button", "tab", "Connect Claude");
2921
- connectTab.dataset.tab = "connect";
2922
- connectTab.onclick = () => this.setTab("connect");
2923
- tabs.append(commentsTab, connectTab);
3030
+ const tabBtn = (key, label) => {
3031
+ const b = el("button", "tab", label);
3032
+ b.dataset.tab = key;
3033
+ b.onclick = () => this.setTab(key);
3034
+ return b;
3035
+ };
3036
+ tabs.append(tabBtn("home", "Home"), tabBtn("comments", "Comments"), tabBtn("connect", "Connect Claude"));
2924
3037
  const tools = el("div", "tools");
2925
3038
  const inspectBtn = this.toolBtn("\u271B", "Inspect", "inspect");
2926
3039
  inspectBtn.title = "Inspect an element and comment on it";
@@ -2957,15 +3070,119 @@ var LoupeApp = class {
2957
3070
  this.renderList();
2958
3071
  };
2959
3072
  listHead.appendChild(search);
3073
+ this.repoSel = el("select", "reposel");
3074
+ this.repoSel.title = "Filter by repository";
3075
+ this.repoSel.setAttribute("aria-label", "Filter by repository");
3076
+ this.repoSel.onchange = () => {
3077
+ this.repoFilter = this.repoSel.value;
3078
+ this.renderList();
3079
+ };
3080
+ this.repoSel.style.display = "none";
3081
+ listHead.appendChild(this.repoSel);
2960
3082
  this.listEl = el("div", "list");
3083
+ const homeView = el("div", "view home-view");
3084
+ this.homeEl = homeView;
2961
3085
  const commentsView = el("div", "view comments-view");
2962
3086
  commentsView.append(tools, listHead, this.listEl, this.buildIntegrations());
2963
3087
  const connectView = this.buildConnectPanel();
2964
3088
  const resize = el("div", "resize");
2965
3089
  resize.addEventListener("pointerdown", this.onResizeDown);
2966
- dock.append(head, tabs, commentsView, connectView, resize);
3090
+ dock.append(head, tabs, homeView, commentsView, connectView, resize);
3091
+ this.buildHomePanel();
2967
3092
  return dock;
2968
3093
  }
3094
+ /**
3095
+ * The Home overview: four stat tiles over the current scope, a scope switch
3096
+ * (this page ↔ the whole project), a one-click way into capture, and the
3097
+ * most recent feedback. Every tile is a button that narrows the list.
3098
+ */
3099
+ buildHomePanel() {
3100
+ const title = this.cfg.label ?? "Loupe";
3101
+ 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>`;
3102
+ this.homeEl.querySelectorAll(".hscope-b").forEach((b) => {
3103
+ b.onclick = () => this.setScope(b.dataset.scope === "all" ? "all" : "page");
3104
+ });
3105
+ this.homeEl.querySelector('[data-role="home-pin"]').onclick = () => {
3106
+ this.setTab("comments");
3107
+ this.setMode("inspect");
3108
+ };
3109
+ this.homeEl.querySelector(".hrefresh").onclick = () => {
3110
+ void this.reloadComments();
3111
+ if (this.scope === "all") void this.loadAllComments();
3112
+ };
3113
+ }
3114
+ /** The comments the panel is currently looking at (page scope or project scope). */
3115
+ get visibleComments() {
3116
+ return this.scope === "all" ? this.allComments : this.comments;
3117
+ }
3118
+ /** Switch the panel between "this page" and the whole project. */
3119
+ setScope(scope) {
3120
+ this.scope = scope;
3121
+ this.saveState();
3122
+ if (scope === "all" && !this.allComments.length) void this.loadAllComments();
3123
+ this.renderHome();
3124
+ this.renderList();
3125
+ }
3126
+ async loadAllComments() {
3127
+ try {
3128
+ this.allComments = await this.store.listAll(this.cfg.projectKey);
3129
+ this.renderHome();
3130
+ this.renderList();
3131
+ } catch {
3132
+ }
3133
+ }
3134
+ /**
3135
+ * The four buckets a triager actually asks about. "Stale" is open work older
3136
+ * than a week — the thing that quietly rots on a board.
3137
+ */
3138
+ homeStats() {
3139
+ const list = this.visibleComments;
3140
+ const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1e3;
3141
+ return [
3142
+ { key: "open", label: "Open", n: list.filter((c) => normalizeStatus(c.status) !== "resolved").length },
3143
+ { key: "needs_you", label: "Needs you", n: list.filter((c) => normalizeStatus(c.status) === "in_review").length },
3144
+ { key: "resolved", label: "Resolved", n: list.filter((c) => normalizeStatus(c.status) === "resolved").length },
3145
+ {
3146
+ key: "stale",
3147
+ label: "Stale",
3148
+ n: list.filter((c) => normalizeStatus(c.status) !== "resolved" && Date.parse(c.createdAt) < weekAgo).length
3149
+ }
3150
+ ];
3151
+ }
3152
+ renderHome() {
3153
+ if (!this.homeEl) return;
3154
+ const stats = this.homeStats();
3155
+ const statsEl = this.homeEl.querySelector("#loupe-hstats");
3156
+ if (statsEl) {
3157
+ 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("");
3158
+ statsEl.querySelectorAll(".hstat-b").forEach((b) => {
3159
+ b.onclick = () => {
3160
+ const key = b.dataset.stat;
3161
+ this.statFilter = this.statFilter === key ? "" : key;
3162
+ this.setTab("comments");
3163
+ };
3164
+ });
3165
+ }
3166
+ this.homeEl.querySelectorAll(".hscope-b").forEach((b) => b.classList.toggle("on", b.dataset.scope === this.scope));
3167
+ const feed = this.homeEl.querySelector("#loupe-hfeed");
3168
+ if (!feed) return;
3169
+ const recent = [...this.visibleComments].sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 8);
3170
+ feed.innerHTML = recent.length ? recent.map((c) => {
3171
+ const stage = STAGE_LABELS[normalizeStatus(c.status)];
3172
+ const prio = normalizePriority(c.priority);
3173
+ 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>`;
3174
+ }).join("") : `<div class="hempty">Nothing here yet.</div>`;
3175
+ feed.querySelectorAll(".hfeed-i").forEach((b) => {
3176
+ b.onclick = () => {
3177
+ const id = b.dataset.id;
3178
+ this.statFilter = "";
3179
+ this.setTab("comments");
3180
+ this.expanded.add(id);
3181
+ this.renderList();
3182
+ this.flash(id);
3183
+ };
3184
+ });
3185
+ }
2969
3186
  /** The "INTEGRATES WITH" footer on the Comments page (visual only for now). */
2970
3187
  buildIntegrations() {
2971
3188
  const wrap = el("div", "integrations");
@@ -3558,9 +3775,9 @@ var LoupeApp = class {
3558
3775
  this.updateCount();
3559
3776
  this.position();
3560
3777
  }
3561
- updateCount() {
3778
+ updateCount(shown = this.comments.length) {
3779
+ this.countEl.textContent = String(shown);
3562
3780
  const n = this.comments.length;
3563
- this.countEl.textContent = String(n);
3564
3781
  this.fabBadge.textContent = n ? String(n) : "";
3565
3782
  }
3566
3783
  /**
@@ -3619,14 +3836,18 @@ var LoupeApp = class {
3619
3836
  this.saveState();
3620
3837
  this.applyDockLayout();
3621
3838
  }
3622
- /** Switch the sidebar page (Comments ↔ Connect Claude). */
3839
+ /** Switch the sidebar page (Home ↔ Comments ↔ Connect Claude). */
3623
3840
  setTab(tab) {
3624
3841
  if (this.tab === tab) return;
3625
3842
  if (tab !== "comments") this.setMode("off");
3626
3843
  this.tab = tab;
3627
3844
  this.saveState();
3628
- this.applyDockLayout();
3845
+ if (tab === "home") {
3846
+ this.renderHome();
3847
+ if (this.scope === "all" && !this.allComments.length) void this.loadAllComments();
3848
+ }
3629
3849
  if (tab === "comments") this.renderList();
3850
+ this.applyDockLayout();
3630
3851
  }
3631
3852
  toggleTheme() {
3632
3853
  this.theme = this.theme === "dark" ? "light" : "dark";
@@ -3645,7 +3866,10 @@ var LoupeApp = class {
3645
3866
  if (DOCK_MODES.includes(p?.mode)) this.dockMode = p.mode;
3646
3867
  if (typeof p?.open === "boolean") this.open = p.open;
3647
3868
  if (p?.theme === "light" || p?.theme === "dark") this.theme = p.theme;
3648
- if (p?.tab === "comments" || p?.tab === "connect") this.tab = p.tab;
3869
+ if (p?.tab === "home" || p?.tab === "comments" || p?.tab === "connect") this.tab = p.tab;
3870
+ if (p?.scope === "page" || p?.scope === "all") this.scope = p.scope;
3871
+ if (typeof p?.statFilter === "string") this.statFilter = p.statFilter;
3872
+ if (typeof p?.repoFilter === "string") this.repoFilter = p.repoFilter;
3649
3873
  if (p?.float && typeof p.float.w === "number") this.floatRect = { ...this.floatRect, ...p.float };
3650
3874
  if (typeof p?.markersHidden === "boolean") this.markersHidden = p.markersHidden;
3651
3875
  } catch {
@@ -3659,7 +3883,10 @@ var LoupeApp = class {
3659
3883
  theme: this.theme,
3660
3884
  tab: this.tab,
3661
3885
  float: this.floatRect,
3662
- markersHidden: this.markersHidden
3886
+ markersHidden: this.markersHidden,
3887
+ scope: this.scope,
3888
+ statFilter: this.statFilter,
3889
+ repoFilter: this.repoFilter
3663
3890
  }));
3664
3891
  } catch {
3665
3892
  }
@@ -3672,8 +3899,10 @@ var LoupeApp = class {
3672
3899
  const d = this.dock;
3673
3900
  d.classList.toggle("open", this.open);
3674
3901
  for (const m of DOCK_MODES) d.classList.toggle("mode-" + m, this.dockMode === m);
3902
+ d.classList.toggle("tab-home", this.tab === "home");
3675
3903
  d.classList.toggle("tab-comments", this.tab === "comments");
3676
3904
  d.classList.toggle("tab-connect", this.tab === "connect");
3905
+ this.renderHome();
3677
3906
  d.querySelectorAll(".tabs .tab").forEach((b) => b.classList.toggle("on", b.dataset.tab === this.tab));
3678
3907
  if (this.dockMode === "float") {
3679
3908
  const vw = window.innerWidth, vh = window.innerHeight;
@@ -3719,16 +3948,46 @@ var LoupeApp = class {
3719
3948
  renderList() {
3720
3949
  this.listEl.innerHTML = "";
3721
3950
  const q = this.search.trim().toLowerCase();
3722
- const items = q ? this.comments.filter((c) => `${c.title ?? ""} ${c.body} ${c.author?.name ?? ""}`.toLowerCase().includes(q)) : this.comments;
3951
+ const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1e3;
3952
+ let items = this.visibleComments.filter((c) => {
3953
+ const stage = normalizeStatus(c.status);
3954
+ if (this.statFilter === "open" && stage === "resolved") return false;
3955
+ if (this.statFilter === "needs_you" && stage !== "in_review") return false;
3956
+ if (this.statFilter === "resolved" && stage !== "resolved") return false;
3957
+ if (this.statFilter === "stale" && (stage === "resolved" || !(Date.parse(c.createdAt) < weekAgo))) return false;
3958
+ if (this.repoFilter && c.repo !== this.repoFilter) return false;
3959
+ return !q || `${c.title ?? ""} ${c.body} ${c.author?.name ?? ""}`.toLowerCase().includes(q);
3960
+ });
3961
+ if (this.scope === "all") items = [...items].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
3962
+ this.renderRepoFilter();
3723
3963
  if (!items.length) {
3724
3964
  this.listEl.appendChild(el(
3725
3965
  "div",
3726
3966
  "empty",
3727
- 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."
3967
+ 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."
3728
3968
  ));
3729
3969
  }
3730
- items.forEach((c, i) => this.listEl.appendChild(this.itemView(c, i)));
3731
- this.updateCount();
3970
+ let lastDay = "";
3971
+ items.forEach((c, i) => {
3972
+ if (this.scope === "all") {
3973
+ const day = dayLabel(c.createdAt);
3974
+ if (day !== lastDay) {
3975
+ this.listEl.appendChild(el("div", "daylabel", day));
3976
+ lastDay = day;
3977
+ }
3978
+ }
3979
+ this.listEl.appendChild(this.itemView(c, i));
3980
+ });
3981
+ this.updateCount(items.length);
3982
+ }
3983
+ /** Repo filter — only offered in the project scope, where it means something. */
3984
+ renderRepoFilter() {
3985
+ if (!this.repoSel) return;
3986
+ const repos = [...new Set(this.visibleComments.map((c) => c.repo).filter((r) => !!r))].sort();
3987
+ this.repoSel.style.display = this.scope === "all" && repos.length > 1 ? "" : "none";
3988
+ const current = this.repoFilter;
3989
+ this.repoSel.innerHTML = `<option value="">All repos</option>` + repos.map((r) => `<option value="${escapeHtml(r)}">${escapeHtml(r)}</option>`).join("");
3990
+ this.repoSel.value = current;
3732
3991
  }
3733
3992
  itemView(c, i) {
3734
3993
  const detached = this.pins.get(c.id)?.classList.contains("detached") && !isResolved(c);
@@ -3871,6 +4130,28 @@ function el(tag, cls = "", text = "") {
3871
4130
  function clamp(n) {
3872
4131
  return Math.max(0, Math.min(1, n));
3873
4132
  }
4133
+ function fmtAgo(iso) {
4134
+ const then = Date.parse(iso);
4135
+ if (Number.isNaN(then)) return "";
4136
+ const s = Math.max(0, Math.round((Date.now() - then) / 1e3));
4137
+ if (s < 60) return "just now";
4138
+ const m = Math.round(s / 60);
4139
+ if (m < 60) return `${m}m ago`;
4140
+ const h = Math.round(m / 60);
4141
+ if (h < 24) return `${h}h ago`;
4142
+ return `${Math.round(h / 24)}d ago`;
4143
+ }
4144
+ function dayLabel(iso) {
4145
+ const d = new Date(iso);
4146
+ if (Number.isNaN(d.getTime())) return "Earlier";
4147
+ const today = /* @__PURE__ */ new Date();
4148
+ const startOf = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
4149
+ const days = Math.round((startOf(today) - startOf(d)) / 864e5);
4150
+ if (days <= 0) return "Today";
4151
+ if (days === 1) return "Yesterday";
4152
+ if (days < 7) return `${days} days ago`;
4153
+ return d.toLocaleDateString(void 0, { month: "short", day: "numeric", year: "numeric" });
4154
+ }
3874
4155
  function clampPx(n, min, max) {
3875
4156
  return Math.max(min, Math.min(max, n));
3876
4157
  }
@@ -3884,6 +4165,9 @@ function optionEl(text, value, selected) {
3884
4165
  function escapeHtml(s) {
3885
4166
  return s.replace(/[&<>"']/g, (m) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[m]);
3886
4167
  }
4168
+ function escapeAttr(s) {
4169
+ return escapeHtml(s);
4170
+ }
3887
4171
  var svg = (inner) => `<svg viewBox="0 0 16 16" width="15" height="15" fill="none" aria-hidden="true">${inner}</svg>`;
3888
4172
  var DOCK_FRAME = `<rect x="1.5" y="2.5" width="13" height="11" rx="1.6" stroke="currentColor" stroke-width="1.3"/>`;
3889
4173
  var I_DOCK_LEFT = svg(`${DOCK_FRAME}<rect x="2.2" y="3.2" width="4" height="9.6" rx="1" fill="currentColor"/>`);