@loupekit/sdk 0.10.13 → 0.10.14

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
@@ -302,11 +302,11 @@ var STYLES = (
302
302
  .tabs .tab:hover { color: var(--ink); background: var(--bg-2); }
303
303
  .tabs .tab.on { color: var(--accent); border-bottom-color: var(--accent); }
304
304
 
305
- /* Three sidebar pages: only the active one shows. */
305
+ /* Only the active page shows. Driven by an "on" class rather than a .tab-<id>
306
+ selector, so host-registered tab ids need no CSS of their own. */
306
307
  .view { display: none; }
307
- .dock.tab-home .home-view { display: block; flex: 1; min-height: 0; overflow-y: auto; }
308
- .dock.tab-comments .comments-view { display: flex; flex-direction: column; flex: 1; min-height: 0; }
309
- .dock.tab-connect .connect-view { display: block; flex: 1; overflow-y: auto; }
308
+ .view.on { display: block; flex: 1; min-height: 0; overflow-y: auto; }
309
+ .view.on.comments-view { display: flex; flex-direction: column; }
310
310
 
311
311
  /* ------------------------------------------------------------- Home overview */
312
312
  .home-view { padding: 12px 12px 18px; }
@@ -456,6 +456,131 @@ var STYLES = (
456
456
  .minbar .mtext b { color: var(--ink); }
457
457
  .minbar .mrestore { flex: none; padding: 0 2px; border: 0; background: transparent; color: var(--accent); font-size: 14px; cursor: pointer; }
458
458
 
459
+ /* ---------------------------------------------------------------- activity monitor */
460
+ .activity-view { padding: 10px 12px 16px; }
461
+ .mon-status { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight: 600; color: var(--ink); }
462
+ .mon-spacer { flex: 1; }
463
+ .mon-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex: none; }
464
+ .mon-status.st-working .mon-dot { background: var(--accent); animation: loupe-pulse 1.4s ease-in-out infinite; }
465
+ .mon-status.st-error .mon-dot { background: var(--pin); }
466
+ .mon-status.st-idle .mon-dot { background: var(--muted); }
467
+ @keyframes loupe-pulse { 0%,100% { opacity: 1; } 50% { opacity: .3; } }
468
+ @media (prefers-reduced-motion: reduce) { .mon-status.st-working .mon-dot { animation: none; } }
469
+ .mon-clear {
470
+ padding: 3px 8px; border: 1px solid var(--line); border-radius: 7px;
471
+ background: var(--bg-2); color: var(--muted); font-size: 11px; cursor: pointer;
472
+ }
473
+ .mon-clear:hover { border-color: var(--accent); color: var(--ink); }
474
+
475
+ .mon-summary { margin-top: 8px; border: 1px solid var(--line); border-radius: 10px; background: var(--bg-2); overflow: hidden; }
476
+ .mon-sum-head {
477
+ display: flex; align-items: center; gap: 8px; width: 100%; padding: 8px 10px;
478
+ border: 0; background: transparent; color: var(--ink); text-align: left; cursor: pointer;
479
+ }
480
+ .mon-sum-title { font-size: 12px; font-weight: 700; }
481
+ .mon-sum-peek { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; color: var(--muted); }
482
+ .mon-caret { color: var(--muted); font-size: 11px; }
483
+ .mon-sum-body { padding: 2px 10px 10px; }
484
+ .mon-kv { display: flex; justify-content: space-between; gap: 10px; padding: 3px 0; font-size: 11.5px; color: var(--muted); }
485
+ .mon-kv b { color: var(--ink); font-weight: 600; text-align: right; }
486
+ .mon-preview {
487
+ margin-top: 8px; padding: 7px 8px; border-radius: 7px; background: var(--bg);
488
+ font-family: ui-monospace, Menlo, monospace; font-size: 10.5px; color: var(--muted);
489
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
490
+ }
491
+ .mon-micro { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 8px; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
492
+ .mon-chips { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
493
+ .mon-chip {
494
+ padding: 3px 8px; border: 1px solid var(--line); border-radius: 999px;
495
+ background: var(--bg-2); color: var(--muted); font-size: 11px; cursor: pointer;
496
+ }
497
+ .mon-chip:hover { border-color: var(--accent); color: var(--ink); }
498
+ .mon-chip.on { border-color: var(--accent); background: var(--accent); color: #fff; }
499
+ .mon-feed {
500
+ margin-top: 8px; max-height: 46vh; overflow-y: auto;
501
+ border: 1px solid var(--line); border-radius: 10px; background: var(--bg);
502
+ }
503
+ .mon-row {
504
+ display: grid; grid-template-columns: 58px 88px 1fr; gap: 6px;
505
+ padding: 5px 8px; border-bottom: 1px solid var(--line); font-size: 11px; line-height: 1.4;
506
+ }
507
+ .mon-row:last-child { border-bottom: 0; }
508
+ .mon-time { color: var(--muted); font-family: ui-monospace, Menlo, monospace; font-variant-numeric: tabular-nums; }
509
+ .mon-kind { color: var(--accent); font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
510
+ .mon-label { color: var(--ink); overflow-wrap: anywhere; }
511
+ .mon-detail { display: block; color: var(--muted); font-family: ui-monospace, Menlo, monospace; font-size: 10.5px; overflow-wrap: anywhere; }
512
+ .mon-row.lv-warn .mon-label { color: #e0a92c; }
513
+ .mon-row.lv-error .mon-label { color: var(--pin); }
514
+ .mon-empty { padding: 16px 12px; text-align: center; font-size: 11.5px; line-height: 1.6; color: var(--muted); }
515
+ .mon-empty code { font-family: ui-monospace, Menlo, monospace; font-size: 10.5px; color: var(--ink); }
516
+
517
+ /* ---------------------------------------------- project manager (repo + environments) */
518
+ .projbar { display: flex; align-items: center; gap: 8px; margin-top: 12px; position: relative; }
519
+ .proj-label { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; font-weight: 700; color: var(--muted); }
520
+ .proj-chip {
521
+ display: flex; align-items: center; gap: 5px; flex: 1; min-width: 0; padding: 5px 9px;
522
+ border: 1px solid var(--line); border-radius: 8px; background: var(--bg-2);
523
+ color: var(--ink); font-size: 11.5px; font-family: ui-monospace, Menlo, monospace; cursor: pointer;
524
+ }
525
+ .proj-chip:hover { border-color: var(--accent); }
526
+ .proj-chip .proj-repo { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; }
527
+ .proj-chip.unset .proj-repo { color: var(--muted); font-style: italic; }
528
+ .proj-caret { color: var(--muted); font-family: inherit; }
529
+ .proj-pop {
530
+ position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 25;
531
+ display: none; max-height: 60vh; overflow-y: auto; padding: 10px;
532
+ background: var(--bg-2); border: 1px solid var(--line); border-radius: 12px; box-shadow: var(--shadow);
533
+ }
534
+ .proj-pop.open { display: block; }
535
+ .pp-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; font-size: 10px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
536
+ .pp-x { padding: 0 4px; border: 0; border-radius: 5px; background: transparent; color: var(--muted); font-size: 12px; line-height: 1; cursor: pointer; }
537
+ .pp-x:hover { background: var(--bg-3); color: var(--ink); }
538
+ .pp-cur { margin-bottom: 8px; font-size: 11.5px; line-height: 1.45; color: var(--muted); }
539
+ .pp-cur b { color: var(--ink); font-family: ui-monospace, Menlo, monospace; }
540
+ .pp-search {
541
+ width: 100%; margin-bottom: 6px; padding: 6px 8px; border: 1px solid var(--line);
542
+ border-radius: 8px; background: var(--bg); color: var(--ink); font-size: 12px;
543
+ }
544
+ .pp-search:focus { outline: none; border-color: var(--accent); }
545
+ .pp-list { display: flex; flex-direction: column; gap: 2px; max-height: 30vh; overflow-y: auto; }
546
+ .pp-item {
547
+ padding: 6px 8px; border: 1px solid transparent; border-radius: 7px; background: transparent;
548
+ color: var(--ink); font-size: 11.5px; font-family: ui-monospace, Menlo, monospace;
549
+ text-align: left; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
550
+ }
551
+ .pp-item:hover { background: var(--bg-3); }
552
+ .pp-item.on { border-color: var(--accent); color: var(--accent); }
553
+ .pp-empty { padding: 8px 6px; font-size: 11px; line-height: 1.5; color: var(--muted); }
554
+ .pp-empty code { font-family: ui-monospace, Menlo, monospace; color: var(--ink); }
555
+ .pp-clear {
556
+ margin-top: 8px; padding: 5px 8px; border: 1px solid var(--line); border-radius: 7px;
557
+ background: var(--bg); color: var(--muted); font-size: 11px; cursor: pointer;
558
+ }
559
+ .pp-clear:hover { border-color: var(--pin); color: var(--pin); }
560
+ .pp-sep { height: 1px; margin: 10px 0; background: var(--line); }
561
+ .pp-env { display: flex; align-items: center; gap: 6px; padding: 4px 6px; border-radius: 7px; }
562
+ .pp-env:hover { background: var(--bg-3); }
563
+ .pp-env-u { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, Menlo, monospace; font-size: 11px; color: var(--ink); }
564
+ .pp-add { display: flex; gap: 6px; margin-top: 6px; }
565
+ .pp-env-url {
566
+ flex: 1; min-width: 0; padding: 6px 8px; border: 1px solid var(--line); border-radius: 8px;
567
+ background: var(--bg); color: var(--ink); font-size: 11.5px;
568
+ }
569
+ .pp-env-url:focus { outline: none; border-color: var(--accent); }
570
+ .pp-add-b {
571
+ flex: none; padding: 6px 10px; border: 1px solid var(--accent); border-radius: 8px;
572
+ background: var(--accent); color: #fff; font-size: 11.5px; font-weight: 600; cursor: pointer;
573
+ }
574
+ .pp-err { margin-top: 6px; font-size: 11px; color: var(--pin); }
575
+ .hver { font-family: ui-monospace, Menlo, monospace; }
576
+ .menu-ver {
577
+ display: flex; align-items: center; justify-content: space-between; gap: 8px;
578
+ margin: 8px 4px 2px; padding-top: 8px; border-top: 1px solid var(--line);
579
+ font-size: 10.5px; color: var(--muted);
580
+ }
581
+ .menu-ver b { color: var(--ink); font-family: ui-monospace, Menlo, monospace; font-weight: 600; }
582
+ .menu-mode { text-transform: uppercase; letter-spacing: .06em; font-size: 9.5px; }
583
+
459
584
  /* ------------------------------------------------------- hint card (once per view) */
460
585
  .hint {
461
586
  position: relative; margin: 8px 12px 0; padding: 10px 28px 10px 11px;
@@ -2676,6 +2801,50 @@ var HttpAdapter = class {
2676
2801
  }
2677
2802
  };
2678
2803
 
2804
+ // ../shared/dist/activity.js
2805
+ var ACTIVITY_STATUS_LABELS = {
2806
+ idle: "Idle",
2807
+ working: "Working",
2808
+ error: "Error"
2809
+ };
2810
+ function formatDuration(ms) {
2811
+ if (!ms || ms < 0)
2812
+ return "\u2014";
2813
+ const s = Math.round(ms / 1e3);
2814
+ if (s < 60)
2815
+ return `${s}s`;
2816
+ const m = Math.floor(s / 60);
2817
+ const rest = s % 60;
2818
+ if (m < 60)
2819
+ return rest ? `${m}m ${rest}s` : `${m}m`;
2820
+ const h = Math.floor(m / 60);
2821
+ return `${h}h ${m % 60}m`;
2822
+ }
2823
+ function summarizeActivity(events, status = "idle", now = Date.now()) {
2824
+ const files = /* @__PURE__ */ new Set();
2825
+ const kinds = /* @__PURE__ */ new Map();
2826
+ let errors = 0;
2827
+ let first = Infinity;
2828
+ for (const e of events) {
2829
+ for (const f of e.files ?? [])
2830
+ files.add(f);
2831
+ kinds.set(e.kind, (kinds.get(e.kind) ?? 0) + 1);
2832
+ if (e.level === "error")
2833
+ errors++;
2834
+ const t = Date.parse(e.at);
2835
+ if (!Number.isNaN(t))
2836
+ first = Math.min(first, t);
2837
+ }
2838
+ return {
2839
+ status,
2840
+ events: events.length,
2841
+ errors,
2842
+ files: files.size,
2843
+ durationMs: events.length && first !== Infinity ? Math.max(0, now - first) : 0,
2844
+ byKind: [...kinds.entries()].map(([kind, count]) => ({ kind, count })).sort((a, b) => b.count - a.count || a.kind.localeCompare(b.kind))
2845
+ };
2846
+ }
2847
+
2679
2848
  // ../shared/dist/index.js
2680
2849
  var COMMENT_STAGES = ["queue", "todo", "in_progress", "in_review", "resolved"];
2681
2850
  var STAGE_LABELS = {
@@ -2723,7 +2892,7 @@ function normalizePriority(value) {
2723
2892
  }
2724
2893
 
2725
2894
  // src/app.ts
2726
- var SDK_VERSION = true ? "0.10.13" : "dev";
2895
+ var SDK_VERSION = true ? "0.10.14" : "dev";
2727
2896
  var ACCENTS = [
2728
2897
  { id: "indigo", dark: "#6b73e6", light: "#4a55d6", soft: "rgba(107,115,230,0.12)" },
2729
2898
  { id: "violet", dark: "#a06be6", light: "#7c3fd4", soft: "rgba(160,107,230,0.14)" },
@@ -2754,17 +2923,28 @@ var TOUR = [
2754
2923
  body: "Inspect picks an element, Note drops a page-level comment, Region captures a rectangle, and Record films one."
2755
2924
  },
2756
2925
  {
2757
- sel: '.tabs [data-tab="connect"]',
2758
- tab: "connect",
2759
- title: "Hand it to Claude",
2760
- body: "Connect Claude wires up the MCP server so an agent reads this feedback with its element context."
2926
+ sel: '.tabs [data-tab="activity"]',
2927
+ tab: "activity",
2928
+ title: "Watch the work happen",
2929
+ body: "Anything an agent bridge or your app reports lands here \u2014 with tool chips to filter it, and Loupe's own operations alongside."
2930
+ },
2931
+ {
2932
+ sel: '.dctl [data-role="settings"]',
2933
+ tab: "activity",
2934
+ title: "Make it yours",
2935
+ body: "Accents, the visibility switches, and Restart tour all live here."
2761
2936
  }
2762
2937
  ];
2763
2938
  var HINTS = {
2764
2939
  home: { title: "Your triage at a glance", body: "The tiles count this page by default. Switch to All for the whole project, or click a tile to jump straight to that bucket." },
2765
2940
  comments: { title: "Pin, note or record", body: "Inspect selects an element, Note comments anywhere on the page, Region screenshots a rectangle, and Record captures video of one." },
2766
- connect: { title: "Claude reads these", body: "Add the MCP server to your client and it can list, read and answer this feedback \u2014 screenshot included." }
2941
+ activity: { title: "Watch the work happen", body: "Every event the bridge or your app reports lands here, alongside Loupe's own operations. Click a tool chip to filter the feed." }
2767
2942
  };
2943
+ var BUILTIN_TABS = [
2944
+ { id: "home", label: "Home" },
2945
+ { id: "comments", label: "Comments" },
2946
+ { id: "activity", label: "Activity" }
2947
+ ];
2768
2948
  var DOCK_MODES = ["left", "right", "bottom", "float"];
2769
2949
  var RECORD_MAX_MS = 2e4;
2770
2950
  var MAX_FILES = 10;
@@ -2857,11 +3037,38 @@ var LoupeApp = class {
2857
3037
  this.tourStep = -1;
2858
3038
  /** The first-run tour has been finished or skipped. */
2859
3039
  this.tourDone = false;
3040
+ /** The live Activity feed — in memory only; it is a monitor, not an archive. */
3041
+ this.activityEvents = [];
3042
+ this.activityStatus = "idle";
3043
+ /** A tool chip the user clicked, which narrows the feed to that kind. */
3044
+ this.activityFilter = "";
3045
+ /** Auto-scroll the feed to the newest event (off while the user is reading). */
3046
+ this.activityFollow = true;
3047
+ /** The summary card's key/value rows are behind this toggle. */
3048
+ this.summaryOpen = false;
3049
+ /** Per-browser project settings: a repo override and the environment URLs. */
3050
+ this.project = { environments: [] };
3051
+ this.projSearch = "";
3052
+ this.projOpen = false;
3053
+ this.projResults = [];
3054
+ this.projError = "";
3055
+ this.envDraft = "";
3056
+ /** Guards against a slow repo search overwriting a newer one. */
3057
+ this.repoSeq = 0;
2860
3058
  /** Float-mode window geometry; (x<=0 && y<=0) → placed on first layout. */
2861
3059
  this.floatRect = { x: 0, y: 0, w: 380, h: 540 };
2862
3060
  this.floatDrag = null;
2863
3061
  this.floatResize = null;
2864
3062
  this.raf = 0;
3063
+ /** Tab id → its hint slot (custom tabs only; the built-ins have named fields). */
3064
+ this.customHintSlots = /* @__PURE__ */ new Map();
3065
+ /** The tab strip, in order. */
3066
+ this.tabList = [];
3067
+ /**
3068
+ * Each page's container, keyed by tab id. Display is driven by an "on" class
3069
+ * rather than a `.tab-<id>` selector, so host-registered ids need no CSS.
3070
+ */
3071
+ this.viewEls = /* @__PURE__ */ new Map();
2865
3072
  // ---- inspector ------------------------------------------------------------
2866
3073
  // Pointer Events, not mouse events: a touch drag on a phone never fires
2867
3074
  // mousemove/mouseup, so Region/Record selection was impossible on touch devices.
@@ -3085,6 +3292,7 @@ var LoupeApp = class {
3085
3292
  }
3086
3293
  async start() {
3087
3294
  this.loadState();
3295
+ this.loadProject();
3088
3296
  this.buildDom();
3089
3297
  if (this.cfg.autoOpen) this.open = true;
3090
3298
  this.applyDockLayout();
@@ -3206,7 +3414,7 @@ var LoupeApp = class {
3206
3414
  setBtn.innerHTML = I_GEAR;
3207
3415
  setBtn.onclick = () => this.toggleMenu(this.settingsMenu);
3208
3416
  this.settingsMenu = el("div", "menu");
3209
- this.settingsMenu.innerHTML = `<div class="menu-label">Appearance</div><div class="acc-dots">` + ACCENTS.map((a) => `<button class="acc-dot" data-accent="${a.id}" style="background:${a.dark}" title="${a.id}" aria-label="${a.id} accent"></button>`).join("") + `</div><div class="menu-sep"></div><div class="menu-label">Show</div><button class="menu-row" data-set="hoverHints" aria-pressed="true"><span>Hover hints</span><span class="sw"></span></button><button class="menu-row" data-set="markersHidden" aria-pressed="true"><span>Markers</span><span class="sw"></span></button><button class="menu-row" data-set="showPaths" aria-pressed="false"><span>Page paths</span><span class="sw"></span></button><div class="menu-sep"></div><button class="menu-row" data-set="tour"><span>Restart tour</span></button>`;
3417
+ this.settingsMenu.innerHTML = `<div class="menu-label">Appearance</div><div class="acc-dots">` + ACCENTS.map((a) => `<button class="acc-dot" data-accent="${a.id}" style="background:${a.dark}" title="${a.id}" aria-label="${a.id} accent"></button>`).join("") + `</div><div class="menu-sep"></div><div class="menu-label">Show</div><button class="menu-row" data-set="hoverHints" aria-pressed="true"><span>Hover hints</span><span class="sw"></span></button><button class="menu-row" data-set="markersHidden" aria-pressed="true"><span>Markers</span><span class="sw"></span></button><button class="menu-row" data-set="showPaths" aria-pressed="false"><span>Page paths</span><span class="sw"></span></button><div class="menu-sep"></div><button class="menu-row" data-set="tour"><span>Restart tour</span></button><div class="menu-ver">Loupe <b>v${escapeHtml(SDK_VERSION)}</b><span class="menu-mode">${this.cfg.apiBase ? "server" : "offline"}</span></div>`;
3210
3418
  this.settingsMenu.querySelectorAll("[data-accent]").forEach((b) => {
3211
3419
  b.onclick = () => this.setAccent(b.dataset.accent);
3212
3420
  });
@@ -3238,14 +3446,14 @@ var LoupeApp = class {
3238
3446
  head.append(brand, ctl);
3239
3447
  this.minBar = el("div", "minbar");
3240
3448
  this.minBar.onclick = () => this.setMinimized(false);
3449
+ this.tabList = [...BUILTIN_TABS, ...(this.cfg.tabs ?? []).map((t) => ({ id: t.id, label: t.label }))];
3241
3450
  const tabs = el("div", "tabs");
3242
- const tabBtn = (key, label) => {
3243
- const b = el("button", "tab", label);
3244
- b.dataset.tab = key;
3245
- b.onclick = () => this.setTab(key);
3246
- return b;
3247
- };
3248
- tabs.append(tabBtn("home", "Home"), tabBtn("comments", "Comments"), tabBtn("connect", "Connect Claude"));
3451
+ for (const t of this.tabList) {
3452
+ const b = el("button", "tab", t.label);
3453
+ b.dataset.tab = t.id;
3454
+ b.onclick = () => this.setTab(t.id);
3455
+ tabs.appendChild(b);
3456
+ }
3249
3457
  const tools = el("div", "tools");
3250
3458
  const inspectBtn = this.toolBtn("\u271B", "Inspect", "inspect");
3251
3459
  inspectBtn.title = "Inspect an element and comment on it";
@@ -3297,15 +3505,160 @@ var LoupeApp = class {
3297
3505
  const commentsView = el("div", "view comments-view");
3298
3506
  this.commentsHint = el("div", "hint-slot");
3299
3507
  commentsView.append(this.commentsHint, tools, listHead, this.listEl, this.buildIntegrations());
3300
- const connectView = this.buildConnectPanel();
3301
- this.connectHint = el("div", "hint-slot");
3302
- connectView.prepend(this.connectHint);
3508
+ const activityView = el("div", "view activity-view");
3509
+ this.activityEl = activityView;
3510
+ const customViews = (this.cfg.tabs ?? []).map((t) => {
3511
+ const view = el("div", "view custom-view");
3512
+ const slot = el("div", "hint-slot");
3513
+ view.appendChild(slot);
3514
+ this.customHintSlots.set(t.id, slot);
3515
+ try {
3516
+ const out = t.render({
3517
+ projectKey: this.cfg.projectKey,
3518
+ apiBase: this.cfg.apiBase,
3519
+ user: this.cfg.user,
3520
+ comments: this.comments,
3521
+ url: this.url,
3522
+ version: SDK_VERSION,
3523
+ track: (event) => this.addActivity(event),
3524
+ open: (id) => this.setTab(id),
3525
+ close: () => this.closeDock()
3526
+ });
3527
+ if (typeof out === "string") {
3528
+ const wrap = el("div", "tab-body");
3529
+ wrap.innerHTML = out;
3530
+ view.append(...Array.from(wrap.childNodes));
3531
+ } else if (out) view.appendChild(out);
3532
+ } catch (e) {
3533
+ view.appendChild(el("div", "empty", `This tab failed to render: ${e instanceof Error ? e.message : String(e)}`));
3534
+ }
3535
+ return view;
3536
+ });
3303
3537
  const resize = el("div", "resize");
3304
3538
  resize.addEventListener("pointerdown", this.onResizeDown);
3305
- dock.append(head, this.minBar, tabs, homeView, commentsView, connectView, resize);
3539
+ dock.append(head, this.minBar, tabs, homeView, commentsView, activityView, ...customViews, resize);
3540
+ for (const [id, view] of [["home", homeView], ["comments", commentsView], ["activity", activityView]]) {
3541
+ this.viewEls.set(id, view);
3542
+ }
3543
+ customViews.forEach((v, i) => this.viewEls.set((this.cfg.tabs ?? [])[i].id, v));
3306
3544
  this.buildHomePanel();
3545
+ this.buildActivityPanel();
3307
3546
  return dock;
3308
3547
  }
3548
+ /**
3549
+ * The Activity view: a status row, a collapsible summary, the tool chips and the
3550
+ * live feed. Everything is re-derived from `activityEvents` on render, so the
3551
+ * summary and the micro-stats can never disagree with the feed below them.
3552
+ */
3553
+ buildActivityPanel() {
3554
+ this.activityEl.innerHTML = `<div class="hint-slot" id="loupe-ahint"></div><div class="mon-status" id="loupe-mon-status"><span class="mon-dot"></span><span class="mon-status-label"></span><span class="mon-spacer"></span><button class="mon-clear" data-role="mon-clear" title="Clear the feed">Clear</button></div><div class="mon-summary" id="loupe-mon-summary"></div><div class="mon-micro" id="loupe-mon-micro"></div><div class="mon-chips" id="loupe-mon-chips"></div><div class="mon-feed" id="loupe-mon-feed"></div>`;
3555
+ this.feedEl = this.activityEl.querySelector("#loupe-mon-feed");
3556
+ this.activityHint = this.activityEl.querySelector("#loupe-ahint");
3557
+ this.activityEl.querySelector('[data-role="mon-clear"]').onclick = () => this.clearActivity();
3558
+ this.feedEl.addEventListener("scroll", () => {
3559
+ const el2 = this.feedEl;
3560
+ this.activityFollow = el2.scrollHeight - el2.scrollTop - el2.clientHeight < 24;
3561
+ this.renderActivityChrome();
3562
+ });
3563
+ this.renderActivity();
3564
+ }
3565
+ // ---- activity feed --------------------------------------------------------
3566
+ /** Push one event. Called by Loupe itself and by the public trackActivity(). */
3567
+ addActivity(input) {
3568
+ const event = {
3569
+ id: input.id ?? `a${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
3570
+ at: input.at ?? (/* @__PURE__ */ new Date()).toISOString(),
3571
+ kind: input.kind,
3572
+ label: input.label,
3573
+ detail: input.detail,
3574
+ level: input.level ?? "info",
3575
+ files: input.files
3576
+ };
3577
+ this.activityEvents.push(event);
3578
+ if (this.activityEvents.length > 500) this.activityEvents = this.activityEvents.slice(-500);
3579
+ this.activityStatus = event.level === "error" ? "error" : "working";
3580
+ this.renderActivity();
3581
+ }
3582
+ setActivityStatus(status) {
3583
+ this.activityStatus = status;
3584
+ this.renderActivity();
3585
+ }
3586
+ clearActivity() {
3587
+ this.activityEvents = [];
3588
+ this.activityFilter = "";
3589
+ this.activityStatus = "idle";
3590
+ this.renderActivity();
3591
+ }
3592
+ /** The summary, status row, micro-stats and chips — derived, never stored. */
3593
+ renderActivityChrome() {
3594
+ if (!this.activityEl) return;
3595
+ const status = this.activityEl.querySelector("#loupe-mon-status");
3596
+ if (!status) return;
3597
+ const s = summarizeActivity(this.activityEvents, this.activityStatus);
3598
+ status.querySelector(".mon-status-label").textContent = ACTIVITY_STATUS_LABELS[s.status];
3599
+ status.className = `mon-status st-${s.status}`;
3600
+ const summary = this.activityEl.querySelector("#loupe-mon-summary");
3601
+ if (!s.events) {
3602
+ summary.innerHTML = "";
3603
+ summary.style.display = "none";
3604
+ } else {
3605
+ summary.style.display = "";
3606
+ const rows = [
3607
+ ["Status", ACTIVITY_STATUS_LABELS[s.status]],
3608
+ ["Events", String(s.events)],
3609
+ ["Duration", formatDuration(s.durationMs)],
3610
+ ["Files touched", String(s.files)],
3611
+ ["Errors", String(s.errors)],
3612
+ ["Tools", s.byKind.map((k) => `${k.kind} \xD7${k.count}`).join(", ") || "\u2014"]
3613
+ ];
3614
+ summary.innerHTML = `<button class="mon-sum-head" data-role="mon-toggle" aria-expanded="${this.summaryOpen}"><span class="mon-sum-title">Session summary</span><span class="mon-sum-peek">${s.events} events \xB7 ${formatDuration(s.durationMs)} \xB7 ${s.files} files</span><span class="mon-caret">${this.summaryOpen ? "\u25BE" : "\u25B8"}</span></button><div class="mon-sum-body"${this.summaryOpen ? "" : ' style="display:none"'}>` + rows.map(([k, v]) => `<div class="mon-kv"><span>${escapeHtml(k)}</span><b>${escapeHtml(v)}</b></div>`).join("") + `<div class="mon-preview"></div></div>`;
3615
+ summary.querySelector(".mon-preview").textContent = this.activityEvents[this.activityEvents.length - 1]?.label ?? "";
3616
+ summary.querySelector('[data-role="mon-toggle"]').onclick = () => {
3617
+ this.summaryOpen = !this.summaryOpen;
3618
+ this.renderActivityChrome();
3619
+ };
3620
+ }
3621
+ const micro = this.activityEl.querySelector("#loupe-mon-micro");
3622
+ micro.textContent = "";
3623
+ if (s.events) {
3624
+ micro.append(
3625
+ el("span", "mon-micro-i", `\u23F1 ${formatDuration(s.durationMs)}`),
3626
+ el("span", "mon-micro-i", `\u25E6 ${s.events} events`),
3627
+ el("span", "mon-micro-i", `\u29C9 ${s.files} files`)
3628
+ );
3629
+ }
3630
+ const chips = this.activityEl.querySelector("#loupe-mon-chips");
3631
+ chips.textContent = "";
3632
+ if (s.events) {
3633
+ const chip = (kind, label, count) => {
3634
+ const b = el("button", "mon-chip" + (this.activityFilter === kind ? " on" : ""), `${label} ${count}`);
3635
+ b.dataset.kind = kind;
3636
+ b.onclick = () => {
3637
+ this.activityFilter = this.activityFilter === kind ? "" : kind;
3638
+ this.renderActivity();
3639
+ };
3640
+ return b;
3641
+ };
3642
+ chips.appendChild(chip("", "All", s.events));
3643
+ for (const { kind, count } of s.byKind) chips.appendChild(chip(kind, kind, count));
3644
+ }
3645
+ }
3646
+ /** The feed itself, newest last (so it grows downward like a log). */
3647
+ renderActivity() {
3648
+ if (!this.activityEl) return;
3649
+ this.renderActivityChrome();
3650
+ if (!this.feedEl) return;
3651
+ const events = this.activityFilter ? this.activityEvents.filter((e) => e.kind === this.activityFilter) : this.activityEvents;
3652
+ if (!events.length) {
3653
+ this.feedEl.innerHTML = `<div class="mon-empty">` + (this.activityEvents.length ? `Nothing from <b>${escapeHtml(this.activityFilter)}</b> yet.` : `<b>Monitor unavailable.</b> Nothing is feeding this view yet.<br>A bridge or your app can push events with <code>Loupe.trackActivity({ kind, label })</code>, and Loupe reports its own operations here as you work.`) + `</div>`;
3654
+ return;
3655
+ }
3656
+ this.feedEl.innerHTML = events.map((e) => {
3657
+ const time = new Date(e.at).toLocaleTimeString(void 0, { hour12: false });
3658
+ return `<div class="mon-row lv-${e.level ?? "info"}"><span class="mon-time">${escapeHtml(time)}</span><span class="mon-kind">${escapeHtml(e.kind)}</span><span class="mon-label">${escapeHtml(e.label)}` + (e.detail ? `<span class="mon-detail">${escapeHtml(e.detail)}</span>` : "") + `</span></div>`;
3659
+ }).join("");
3660
+ if (this.activityFollow) this.feedEl.scrollTop = this.feedEl.scrollHeight;
3661
+ }
3309
3662
  /**
3310
3663
  * The Home overview: four stat tiles over the current scope, a scope switch
3311
3664
  * (this page ↔ the whole project), a one-click way into capture, and the
@@ -3313,7 +3666,11 @@ var LoupeApp = class {
3313
3666
  */
3314
3667
  buildHomePanel() {
3315
3668
  const title = this.cfg.label ?? "Loupe";
3316
- this.homeEl.innerHTML = `<div class="hint-slot" id="loupe-hhint"></div><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>`;
3669
+ this.homeEl.innerHTML = `<div class="hint-slot" id="loupe-hhint"></div><div class="hstat" id="loupe-hstats"></div><div class="hscope"><button class="hscope-b" data-scope="page">This page <b class="hscope-n"></b></button><button class="hscope-b" data-scope="all">All <b class="hscope-n"></b></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="projbar"><span class="proj-label">Project</span><button class="proj-chip" data-role="proj-open" aria-haspopup="dialog" aria-expanded="false"><span class="proj-repo"></span><span class="proj-caret">\u25BE</span></button><div class="proj-pop" id="loupe-proj" role="dialog" aria-label="Project settings"></div></div><div class="hlabel">Recent</div><div class="hfeed" id="loupe-hfeed"></div><div class="hfoot">${escapeHtml(title)} \xB7 <span class="hver">v${escapeHtml(SDK_VERSION)}</span></div>`;
3670
+ this.homeEl.querySelector('[data-role="proj-open"]').onclick = (e) => {
3671
+ e.stopPropagation();
3672
+ this.setProjectOpen(!this.projOpen);
3673
+ };
3317
3674
  this.homeEl.querySelectorAll(".hscope-b").forEach((b) => {
3318
3675
  b.onclick = () => this.setScope(b.dataset.scope === "all" ? "all" : "page");
3319
3676
  });
@@ -3378,7 +3735,20 @@ var LoupeApp = class {
3378
3735
  };
3379
3736
  });
3380
3737
  }
3381
- this.homeEl.querySelectorAll(".hscope-b").forEach((b) => b.classList.toggle("on", b.dataset.scope === this.scope));
3738
+ const allKnown = this.allComments.length > 0 || this.scope === "all";
3739
+ const counts = {
3740
+ page: String(this.comments.length),
3741
+ all: allKnown ? String(this.allComments.length) : "\u22EF"
3742
+ };
3743
+ const labels = { page: "This page", all: "All" };
3744
+ this.homeEl.querySelectorAll(".hscope-b").forEach((b) => {
3745
+ const scope = b.dataset.scope === "all" ? "all" : "page";
3746
+ b.classList.toggle("on", scope === this.scope);
3747
+ b.setAttribute("aria-pressed", String(scope === this.scope));
3748
+ b.setAttribute("aria-label", `${labels[scope]} \u2014 ${counts[scope]} comments`);
3749
+ b.querySelector(".hscope-n").textContent = counts[scope];
3750
+ });
3751
+ this.renderProject();
3382
3752
  const feed = this.homeEl.querySelector("#loupe-hfeed");
3383
3753
  if (!feed) return;
3384
3754
  const recent = [...this.visibleComments].sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 8);
@@ -3398,6 +3768,165 @@ var LoupeApp = class {
3398
3768
  };
3399
3769
  });
3400
3770
  }
3771
+ // ---- project manager (#60) ------------------------------------------------
3772
+ /** The repo new comments are filed against: the panel's choice, else the config. */
3773
+ effectiveRepo() {
3774
+ return this.project.repo || this.cfg.repo || void 0;
3775
+ }
3776
+ setProjectOpen(open) {
3777
+ this.projOpen = open;
3778
+ this.projError = "";
3779
+ if (open) {
3780
+ this.projSearch = "";
3781
+ void this.refreshRepos();
3782
+ }
3783
+ this.renderProject();
3784
+ }
3785
+ /** Repositories to offer — a fixed list, or the host's search function. */
3786
+ async repoChoices() {
3787
+ const source = this.cfg.repos;
3788
+ if (!source) return [];
3789
+ if (Array.isArray(source)) {
3790
+ const q = this.projSearch.trim().toLowerCase();
3791
+ const all = q ? source.filter((r) => r.toLowerCase().includes(q)) : source;
3792
+ return all.slice(0, 50);
3793
+ }
3794
+ try {
3795
+ const out = await source(this.projSearch);
3796
+ return (Array.isArray(out) ? out : []).slice(0, 50);
3797
+ } catch (e) {
3798
+ this.projError = e instanceof Error ? e.message : "Could not load repositories.";
3799
+ return [];
3800
+ }
3801
+ }
3802
+ /**
3803
+ * Re-run the repo search. The sequence guard matters: typing fires overlapping
3804
+ * requests, and without it a slow early reply would overwrite a newer one.
3805
+ */
3806
+ async refreshRepos() {
3807
+ this.projError = "";
3808
+ const seq = ++this.repoSeq;
3809
+ const out = await this.repoChoices();
3810
+ if (seq !== this.repoSeq) return;
3811
+ this.projResults = out;
3812
+ this.renderRepoList();
3813
+ this.renderProjectError();
3814
+ }
3815
+ /** Only the list is rewritten on search, so the input keeps focus while typing. */
3816
+ renderRepoList() {
3817
+ const list = this.homeEl?.querySelector("#loupe-pp-repos");
3818
+ if (!list) return;
3819
+ const repo = this.effectiveRepo();
3820
+ list.innerHTML = this.projResults.length ? this.projResults.map((r) => `<button class="pp-item${r === repo ? " on" : ""}" data-repo="${escapeAttr(r)}" title="${escapeAttr(r)}">${escapeHtml(r)}</button>`).join("") : `<div class="pp-empty">No repositories match.</div>`;
3821
+ list.querySelectorAll("[data-repo]").forEach((b) => {
3822
+ b.onclick = () => {
3823
+ this.project.repo = b.dataset.repo;
3824
+ this.saveProject();
3825
+ this.renderProject();
3826
+ this.renderPins();
3827
+ this.renderList();
3828
+ this.addActivity({ kind: "repo.link", label: `Linked this page to ${b.dataset.repo}` });
3829
+ };
3830
+ });
3831
+ }
3832
+ renderProjectError() {
3833
+ const box = this.homeEl?.querySelector("#loupe-pp-err");
3834
+ if (!box) return;
3835
+ box.textContent = this.projError;
3836
+ box.style.display = this.projError ? "" : "none";
3837
+ }
3838
+ renderProject() {
3839
+ if (!this.homeEl) return;
3840
+ const chip = this.homeEl.querySelector(".proj-chip");
3841
+ const pop = this.homeEl.querySelector("#loupe-proj");
3842
+ if (!chip || !pop) return;
3843
+ const repo = this.effectiveRepo();
3844
+ chip.querySelector(".proj-repo").textContent = repo ?? "no repo linked";
3845
+ chip.classList.toggle("unset", !repo);
3846
+ chip.setAttribute("aria-expanded", String(this.projOpen));
3847
+ if (!this.projOpen) {
3848
+ pop.classList.remove("open");
3849
+ pop.innerHTML = "";
3850
+ return;
3851
+ }
3852
+ pop.classList.add("open");
3853
+ const envs = this.project.environments;
3854
+ pop.innerHTML = `<div class="pp-head"><span>Repository</span><button class="pp-x" data-role="pp-close" aria-label="Close">\u2715</button></div><div class="pp-cur">${repo ? `New comments are filed against <b>${escapeHtml(repo)}</b>.` : "This page is not linked to a repository yet."}</div>` + (this.cfg.repos ? `<input class="pp-search" type="search" placeholder="Search repositories\u2026" value="${escapeAttr(this.projSearch)}" aria-label="Search repositories"><div class="pp-list" id="loupe-pp-repos"></div>` : `<div class="pp-empty">No repository list to search. Pass <code>repos</code> to <code>init()</code> \u2014 a string array, or a function the panel calls with the search text.</div>`) + (repo ? `<button class="pp-clear" data-role="pp-clear">Unlink this page</button>` : "") + `<div class="pp-sep"></div><div class="pp-head"><span>Environments</span></div><div class="pp-list">` + (envs.length ? envs.map((u, i) => `<div class="pp-env"><span class="pp-env-u" title="${escapeAttr(u)}">${escapeHtml(u)}</span><button class="pp-x" data-env-rm="${i}" aria-label="Remove environment">\u2715</button></div>`).join("") : `<div class="pp-empty">No environment URLs yet.</div>`) + `</div><div class="pp-add"><input class="pp-env-url" type="url" placeholder="https://staging.example.com" value="${escapeAttr(this.envDraft)}" aria-label="Environment URL"><button class="pp-add-b" data-role="env-add">Add</button></div><div class="pp-err" id="loupe-pp-err"></div>`;
3855
+ this.renderRepoList();
3856
+ this.renderProjectError();
3857
+ pop.querySelector('[data-role="pp-close"]').onclick = () => this.setProjectOpen(false);
3858
+ pop.querySelector('[data-role="pp-clear"]')?.addEventListener("click", () => {
3859
+ this.project.repo = void 0;
3860
+ this.saveProject();
3861
+ this.renderProject();
3862
+ this.renderPins();
3863
+ this.renderList();
3864
+ });
3865
+ const search = pop.querySelector(".pp-search");
3866
+ if (search) {
3867
+ search.addEventListener("click", (e) => e.stopPropagation());
3868
+ search.oninput = () => {
3869
+ this.projSearch = search.value;
3870
+ void this.refreshRepos();
3871
+ };
3872
+ }
3873
+ pop.querySelectorAll("[data-env-rm]").forEach((b) => {
3874
+ b.onclick = () => {
3875
+ const i = Number(b.dataset.envRm);
3876
+ this.project.environments = this.project.environments.filter((_, n) => n !== i);
3877
+ this.saveProject();
3878
+ this.renderProject();
3879
+ };
3880
+ });
3881
+ const url = pop.querySelector(".pp-env-url");
3882
+ if (url) {
3883
+ url.addEventListener("click", (e) => e.stopPropagation());
3884
+ url.oninput = () => {
3885
+ this.envDraft = url.value;
3886
+ };
3887
+ url.onkeydown = (e) => {
3888
+ if (e.key === "Enter") pop.querySelector('[data-role="env-add"]').click();
3889
+ };
3890
+ }
3891
+ pop.querySelector('[data-role="env-add"]').onclick = () => {
3892
+ const normalized = normalizeEnvUrl(this.envDraft);
3893
+ if (!normalized) {
3894
+ this.projError = "Enter a full http:// or https:// URL.";
3895
+ this.renderProjectError();
3896
+ return;
3897
+ }
3898
+ if (this.project.environments.includes(normalized)) {
3899
+ this.projError = "That environment is already listed.";
3900
+ this.renderProjectError();
3901
+ return;
3902
+ }
3903
+ this.project.environments = [...this.project.environments, normalized];
3904
+ this.envDraft = "";
3905
+ this.projError = "";
3906
+ this.saveProject();
3907
+ this.renderProject();
3908
+ pop.querySelector(".pp-env-url")?.focus();
3909
+ };
3910
+ }
3911
+ /** Per-browser project settings, keyed separately from the dock's UI state. */
3912
+ loadProject() {
3913
+ const fromConfig = (this.cfg.environments ?? []).filter((u) => typeof u === "string");
3914
+ try {
3915
+ const raw = localStorage.getItem(`loupe:project:${this.cfg.projectKey}`);
3916
+ const p = raw ? JSON.parse(raw) : null;
3917
+ const repo = typeof p?.repo === "string" && p.repo ? p.repo : void 0;
3918
+ const stored = Array.isArray(p?.environments) ? p.environments.filter((u) => typeof u === "string") : null;
3919
+ this.project = { repo, environments: stored ?? fromConfig };
3920
+ } catch {
3921
+ this.project = { environments: fromConfig };
3922
+ }
3923
+ }
3924
+ saveProject() {
3925
+ try {
3926
+ localStorage.setItem(`loupe:project:${this.cfg.projectKey}`, JSON.stringify(this.project));
3927
+ } catch {
3928
+ }
3929
+ }
3401
3930
  /** The "INTEGRATES WITH" footer on the Comments page (visual only for now). */
3402
3931
  buildIntegrations() {
3403
3932
  const wrap = el("div", "integrations");
@@ -3419,46 +3948,6 @@ var LoupeApp = class {
3419
3948
  wrap.appendChild(row);
3420
3949
  return wrap;
3421
3950
  }
3422
- /** The "Connect Claude" onboarding page: how to wire the MCP server to this project. */
3423
- buildConnectPanel() {
3424
- const view = el("div", "view connect-view");
3425
- const key = this.cfg.projectKey;
3426
- const apiHint = this.cfg.apiBase ?? "http://localhost:8787";
3427
- const config = JSON.stringify(
3428
- {
3429
- mcpServers: {
3430
- loupe: {
3431
- command: "npx",
3432
- args: ["-y", "@loupekit/mcp"],
3433
- env: { LOUPE_API: apiHint, LOUPE_PROJECT_KEY: key, LOUPE_ADMIN_KEY: "<your project secret>" }
3434
- }
3435
- }
3436
- },
3437
- null,
3438
- 2
3439
- );
3440
- const steps = [
3441
- ["Pin your feedback", "Use Inspect, Region, Record, or Note to leave comments right on the live app."],
3442
- ["Add the Loupe MCP server", "Drop this into your Claude Code config so Claude can read this project's backlog:"],
3443
- ["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."]
3444
- ];
3445
- const hero = el("div", "connect-hero");
3446
- 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>`;
3447
- view.appendChild(hero);
3448
- const list = el("ol", "connect-steps");
3449
- steps.forEach(([t, d], i) => {
3450
- const li = el("li");
3451
- li.innerHTML = `<div class="cstep-t">${i + 1}. ${escapeHtml(t)}</div><div class="cstep-d">${escapeHtml(d)}</div>`;
3452
- if (i === 1) {
3453
- const pre = el("pre", "cstep-code");
3454
- pre.textContent = config;
3455
- li.appendChild(pre);
3456
- }
3457
- list.appendChild(li);
3458
- });
3459
- view.appendChild(list);
3460
- return view;
3461
- }
3462
3951
  /** The floating "recording…" pill with a Stop button (shown only while recording). */
3463
3952
  buildRecBar() {
3464
3953
  const bar = el("button", "recbar");
@@ -3499,13 +3988,16 @@ var LoupeApp = class {
3499
3988
  };
3500
3989
  const markers = mini("markers", I_EYE, "Markers", "Show or hide the markers on this page");
3501
3990
  markers.onclick = () => this.toggleMarkers();
3502
- const connect = mini("connect", I_PLUG, "Connect Claude", "Set up the Claude/MCP connection");
3503
- connect.onclick = () => {
3504
- this.collapseFab();
3505
- this.openDock();
3506
- this.setTab("connect");
3507
- };
3508
- minis.append(comment, note, markers, connect);
3991
+ minis.append(comment, note, markers);
3992
+ if (this.tabList.some((t) => t.id === "connect")) {
3993
+ const connect = mini("connect", I_PLUG, "Connect Claude", "Set up the Claude/MCP connection");
3994
+ connect.onclick = () => {
3995
+ this.collapseFab();
3996
+ this.openDock();
3997
+ this.setTab("connect");
3998
+ };
3999
+ minis.appendChild(connect);
4000
+ }
3509
4001
  const primary = el("button", "launcher");
3510
4002
  primary.title = `Open ${this.cfg.label ?? "Loupe"}`;
3511
4003
  primary.setAttribute("aria-label", `${this.cfg.label ?? "Loupe"} \u2014 quick actions`);
@@ -3920,8 +4412,9 @@ var LoupeApp = class {
3920
4412
  status: "queue",
3921
4413
  priority,
3922
4414
  changeType,
3923
- // Branch-aware threads: the host declares these once in `init()`.
3924
- repo: this.cfg.repo,
4415
+ // Branch-aware threads: the host declares these once in `init()`, and the
4416
+ // panel's project manager can override the repo per browser.
4417
+ repo: this.effectiveRepo(),
3925
4418
  branch: this.cfg.branch,
3926
4419
  kind: target.kind,
3927
4420
  anchor,
@@ -3950,6 +4443,12 @@ var LoupeApp = class {
3950
4443
  this.renderPins();
3951
4444
  this.renderList();
3952
4445
  this.flash(comment.id);
4446
+ this.addActivity({
4447
+ kind: "comment.create",
4448
+ label: `Created \u201C${comment.title || comment.body.split("\n")[0] || "comment"}\u201D`,
4449
+ detail: [normalizePriority(comment.priority), comment.kind ?? "element", comment.repo].filter(Boolean).join(" \xB7 "),
4450
+ files: comment.repo ? [`${comment.repo}/${shortPath(comment.url)}`] : []
4451
+ });
3953
4452
  }
3954
4453
  /** Upload the reporter's picked files. A file that fails is skipped, not fatal. */
3955
4454
  async uploadAttachments(files) {
@@ -4063,6 +4562,7 @@ var LoupeApp = class {
4063
4562
  if (this.scope === "all" && !this.allComments.length) void this.loadAllComments();
4064
4563
  }
4065
4564
  if (tab === "comments") this.renderList();
4565
+ if (tab === "activity") this.renderActivity();
4066
4566
  this.applyDockLayout();
4067
4567
  }
4068
4568
  toggleTheme() {
@@ -4127,19 +4627,21 @@ var LoupeApp = class {
4127
4627
  */
4128
4628
  renderHints() {
4129
4629
  if (!this.homeEl) return;
4630
+ if (this.hintFor === this.tab) return;
4631
+ this.hintFor = this.tab;
4130
4632
  const slots = {
4131
4633
  home: this.homeEl.querySelector("#loupe-hhint"),
4132
4634
  comments: this.commentsHint ?? null,
4133
- connect: this.connectHint ?? null
4635
+ activity: this.activityHint ?? null
4134
4636
  };
4637
+ for (const [id, slot2] of this.customHintSlots) slots[id] = slot2;
4135
4638
  const slot = slots[this.tab];
4136
- if (!slot || this.hintFor === this.tab) return;
4137
- this.hintFor = this.tab;
4639
+ if (!slot) return;
4138
4640
  slot.innerHTML = "";
4139
- if (!this.hoverHints || this.minimized || this.hintsSeen.has(this.tab)) return;
4641
+ const hint = this.hintForTab(this.tab);
4642
+ if (!hint || !this.hoverHints || this.minimized || this.hintsSeen.has(this.tab)) return;
4140
4643
  this.hintsSeen.add(this.tab);
4141
4644
  this.saveState();
4142
- const hint = HINTS[this.tab];
4143
4645
  slot.innerHTML = `<div class="hint"><div class="hint-t">${escapeHtml(hint.title)}</div><div class="hint-b">${escapeHtml(hint.body)}</div><button class="hint-off" data-role="hint-off">Turn off hints</button><button class="hint-x" aria-label="Dismiss hint">\u2715</button></div>`;
4144
4646
  slot.querySelector('[data-role="hint-off"]').onclick = () => {
4145
4647
  this.hoverHints = false;
@@ -4151,6 +4653,11 @@ var LoupeApp = class {
4151
4653
  slot.innerHTML = "";
4152
4654
  };
4153
4655
  }
4656
+ /** The built-in hints, plus any a registered tab declared for itself. */
4657
+ hintForTab(id) {
4658
+ if (id in HINTS) return HINTS[id];
4659
+ return (this.cfg.tabs ?? []).find((t) => t.id === id)?.hint;
4660
+ }
4154
4661
  // ---- guided tour ----------------------------------------------------------
4155
4662
  startTour() {
4156
4663
  this.tourStep = 0;
@@ -4236,7 +4743,8 @@ var LoupeApp = class {
4236
4743
  if (DOCK_MODES.includes(p?.mode)) this.dockMode = p.mode;
4237
4744
  if (typeof p?.open === "boolean") this.open = p.open;
4238
4745
  if (p?.theme === "light" || p?.theme === "dark") this.theme = p.theme;
4239
- if (p?.tab === "home" || p?.tab === "comments" || p?.tab === "connect") this.tab = p.tab;
4746
+ const known = [...BUILTIN_TABS.map((t) => t.id), ...(this.cfg.tabs ?? []).map((t) => t.id)];
4747
+ if (typeof p?.tab === "string" && known.includes(p.tab)) this.tab = p.tab;
4240
4748
  if (p?.scope === "page" || p?.scope === "all") this.scope = p.scope;
4241
4749
  if (typeof p?.statFilter === "string") this.statFilter = p.statFilter;
4242
4750
  if (typeof p?.repoFilter === "string") this.repoFilter = p.repoFilter;
@@ -4287,9 +4795,8 @@ var LoupeApp = class {
4287
4795
  d.classList.toggle("minimized", this.minimized);
4288
4796
  const openCount = this.comments.filter((c) => !isResolved(c)).length;
4289
4797
  this.minBar.innerHTML = `<span class="logo">\u25CE</span><span class="mtext"><b>${openCount}</b> open ${this.scope === "all" ? "in this project" : "on this page"}</span><span class="mrestore" aria-hidden="true">\u25B8</span>`;
4290
- d.classList.toggle("tab-home", this.tab === "home");
4291
- d.classList.toggle("tab-comments", this.tab === "comments");
4292
- d.classList.toggle("tab-connect", this.tab === "connect");
4798
+ for (const t of this.tabList) d.classList.toggle(`tab-${t.id}`, this.tab === t.id);
4799
+ for (const [id, view] of this.viewEls) view.classList.toggle("on", id === this.tab);
4293
4800
  this.renderHome();
4294
4801
  d.querySelectorAll(".tabs .tab").forEach((b) => b.classList.toggle("on", b.dataset.tab === this.tab));
4295
4802
  this.posMenu.querySelectorAll("[data-pos]").forEach((b) => b.classList.toggle("on", b.dataset.pos === this.dockMode));
@@ -4441,6 +4948,10 @@ var LoupeApp = class {
4441
4948
  await this.store.update(c.id, { status });
4442
4949
  this.renderPins();
4443
4950
  this.renderList();
4951
+ this.addActivity({
4952
+ kind: status === "resolved" ? "comment.resolve" : "comment.reopen",
4953
+ label: `${status === "resolved" ? "Resolved" : "Reopened"} \u201C${c.title || c.body.split("\n")[0] || "comment"}\u201D`
4954
+ });
4444
4955
  };
4445
4956
  const del = el("button", "", "Delete");
4446
4957
  del.onclick = async (e) => {
@@ -4450,6 +4961,11 @@ var LoupeApp = class {
4450
4961
  this.resolved.delete(c.id);
4451
4962
  this.renderPins();
4452
4963
  this.renderList();
4964
+ this.addActivity({
4965
+ kind: "comment.delete",
4966
+ label: `Deleted \u201C${c.title || c.body.split("\n")[0] || "comment"}\u201D`,
4967
+ level: "warn"
4968
+ });
4453
4969
  };
4454
4970
  actions.append(doneBtn, del);
4455
4971
  detail.appendChild(actions);
@@ -4542,6 +5058,19 @@ function shortPath(url) {
4542
5058
  return url;
4543
5059
  }
4544
5060
  }
5061
+ function normalizeEnvUrl(raw) {
5062
+ const s = raw.trim();
5063
+ if (!s) return null;
5064
+ try {
5065
+ const u = new URL(s);
5066
+ if (u.protocol !== "http:" && u.protocol !== "https:") return null;
5067
+ if (!u.hostname) return null;
5068
+ const path = u.pathname.replace(/\/+$/, "");
5069
+ return u.origin + path;
5070
+ } catch {
5071
+ return null;
5072
+ }
5073
+ }
4545
5074
  function dayLabel(iso) {
4546
5075
  const d = new Date(iso);
4547
5076
  if (Number.isNaN(d.getTime())) return "Earlier";
@@ -4658,6 +5187,41 @@ function isResolved(c) {
4658
5187
  return normalizeStatus(c.status) === "resolved";
4659
5188
  }
4660
5189
 
5190
+ // src/connect.ts
5191
+ var escapeHtml2 = (s) => s.replace(/[&<>"']/g, (m) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[m]);
5192
+ function connectTab() {
5193
+ return {
5194
+ id: "connect",
5195
+ label: "Connect",
5196
+ hint: {
5197
+ title: "Hand it to Claude",
5198
+ body: "Add the MCP server to your client and it can list, read and answer this feedback \u2014 screenshot included."
5199
+ },
5200
+ render: (ctx) => {
5201
+ const apiHint = ctx.apiBase ?? "http://localhost:8787";
5202
+ const config = JSON.stringify(
5203
+ {
5204
+ mcpServers: {
5205
+ loupe: {
5206
+ command: "npx",
5207
+ args: ["-y", "@loupekit/mcp"],
5208
+ env: { LOUPE_API: apiHint, LOUPE_PROJECT_KEY: ctx.projectKey, LOUPE_ADMIN_KEY: "<your project secret>" }
5209
+ }
5210
+ }
5211
+ },
5212
+ null,
5213
+ 2
5214
+ );
5215
+ const steps = [
5216
+ ["Pin your feedback", "Use Inspect, Region, Record, or Note to leave comments right on the live app."],
5217
+ ["Add the Loupe MCP server", "Drop this into your Claude Code config so Claude can read this project's backlog:"],
5218
+ ["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."]
5219
+ ];
5220
+ return `<div class="connect-hero"><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></div><ol class="connect-steps">` + steps.map(([title, body], i) => `<li><div class="cstep-t">${i + 1}. ${escapeHtml2(title)}</div><div class="cstep-d">${escapeHtml2(body)}</div>` + (i === 1 ? `<pre class="cstep-code">${escapeHtml2(config)}</pre>` : "") + `</li>`).join("") + `</ol>`;
5221
+ }
5222
+ };
5223
+ }
5224
+
4661
5225
  // src/index.ts
4662
5226
  var app = null;
4663
5227
  function init(config) {
@@ -4679,8 +5243,21 @@ function destroy() {
4679
5243
  app?.destroy();
4680
5244
  app = null;
4681
5245
  }
5246
+ function trackActivity(event) {
5247
+ app?.addActivity(event);
5248
+ }
5249
+ function setActivityStatus(status) {
5250
+ app?.setActivityStatus(status);
5251
+ }
5252
+ function clearActivity() {
5253
+ app?.clearActivity();
5254
+ }
4682
5255
  export {
5256
+ clearActivity,
5257
+ connectTab,
4683
5258
  destroy,
4684
- init
5259
+ init,
5260
+ setActivityStatus,
5261
+ trackActivity
4685
5262
  };
4686
5263
  //# sourceMappingURL=index.js.map