@loupekit/sdk 0.10.13-next.58 → 0.10.14-next.59

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.
@@ -21,8 +21,12 @@ var Loupe = (() => {
21
21
  // src/index.ts
22
22
  var src_exports = {};
23
23
  __export(src_exports, {
24
+ clearActivity: () => clearActivity,
25
+ connectTab: () => connectTab,
24
26
  destroy: () => destroy,
25
- init: () => init
27
+ init: () => init,
28
+ setActivityStatus: () => setActivityStatus,
29
+ trackActivity: () => trackActivity
26
30
  });
27
31
 
28
32
  // src/styles.ts
@@ -329,11 +333,11 @@ var Loupe = (() => {
329
333
  .tabs .tab:hover { color: var(--ink); background: var(--bg-2); }
330
334
  .tabs .tab.on { color: var(--accent); border-bottom-color: var(--accent); }
331
335
 
332
- /* Three sidebar pages: only the active one shows. */
336
+ /* Only the active page shows. Driven by an "on" class rather than a .tab-<id>
337
+ selector, so host-registered tab ids need no CSS of their own. */
333
338
  .view { display: none; }
334
- .dock.tab-home .home-view { display: block; flex: 1; min-height: 0; overflow-y: auto; }
335
- .dock.tab-comments .comments-view { display: flex; flex-direction: column; flex: 1; min-height: 0; }
336
- .dock.tab-connect .connect-view { display: block; flex: 1; overflow-y: auto; }
339
+ .view.on { display: block; flex: 1; min-height: 0; overflow-y: auto; }
340
+ .view.on.comments-view { display: flex; flex-direction: column; }
337
341
 
338
342
  /* ------------------------------------------------------------- Home overview */
339
343
  .home-view { padding: 12px 12px 18px; }
@@ -483,6 +487,131 @@ var Loupe = (() => {
483
487
  .minbar .mtext b { color: var(--ink); }
484
488
  .minbar .mrestore { flex: none; padding: 0 2px; border: 0; background: transparent; color: var(--accent); font-size: 14px; cursor: pointer; }
485
489
 
490
+ /* ---------------------------------------------------------------- activity monitor */
491
+ .activity-view { padding: 10px 12px 16px; }
492
+ .mon-status { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight: 600; color: var(--ink); }
493
+ .mon-spacer { flex: 1; }
494
+ .mon-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex: none; }
495
+ .mon-status.st-working .mon-dot { background: var(--accent); animation: loupe-pulse 1.4s ease-in-out infinite; }
496
+ .mon-status.st-error .mon-dot { background: var(--pin); }
497
+ .mon-status.st-idle .mon-dot { background: var(--muted); }
498
+ @keyframes loupe-pulse { 0%,100% { opacity: 1; } 50% { opacity: .3; } }
499
+ @media (prefers-reduced-motion: reduce) { .mon-status.st-working .mon-dot { animation: none; } }
500
+ .mon-clear {
501
+ padding: 3px 8px; border: 1px solid var(--line); border-radius: 7px;
502
+ background: var(--bg-2); color: var(--muted); font-size: 11px; cursor: pointer;
503
+ }
504
+ .mon-clear:hover { border-color: var(--accent); color: var(--ink); }
505
+
506
+ .mon-summary { margin-top: 8px; border: 1px solid var(--line); border-radius: 10px; background: var(--bg-2); overflow: hidden; }
507
+ .mon-sum-head {
508
+ display: flex; align-items: center; gap: 8px; width: 100%; padding: 8px 10px;
509
+ border: 0; background: transparent; color: var(--ink); text-align: left; cursor: pointer;
510
+ }
511
+ .mon-sum-title { font-size: 12px; font-weight: 700; }
512
+ .mon-sum-peek { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; color: var(--muted); }
513
+ .mon-caret { color: var(--muted); font-size: 11px; }
514
+ .mon-sum-body { padding: 2px 10px 10px; }
515
+ .mon-kv { display: flex; justify-content: space-between; gap: 10px; padding: 3px 0; font-size: 11.5px; color: var(--muted); }
516
+ .mon-kv b { color: var(--ink); font-weight: 600; text-align: right; }
517
+ .mon-preview {
518
+ margin-top: 8px; padding: 7px 8px; border-radius: 7px; background: var(--bg);
519
+ font-family: ui-monospace, Menlo, monospace; font-size: 10.5px; color: var(--muted);
520
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
521
+ }
522
+ .mon-micro { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 8px; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
523
+ .mon-chips { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
524
+ .mon-chip {
525
+ padding: 3px 8px; border: 1px solid var(--line); border-radius: 999px;
526
+ background: var(--bg-2); color: var(--muted); font-size: 11px; cursor: pointer;
527
+ }
528
+ .mon-chip:hover { border-color: var(--accent); color: var(--ink); }
529
+ .mon-chip.on { border-color: var(--accent); background: var(--accent); color: #fff; }
530
+ .mon-feed {
531
+ margin-top: 8px; max-height: 46vh; overflow-y: auto;
532
+ border: 1px solid var(--line); border-radius: 10px; background: var(--bg);
533
+ }
534
+ .mon-row {
535
+ display: grid; grid-template-columns: 58px 88px 1fr; gap: 6px;
536
+ padding: 5px 8px; border-bottom: 1px solid var(--line); font-size: 11px; line-height: 1.4;
537
+ }
538
+ .mon-row:last-child { border-bottom: 0; }
539
+ .mon-time { color: var(--muted); font-family: ui-monospace, Menlo, monospace; font-variant-numeric: tabular-nums; }
540
+ .mon-kind { color: var(--accent); font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
541
+ .mon-label { color: var(--ink); overflow-wrap: anywhere; }
542
+ .mon-detail { display: block; color: var(--muted); font-family: ui-monospace, Menlo, monospace; font-size: 10.5px; overflow-wrap: anywhere; }
543
+ .mon-row.lv-warn .mon-label { color: #e0a92c; }
544
+ .mon-row.lv-error .mon-label { color: var(--pin); }
545
+ .mon-empty { padding: 16px 12px; text-align: center; font-size: 11.5px; line-height: 1.6; color: var(--muted); }
546
+ .mon-empty code { font-family: ui-monospace, Menlo, monospace; font-size: 10.5px; color: var(--ink); }
547
+
548
+ /* ---------------------------------------------- project manager (repo + environments) */
549
+ .projbar { display: flex; align-items: center; gap: 8px; margin-top: 12px; position: relative; }
550
+ .proj-label { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; font-weight: 700; color: var(--muted); }
551
+ .proj-chip {
552
+ display: flex; align-items: center; gap: 5px; flex: 1; min-width: 0; padding: 5px 9px;
553
+ border: 1px solid var(--line); border-radius: 8px; background: var(--bg-2);
554
+ color: var(--ink); font-size: 11.5px; font-family: ui-monospace, Menlo, monospace; cursor: pointer;
555
+ }
556
+ .proj-chip:hover { border-color: var(--accent); }
557
+ .proj-chip .proj-repo { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; }
558
+ .proj-chip.unset .proj-repo { color: var(--muted); font-style: italic; }
559
+ .proj-caret { color: var(--muted); font-family: inherit; }
560
+ .proj-pop {
561
+ position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 25;
562
+ display: none; max-height: 60vh; overflow-y: auto; padding: 10px;
563
+ background: var(--bg-2); border: 1px solid var(--line); border-radius: 12px; box-shadow: var(--shadow);
564
+ }
565
+ .proj-pop.open { display: block; }
566
+ .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); }
567
+ .pp-x { padding: 0 4px; border: 0; border-radius: 5px; background: transparent; color: var(--muted); font-size: 12px; line-height: 1; cursor: pointer; }
568
+ .pp-x:hover { background: var(--bg-3); color: var(--ink); }
569
+ .pp-cur { margin-bottom: 8px; font-size: 11.5px; line-height: 1.45; color: var(--muted); }
570
+ .pp-cur b { color: var(--ink); font-family: ui-monospace, Menlo, monospace; }
571
+ .pp-search {
572
+ width: 100%; margin-bottom: 6px; padding: 6px 8px; border: 1px solid var(--line);
573
+ border-radius: 8px; background: var(--bg); color: var(--ink); font-size: 12px;
574
+ }
575
+ .pp-search:focus { outline: none; border-color: var(--accent); }
576
+ .pp-list { display: flex; flex-direction: column; gap: 2px; max-height: 30vh; overflow-y: auto; }
577
+ .pp-item {
578
+ padding: 6px 8px; border: 1px solid transparent; border-radius: 7px; background: transparent;
579
+ color: var(--ink); font-size: 11.5px; font-family: ui-monospace, Menlo, monospace;
580
+ text-align: left; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
581
+ }
582
+ .pp-item:hover { background: var(--bg-3); }
583
+ .pp-item.on { border-color: var(--accent); color: var(--accent); }
584
+ .pp-empty { padding: 8px 6px; font-size: 11px; line-height: 1.5; color: var(--muted); }
585
+ .pp-empty code { font-family: ui-monospace, Menlo, monospace; color: var(--ink); }
586
+ .pp-clear {
587
+ margin-top: 8px; padding: 5px 8px; border: 1px solid var(--line); border-radius: 7px;
588
+ background: var(--bg); color: var(--muted); font-size: 11px; cursor: pointer;
589
+ }
590
+ .pp-clear:hover { border-color: var(--pin); color: var(--pin); }
591
+ .pp-sep { height: 1px; margin: 10px 0; background: var(--line); }
592
+ .pp-env { display: flex; align-items: center; gap: 6px; padding: 4px 6px; border-radius: 7px; }
593
+ .pp-env:hover { background: var(--bg-3); }
594
+ .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); }
595
+ .pp-add { display: flex; gap: 6px; margin-top: 6px; }
596
+ .pp-env-url {
597
+ flex: 1; min-width: 0; padding: 6px 8px; border: 1px solid var(--line); border-radius: 8px;
598
+ background: var(--bg); color: var(--ink); font-size: 11.5px;
599
+ }
600
+ .pp-env-url:focus { outline: none; border-color: var(--accent); }
601
+ .pp-add-b {
602
+ flex: none; padding: 6px 10px; border: 1px solid var(--accent); border-radius: 8px;
603
+ background: var(--accent); color: #fff; font-size: 11.5px; font-weight: 600; cursor: pointer;
604
+ }
605
+ .pp-err { margin-top: 6px; font-size: 11px; color: var(--pin); }
606
+ .hver { font-family: ui-monospace, Menlo, monospace; }
607
+ .menu-ver {
608
+ display: flex; align-items: center; justify-content: space-between; gap: 8px;
609
+ margin: 8px 4px 2px; padding-top: 8px; border-top: 1px solid var(--line);
610
+ font-size: 10.5px; color: var(--muted);
611
+ }
612
+ .menu-ver b { color: var(--ink); font-family: ui-monospace, Menlo, monospace; font-weight: 600; }
613
+ .menu-mode { text-transform: uppercase; letter-spacing: .06em; font-size: 9.5px; }
614
+
486
615
  /* ------------------------------------------------------- hint card (once per view) */
487
616
  .hint {
488
617
  position: relative; margin: 8px 12px 0; padding: 10px 28px 10px 11px;
@@ -2703,6 +2832,50 @@ var Loupe = (() => {
2703
2832
  }
2704
2833
  };
2705
2834
 
2835
+ // ../shared/dist/activity.js
2836
+ var ACTIVITY_STATUS_LABELS = {
2837
+ idle: "Idle",
2838
+ working: "Working",
2839
+ error: "Error"
2840
+ };
2841
+ function formatDuration(ms) {
2842
+ if (!ms || ms < 0)
2843
+ return "\u2014";
2844
+ const s = Math.round(ms / 1e3);
2845
+ if (s < 60)
2846
+ return `${s}s`;
2847
+ const m = Math.floor(s / 60);
2848
+ const rest = s % 60;
2849
+ if (m < 60)
2850
+ return rest ? `${m}m ${rest}s` : `${m}m`;
2851
+ const h = Math.floor(m / 60);
2852
+ return `${h}h ${m % 60}m`;
2853
+ }
2854
+ function summarizeActivity(events, status = "idle", now = Date.now()) {
2855
+ const files = /* @__PURE__ */ new Set();
2856
+ const kinds = /* @__PURE__ */ new Map();
2857
+ let errors = 0;
2858
+ let first = Infinity;
2859
+ for (const e of events) {
2860
+ for (const f of e.files ?? [])
2861
+ files.add(f);
2862
+ kinds.set(e.kind, (kinds.get(e.kind) ?? 0) + 1);
2863
+ if (e.level === "error")
2864
+ errors++;
2865
+ const t = Date.parse(e.at);
2866
+ if (!Number.isNaN(t))
2867
+ first = Math.min(first, t);
2868
+ }
2869
+ return {
2870
+ status,
2871
+ events: events.length,
2872
+ errors,
2873
+ files: files.size,
2874
+ durationMs: events.length && first !== Infinity ? Math.max(0, now - first) : 0,
2875
+ byKind: [...kinds.entries()].map(([kind, count]) => ({ kind, count })).sort((a, b) => b.count - a.count || a.kind.localeCompare(b.kind))
2876
+ };
2877
+ }
2878
+
2706
2879
  // ../shared/dist/index.js
2707
2880
  var COMMENT_STAGES = ["queue", "todo", "in_progress", "in_review", "resolved"];
2708
2881
  var STAGE_LABELS = {
@@ -2750,7 +2923,7 @@ var Loupe = (() => {
2750
2923
  }
2751
2924
 
2752
2925
  // src/app.ts
2753
- var SDK_VERSION = true ? "0.10.13" : "dev";
2926
+ var SDK_VERSION = true ? "0.10.14" : "dev";
2754
2927
  var ACCENTS = [
2755
2928
  { id: "indigo", dark: "#6b73e6", light: "#4a55d6", soft: "rgba(107,115,230,0.12)" },
2756
2929
  { id: "violet", dark: "#a06be6", light: "#7c3fd4", soft: "rgba(160,107,230,0.14)" },
@@ -2781,17 +2954,28 @@ var Loupe = (() => {
2781
2954
  body: "Inspect picks an element, Note drops a page-level comment, Region captures a rectangle, and Record films one."
2782
2955
  },
2783
2956
  {
2784
- sel: '.tabs [data-tab="connect"]',
2785
- tab: "connect",
2786
- title: "Hand it to Claude",
2787
- body: "Connect Claude wires up the MCP server so an agent reads this feedback with its element context."
2957
+ sel: '.tabs [data-tab="activity"]',
2958
+ tab: "activity",
2959
+ title: "Watch the work happen",
2960
+ body: "Anything an agent bridge or your app reports lands here \u2014 with tool chips to filter it, and Loupe's own operations alongside."
2961
+ },
2962
+ {
2963
+ sel: '.dctl [data-role="settings"]',
2964
+ tab: "activity",
2965
+ title: "Make it yours",
2966
+ body: "Accents, the visibility switches, and Restart tour all live here."
2788
2967
  }
2789
2968
  ];
2790
2969
  var HINTS = {
2791
2970
  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." },
2792
2971
  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." },
2793
- 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." }
2972
+ 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." }
2794
2973
  };
2974
+ var BUILTIN_TABS = [
2975
+ { id: "home", label: "Home" },
2976
+ { id: "comments", label: "Comments" },
2977
+ { id: "activity", label: "Activity" }
2978
+ ];
2795
2979
  var DOCK_MODES = ["left", "right", "bottom", "float"];
2796
2980
  var RECORD_MAX_MS = 2e4;
2797
2981
  var MAX_FILES = 10;
@@ -2884,11 +3068,38 @@ var Loupe = (() => {
2884
3068
  this.tourStep = -1;
2885
3069
  /** The first-run tour has been finished or skipped. */
2886
3070
  this.tourDone = false;
3071
+ /** The live Activity feed — in memory only; it is a monitor, not an archive. */
3072
+ this.activityEvents = [];
3073
+ this.activityStatus = "idle";
3074
+ /** A tool chip the user clicked, which narrows the feed to that kind. */
3075
+ this.activityFilter = "";
3076
+ /** Auto-scroll the feed to the newest event (off while the user is reading). */
3077
+ this.activityFollow = true;
3078
+ /** The summary card's key/value rows are behind this toggle. */
3079
+ this.summaryOpen = false;
3080
+ /** Per-browser project settings: a repo override and the environment URLs. */
3081
+ this.project = { environments: [] };
3082
+ this.projSearch = "";
3083
+ this.projOpen = false;
3084
+ this.projResults = [];
3085
+ this.projError = "";
3086
+ this.envDraft = "";
3087
+ /** Guards against a slow repo search overwriting a newer one. */
3088
+ this.repoSeq = 0;
2887
3089
  /** Float-mode window geometry; (x<=0 && y<=0) → placed on first layout. */
2888
3090
  this.floatRect = { x: 0, y: 0, w: 380, h: 540 };
2889
3091
  this.floatDrag = null;
2890
3092
  this.floatResize = null;
2891
3093
  this.raf = 0;
3094
+ /** Tab id → its hint slot (custom tabs only; the built-ins have named fields). */
3095
+ this.customHintSlots = /* @__PURE__ */ new Map();
3096
+ /** The tab strip, in order. */
3097
+ this.tabList = [];
3098
+ /**
3099
+ * Each page's container, keyed by tab id. Display is driven by an "on" class
3100
+ * rather than a `.tab-<id>` selector, so host-registered ids need no CSS.
3101
+ */
3102
+ this.viewEls = /* @__PURE__ */ new Map();
2892
3103
  // ---- inspector ------------------------------------------------------------
2893
3104
  // Pointer Events, not mouse events: a touch drag on a phone never fires
2894
3105
  // mousemove/mouseup, so Region/Record selection was impossible on touch devices.
@@ -3112,6 +3323,7 @@ var Loupe = (() => {
3112
3323
  }
3113
3324
  async start() {
3114
3325
  this.loadState();
3326
+ this.loadProject();
3115
3327
  this.buildDom();
3116
3328
  if (this.cfg.autoOpen) this.open = true;
3117
3329
  this.applyDockLayout();
@@ -3233,7 +3445,7 @@ var Loupe = (() => {
3233
3445
  setBtn.innerHTML = I_GEAR;
3234
3446
  setBtn.onclick = () => this.toggleMenu(this.settingsMenu);
3235
3447
  this.settingsMenu = el("div", "menu");
3236
- 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>`;
3448
+ 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>`;
3237
3449
  this.settingsMenu.querySelectorAll("[data-accent]").forEach((b) => {
3238
3450
  b.onclick = () => this.setAccent(b.dataset.accent);
3239
3451
  });
@@ -3265,14 +3477,14 @@ var Loupe = (() => {
3265
3477
  head.append(brand, ctl);
3266
3478
  this.minBar = el("div", "minbar");
3267
3479
  this.minBar.onclick = () => this.setMinimized(false);
3480
+ this.tabList = [...BUILTIN_TABS, ...(this.cfg.tabs ?? []).map((t) => ({ id: t.id, label: t.label }))];
3268
3481
  const tabs = el("div", "tabs");
3269
- const tabBtn = (key, label) => {
3270
- const b = el("button", "tab", label);
3271
- b.dataset.tab = key;
3272
- b.onclick = () => this.setTab(key);
3273
- return b;
3274
- };
3275
- tabs.append(tabBtn("home", "Home"), tabBtn("comments", "Comments"), tabBtn("connect", "Connect Claude"));
3482
+ for (const t of this.tabList) {
3483
+ const b = el("button", "tab", t.label);
3484
+ b.dataset.tab = t.id;
3485
+ b.onclick = () => this.setTab(t.id);
3486
+ tabs.appendChild(b);
3487
+ }
3276
3488
  const tools = el("div", "tools");
3277
3489
  const inspectBtn = this.toolBtn("\u271B", "Inspect", "inspect");
3278
3490
  inspectBtn.title = "Inspect an element and comment on it";
@@ -3324,15 +3536,160 @@ var Loupe = (() => {
3324
3536
  const commentsView = el("div", "view comments-view");
3325
3537
  this.commentsHint = el("div", "hint-slot");
3326
3538
  commentsView.append(this.commentsHint, tools, listHead, this.listEl, this.buildIntegrations());
3327
- const connectView = this.buildConnectPanel();
3328
- this.connectHint = el("div", "hint-slot");
3329
- connectView.prepend(this.connectHint);
3539
+ const activityView = el("div", "view activity-view");
3540
+ this.activityEl = activityView;
3541
+ const customViews = (this.cfg.tabs ?? []).map((t) => {
3542
+ const view = el("div", "view custom-view");
3543
+ const slot = el("div", "hint-slot");
3544
+ view.appendChild(slot);
3545
+ this.customHintSlots.set(t.id, slot);
3546
+ try {
3547
+ const out = t.render({
3548
+ projectKey: this.cfg.projectKey,
3549
+ apiBase: this.cfg.apiBase,
3550
+ user: this.cfg.user,
3551
+ comments: this.comments,
3552
+ url: this.url,
3553
+ version: SDK_VERSION,
3554
+ track: (event) => this.addActivity(event),
3555
+ open: (id) => this.setTab(id),
3556
+ close: () => this.closeDock()
3557
+ });
3558
+ if (typeof out === "string") {
3559
+ const wrap = el("div", "tab-body");
3560
+ wrap.innerHTML = out;
3561
+ view.append(...Array.from(wrap.childNodes));
3562
+ } else if (out) view.appendChild(out);
3563
+ } catch (e) {
3564
+ view.appendChild(el("div", "empty", `This tab failed to render: ${e instanceof Error ? e.message : String(e)}`));
3565
+ }
3566
+ return view;
3567
+ });
3330
3568
  const resize = el("div", "resize");
3331
3569
  resize.addEventListener("pointerdown", this.onResizeDown);
3332
- dock.append(head, this.minBar, tabs, homeView, commentsView, connectView, resize);
3570
+ dock.append(head, this.minBar, tabs, homeView, commentsView, activityView, ...customViews, resize);
3571
+ for (const [id, view] of [["home", homeView], ["comments", commentsView], ["activity", activityView]]) {
3572
+ this.viewEls.set(id, view);
3573
+ }
3574
+ customViews.forEach((v, i) => this.viewEls.set((this.cfg.tabs ?? [])[i].id, v));
3333
3575
  this.buildHomePanel();
3576
+ this.buildActivityPanel();
3334
3577
  return dock;
3335
3578
  }
3579
+ /**
3580
+ * The Activity view: a status row, a collapsible summary, the tool chips and the
3581
+ * live feed. Everything is re-derived from `activityEvents` on render, so the
3582
+ * summary and the micro-stats can never disagree with the feed below them.
3583
+ */
3584
+ buildActivityPanel() {
3585
+ 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>`;
3586
+ this.feedEl = this.activityEl.querySelector("#loupe-mon-feed");
3587
+ this.activityHint = this.activityEl.querySelector("#loupe-ahint");
3588
+ this.activityEl.querySelector('[data-role="mon-clear"]').onclick = () => this.clearActivity();
3589
+ this.feedEl.addEventListener("scroll", () => {
3590
+ const el2 = this.feedEl;
3591
+ this.activityFollow = el2.scrollHeight - el2.scrollTop - el2.clientHeight < 24;
3592
+ this.renderActivityChrome();
3593
+ });
3594
+ this.renderActivity();
3595
+ }
3596
+ // ---- activity feed --------------------------------------------------------
3597
+ /** Push one event. Called by Loupe itself and by the public trackActivity(). */
3598
+ addActivity(input) {
3599
+ const event = {
3600
+ id: input.id ?? `a${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
3601
+ at: input.at ?? (/* @__PURE__ */ new Date()).toISOString(),
3602
+ kind: input.kind,
3603
+ label: input.label,
3604
+ detail: input.detail,
3605
+ level: input.level ?? "info",
3606
+ files: input.files
3607
+ };
3608
+ this.activityEvents.push(event);
3609
+ if (this.activityEvents.length > 500) this.activityEvents = this.activityEvents.slice(-500);
3610
+ this.activityStatus = event.level === "error" ? "error" : "working";
3611
+ this.renderActivity();
3612
+ }
3613
+ setActivityStatus(status) {
3614
+ this.activityStatus = status;
3615
+ this.renderActivity();
3616
+ }
3617
+ clearActivity() {
3618
+ this.activityEvents = [];
3619
+ this.activityFilter = "";
3620
+ this.activityStatus = "idle";
3621
+ this.renderActivity();
3622
+ }
3623
+ /** The summary, status row, micro-stats and chips — derived, never stored. */
3624
+ renderActivityChrome() {
3625
+ if (!this.activityEl) return;
3626
+ const status = this.activityEl.querySelector("#loupe-mon-status");
3627
+ if (!status) return;
3628
+ const s = summarizeActivity(this.activityEvents, this.activityStatus);
3629
+ status.querySelector(".mon-status-label").textContent = ACTIVITY_STATUS_LABELS[s.status];
3630
+ status.className = `mon-status st-${s.status}`;
3631
+ const summary = this.activityEl.querySelector("#loupe-mon-summary");
3632
+ if (!s.events) {
3633
+ summary.innerHTML = "";
3634
+ summary.style.display = "none";
3635
+ } else {
3636
+ summary.style.display = "";
3637
+ const rows = [
3638
+ ["Status", ACTIVITY_STATUS_LABELS[s.status]],
3639
+ ["Events", String(s.events)],
3640
+ ["Duration", formatDuration(s.durationMs)],
3641
+ ["Files touched", String(s.files)],
3642
+ ["Errors", String(s.errors)],
3643
+ ["Tools", s.byKind.map((k) => `${k.kind} \xD7${k.count}`).join(", ") || "\u2014"]
3644
+ ];
3645
+ 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>`;
3646
+ summary.querySelector(".mon-preview").textContent = this.activityEvents[this.activityEvents.length - 1]?.label ?? "";
3647
+ summary.querySelector('[data-role="mon-toggle"]').onclick = () => {
3648
+ this.summaryOpen = !this.summaryOpen;
3649
+ this.renderActivityChrome();
3650
+ };
3651
+ }
3652
+ const micro = this.activityEl.querySelector("#loupe-mon-micro");
3653
+ micro.textContent = "";
3654
+ if (s.events) {
3655
+ micro.append(
3656
+ el("span", "mon-micro-i", `\u23F1 ${formatDuration(s.durationMs)}`),
3657
+ el("span", "mon-micro-i", `\u25E6 ${s.events} events`),
3658
+ el("span", "mon-micro-i", `\u29C9 ${s.files} files`)
3659
+ );
3660
+ }
3661
+ const chips = this.activityEl.querySelector("#loupe-mon-chips");
3662
+ chips.textContent = "";
3663
+ if (s.events) {
3664
+ const chip = (kind, label, count) => {
3665
+ const b = el("button", "mon-chip" + (this.activityFilter === kind ? " on" : ""), `${label} ${count}`);
3666
+ b.dataset.kind = kind;
3667
+ b.onclick = () => {
3668
+ this.activityFilter = this.activityFilter === kind ? "" : kind;
3669
+ this.renderActivity();
3670
+ };
3671
+ return b;
3672
+ };
3673
+ chips.appendChild(chip("", "All", s.events));
3674
+ for (const { kind, count } of s.byKind) chips.appendChild(chip(kind, kind, count));
3675
+ }
3676
+ }
3677
+ /** The feed itself, newest last (so it grows downward like a log). */
3678
+ renderActivity() {
3679
+ if (!this.activityEl) return;
3680
+ this.renderActivityChrome();
3681
+ if (!this.feedEl) return;
3682
+ const events = this.activityFilter ? this.activityEvents.filter((e) => e.kind === this.activityFilter) : this.activityEvents;
3683
+ if (!events.length) {
3684
+ 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>`;
3685
+ return;
3686
+ }
3687
+ this.feedEl.innerHTML = events.map((e) => {
3688
+ const time = new Date(e.at).toLocaleTimeString(void 0, { hour12: false });
3689
+ 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>`;
3690
+ }).join("");
3691
+ if (this.activityFollow) this.feedEl.scrollTop = this.feedEl.scrollHeight;
3692
+ }
3336
3693
  /**
3337
3694
  * The Home overview: four stat tiles over the current scope, a scope switch
3338
3695
  * (this page ↔ the whole project), a one-click way into capture, and the
@@ -3340,7 +3697,11 @@ var Loupe = (() => {
3340
3697
  */
3341
3698
  buildHomePanel() {
3342
3699
  const title = this.cfg.label ?? "Loupe";
3343
- 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>`;
3700
+ 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>`;
3701
+ this.homeEl.querySelector('[data-role="proj-open"]').onclick = (e) => {
3702
+ e.stopPropagation();
3703
+ this.setProjectOpen(!this.projOpen);
3704
+ };
3344
3705
  this.homeEl.querySelectorAll(".hscope-b").forEach((b) => {
3345
3706
  b.onclick = () => this.setScope(b.dataset.scope === "all" ? "all" : "page");
3346
3707
  });
@@ -3405,7 +3766,20 @@ var Loupe = (() => {
3405
3766
  };
3406
3767
  });
3407
3768
  }
3408
- this.homeEl.querySelectorAll(".hscope-b").forEach((b) => b.classList.toggle("on", b.dataset.scope === this.scope));
3769
+ const allKnown = this.allComments.length > 0 || this.scope === "all";
3770
+ const counts = {
3771
+ page: String(this.comments.length),
3772
+ all: allKnown ? String(this.allComments.length) : "\u22EF"
3773
+ };
3774
+ const labels = { page: "This page", all: "All" };
3775
+ this.homeEl.querySelectorAll(".hscope-b").forEach((b) => {
3776
+ const scope = b.dataset.scope === "all" ? "all" : "page";
3777
+ b.classList.toggle("on", scope === this.scope);
3778
+ b.setAttribute("aria-pressed", String(scope === this.scope));
3779
+ b.setAttribute("aria-label", `${labels[scope]} \u2014 ${counts[scope]} comments`);
3780
+ b.querySelector(".hscope-n").textContent = counts[scope];
3781
+ });
3782
+ this.renderProject();
3409
3783
  const feed = this.homeEl.querySelector("#loupe-hfeed");
3410
3784
  if (!feed) return;
3411
3785
  const recent = [...this.visibleComments].sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 8);
@@ -3425,6 +3799,165 @@ var Loupe = (() => {
3425
3799
  };
3426
3800
  });
3427
3801
  }
3802
+ // ---- project manager (#60) ------------------------------------------------
3803
+ /** The repo new comments are filed against: the panel's choice, else the config. */
3804
+ effectiveRepo() {
3805
+ return this.project.repo || this.cfg.repo || void 0;
3806
+ }
3807
+ setProjectOpen(open) {
3808
+ this.projOpen = open;
3809
+ this.projError = "";
3810
+ if (open) {
3811
+ this.projSearch = "";
3812
+ void this.refreshRepos();
3813
+ }
3814
+ this.renderProject();
3815
+ }
3816
+ /** Repositories to offer — a fixed list, or the host's search function. */
3817
+ async repoChoices() {
3818
+ const source = this.cfg.repos;
3819
+ if (!source) return [];
3820
+ if (Array.isArray(source)) {
3821
+ const q = this.projSearch.trim().toLowerCase();
3822
+ const all = q ? source.filter((r) => r.toLowerCase().includes(q)) : source;
3823
+ return all.slice(0, 50);
3824
+ }
3825
+ try {
3826
+ const out = await source(this.projSearch);
3827
+ return (Array.isArray(out) ? out : []).slice(0, 50);
3828
+ } catch (e) {
3829
+ this.projError = e instanceof Error ? e.message : "Could not load repositories.";
3830
+ return [];
3831
+ }
3832
+ }
3833
+ /**
3834
+ * Re-run the repo search. The sequence guard matters: typing fires overlapping
3835
+ * requests, and without it a slow early reply would overwrite a newer one.
3836
+ */
3837
+ async refreshRepos() {
3838
+ this.projError = "";
3839
+ const seq = ++this.repoSeq;
3840
+ const out = await this.repoChoices();
3841
+ if (seq !== this.repoSeq) return;
3842
+ this.projResults = out;
3843
+ this.renderRepoList();
3844
+ this.renderProjectError();
3845
+ }
3846
+ /** Only the list is rewritten on search, so the input keeps focus while typing. */
3847
+ renderRepoList() {
3848
+ const list = this.homeEl?.querySelector("#loupe-pp-repos");
3849
+ if (!list) return;
3850
+ const repo = this.effectiveRepo();
3851
+ 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>`;
3852
+ list.querySelectorAll("[data-repo]").forEach((b) => {
3853
+ b.onclick = () => {
3854
+ this.project.repo = b.dataset.repo;
3855
+ this.saveProject();
3856
+ this.renderProject();
3857
+ this.renderPins();
3858
+ this.renderList();
3859
+ this.addActivity({ kind: "repo.link", label: `Linked this page to ${b.dataset.repo}` });
3860
+ };
3861
+ });
3862
+ }
3863
+ renderProjectError() {
3864
+ const box = this.homeEl?.querySelector("#loupe-pp-err");
3865
+ if (!box) return;
3866
+ box.textContent = this.projError;
3867
+ box.style.display = this.projError ? "" : "none";
3868
+ }
3869
+ renderProject() {
3870
+ if (!this.homeEl) return;
3871
+ const chip = this.homeEl.querySelector(".proj-chip");
3872
+ const pop = this.homeEl.querySelector("#loupe-proj");
3873
+ if (!chip || !pop) return;
3874
+ const repo = this.effectiveRepo();
3875
+ chip.querySelector(".proj-repo").textContent = repo ?? "no repo linked";
3876
+ chip.classList.toggle("unset", !repo);
3877
+ chip.setAttribute("aria-expanded", String(this.projOpen));
3878
+ if (!this.projOpen) {
3879
+ pop.classList.remove("open");
3880
+ pop.innerHTML = "";
3881
+ return;
3882
+ }
3883
+ pop.classList.add("open");
3884
+ const envs = this.project.environments;
3885
+ 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>`;
3886
+ this.renderRepoList();
3887
+ this.renderProjectError();
3888
+ pop.querySelector('[data-role="pp-close"]').onclick = () => this.setProjectOpen(false);
3889
+ pop.querySelector('[data-role="pp-clear"]')?.addEventListener("click", () => {
3890
+ this.project.repo = void 0;
3891
+ this.saveProject();
3892
+ this.renderProject();
3893
+ this.renderPins();
3894
+ this.renderList();
3895
+ });
3896
+ const search = pop.querySelector(".pp-search");
3897
+ if (search) {
3898
+ search.addEventListener("click", (e) => e.stopPropagation());
3899
+ search.oninput = () => {
3900
+ this.projSearch = search.value;
3901
+ void this.refreshRepos();
3902
+ };
3903
+ }
3904
+ pop.querySelectorAll("[data-env-rm]").forEach((b) => {
3905
+ b.onclick = () => {
3906
+ const i = Number(b.dataset.envRm);
3907
+ this.project.environments = this.project.environments.filter((_, n) => n !== i);
3908
+ this.saveProject();
3909
+ this.renderProject();
3910
+ };
3911
+ });
3912
+ const url = pop.querySelector(".pp-env-url");
3913
+ if (url) {
3914
+ url.addEventListener("click", (e) => e.stopPropagation());
3915
+ url.oninput = () => {
3916
+ this.envDraft = url.value;
3917
+ };
3918
+ url.onkeydown = (e) => {
3919
+ if (e.key === "Enter") pop.querySelector('[data-role="env-add"]').click();
3920
+ };
3921
+ }
3922
+ pop.querySelector('[data-role="env-add"]').onclick = () => {
3923
+ const normalized = normalizeEnvUrl(this.envDraft);
3924
+ if (!normalized) {
3925
+ this.projError = "Enter a full http:// or https:// URL.";
3926
+ this.renderProjectError();
3927
+ return;
3928
+ }
3929
+ if (this.project.environments.includes(normalized)) {
3930
+ this.projError = "That environment is already listed.";
3931
+ this.renderProjectError();
3932
+ return;
3933
+ }
3934
+ this.project.environments = [...this.project.environments, normalized];
3935
+ this.envDraft = "";
3936
+ this.projError = "";
3937
+ this.saveProject();
3938
+ this.renderProject();
3939
+ pop.querySelector(".pp-env-url")?.focus();
3940
+ };
3941
+ }
3942
+ /** Per-browser project settings, keyed separately from the dock's UI state. */
3943
+ loadProject() {
3944
+ const fromConfig = (this.cfg.environments ?? []).filter((u) => typeof u === "string");
3945
+ try {
3946
+ const raw = localStorage.getItem(`loupe:project:${this.cfg.projectKey}`);
3947
+ const p = raw ? JSON.parse(raw) : null;
3948
+ const repo = typeof p?.repo === "string" && p.repo ? p.repo : void 0;
3949
+ const stored = Array.isArray(p?.environments) ? p.environments.filter((u) => typeof u === "string") : null;
3950
+ this.project = { repo, environments: stored ?? fromConfig };
3951
+ } catch {
3952
+ this.project = { environments: fromConfig };
3953
+ }
3954
+ }
3955
+ saveProject() {
3956
+ try {
3957
+ localStorage.setItem(`loupe:project:${this.cfg.projectKey}`, JSON.stringify(this.project));
3958
+ } catch {
3959
+ }
3960
+ }
3428
3961
  /** The "INTEGRATES WITH" footer on the Comments page (visual only for now). */
3429
3962
  buildIntegrations() {
3430
3963
  const wrap = el("div", "integrations");
@@ -3446,46 +3979,6 @@ var Loupe = (() => {
3446
3979
  wrap.appendChild(row);
3447
3980
  return wrap;
3448
3981
  }
3449
- /** The "Connect Claude" onboarding page: how to wire the MCP server to this project. */
3450
- buildConnectPanel() {
3451
- const view = el("div", "view connect-view");
3452
- const key = this.cfg.projectKey;
3453
- const apiHint = this.cfg.apiBase ?? "http://localhost:8787";
3454
- const config = JSON.stringify(
3455
- {
3456
- mcpServers: {
3457
- loupe: {
3458
- command: "npx",
3459
- args: ["-y", "@loupekit/mcp"],
3460
- env: { LOUPE_API: apiHint, LOUPE_PROJECT_KEY: key, LOUPE_ADMIN_KEY: "<your project secret>" }
3461
- }
3462
- }
3463
- },
3464
- null,
3465
- 2
3466
- );
3467
- const steps = [
3468
- ["Pin your feedback", "Use Inspect, Region, Record, or Note to leave comments right on the live app."],
3469
- ["Add the Loupe MCP server", "Drop this into your Claude Code config so Claude can read this project's backlog:"],
3470
- ["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."]
3471
- ];
3472
- const hero = el("div", "connect-hero");
3473
- 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>`;
3474
- view.appendChild(hero);
3475
- const list = el("ol", "connect-steps");
3476
- steps.forEach(([t, d], i) => {
3477
- const li = el("li");
3478
- li.innerHTML = `<div class="cstep-t">${i + 1}. ${escapeHtml(t)}</div><div class="cstep-d">${escapeHtml(d)}</div>`;
3479
- if (i === 1) {
3480
- const pre = el("pre", "cstep-code");
3481
- pre.textContent = config;
3482
- li.appendChild(pre);
3483
- }
3484
- list.appendChild(li);
3485
- });
3486
- view.appendChild(list);
3487
- return view;
3488
- }
3489
3982
  /** The floating "recording…" pill with a Stop button (shown only while recording). */
3490
3983
  buildRecBar() {
3491
3984
  const bar = el("button", "recbar");
@@ -3526,13 +4019,16 @@ var Loupe = (() => {
3526
4019
  };
3527
4020
  const markers = mini("markers", I_EYE, "Markers", "Show or hide the markers on this page");
3528
4021
  markers.onclick = () => this.toggleMarkers();
3529
- const connect = mini("connect", I_PLUG, "Connect Claude", "Set up the Claude/MCP connection");
3530
- connect.onclick = () => {
3531
- this.collapseFab();
3532
- this.openDock();
3533
- this.setTab("connect");
3534
- };
3535
- minis.append(comment, note, markers, connect);
4022
+ minis.append(comment, note, markers);
4023
+ if (this.tabList.some((t) => t.id === "connect")) {
4024
+ const connect = mini("connect", I_PLUG, "Connect Claude", "Set up the Claude/MCP connection");
4025
+ connect.onclick = () => {
4026
+ this.collapseFab();
4027
+ this.openDock();
4028
+ this.setTab("connect");
4029
+ };
4030
+ minis.appendChild(connect);
4031
+ }
3536
4032
  const primary = el("button", "launcher");
3537
4033
  primary.title = `Open ${this.cfg.label ?? "Loupe"}`;
3538
4034
  primary.setAttribute("aria-label", `${this.cfg.label ?? "Loupe"} \u2014 quick actions`);
@@ -3947,8 +4443,9 @@ var Loupe = (() => {
3947
4443
  status: "queue",
3948
4444
  priority,
3949
4445
  changeType,
3950
- // Branch-aware threads: the host declares these once in `init()`.
3951
- repo: this.cfg.repo,
4446
+ // Branch-aware threads: the host declares these once in `init()`, and the
4447
+ // panel's project manager can override the repo per browser.
4448
+ repo: this.effectiveRepo(),
3952
4449
  branch: this.cfg.branch,
3953
4450
  kind: target.kind,
3954
4451
  anchor,
@@ -3977,6 +4474,12 @@ var Loupe = (() => {
3977
4474
  this.renderPins();
3978
4475
  this.renderList();
3979
4476
  this.flash(comment.id);
4477
+ this.addActivity({
4478
+ kind: "comment.create",
4479
+ label: `Created \u201C${comment.title || comment.body.split("\n")[0] || "comment"}\u201D`,
4480
+ detail: [normalizePriority(comment.priority), comment.kind ?? "element", comment.repo].filter(Boolean).join(" \xB7 "),
4481
+ files: comment.repo ? [`${comment.repo}/${shortPath(comment.url)}`] : []
4482
+ });
3980
4483
  }
3981
4484
  /** Upload the reporter's picked files. A file that fails is skipped, not fatal. */
3982
4485
  async uploadAttachments(files) {
@@ -4090,6 +4593,7 @@ var Loupe = (() => {
4090
4593
  if (this.scope === "all" && !this.allComments.length) void this.loadAllComments();
4091
4594
  }
4092
4595
  if (tab === "comments") this.renderList();
4596
+ if (tab === "activity") this.renderActivity();
4093
4597
  this.applyDockLayout();
4094
4598
  }
4095
4599
  toggleTheme() {
@@ -4154,19 +4658,21 @@ var Loupe = (() => {
4154
4658
  */
4155
4659
  renderHints() {
4156
4660
  if (!this.homeEl) return;
4661
+ if (this.hintFor === this.tab) return;
4662
+ this.hintFor = this.tab;
4157
4663
  const slots = {
4158
4664
  home: this.homeEl.querySelector("#loupe-hhint"),
4159
4665
  comments: this.commentsHint ?? null,
4160
- connect: this.connectHint ?? null
4666
+ activity: this.activityHint ?? null
4161
4667
  };
4668
+ for (const [id, slot2] of this.customHintSlots) slots[id] = slot2;
4162
4669
  const slot = slots[this.tab];
4163
- if (!slot || this.hintFor === this.tab) return;
4164
- this.hintFor = this.tab;
4670
+ if (!slot) return;
4165
4671
  slot.innerHTML = "";
4166
- if (!this.hoverHints || this.minimized || this.hintsSeen.has(this.tab)) return;
4672
+ const hint = this.hintForTab(this.tab);
4673
+ if (!hint || !this.hoverHints || this.minimized || this.hintsSeen.has(this.tab)) return;
4167
4674
  this.hintsSeen.add(this.tab);
4168
4675
  this.saveState();
4169
- const hint = HINTS[this.tab];
4170
4676
  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>`;
4171
4677
  slot.querySelector('[data-role="hint-off"]').onclick = () => {
4172
4678
  this.hoverHints = false;
@@ -4178,6 +4684,11 @@ var Loupe = (() => {
4178
4684
  slot.innerHTML = "";
4179
4685
  };
4180
4686
  }
4687
+ /** The built-in hints, plus any a registered tab declared for itself. */
4688
+ hintForTab(id) {
4689
+ if (id in HINTS) return HINTS[id];
4690
+ return (this.cfg.tabs ?? []).find((t) => t.id === id)?.hint;
4691
+ }
4181
4692
  // ---- guided tour ----------------------------------------------------------
4182
4693
  startTour() {
4183
4694
  this.tourStep = 0;
@@ -4263,7 +4774,8 @@ var Loupe = (() => {
4263
4774
  if (DOCK_MODES.includes(p?.mode)) this.dockMode = p.mode;
4264
4775
  if (typeof p?.open === "boolean") this.open = p.open;
4265
4776
  if (p?.theme === "light" || p?.theme === "dark") this.theme = p.theme;
4266
- if (p?.tab === "home" || p?.tab === "comments" || p?.tab === "connect") this.tab = p.tab;
4777
+ const known = [...BUILTIN_TABS.map((t) => t.id), ...(this.cfg.tabs ?? []).map((t) => t.id)];
4778
+ if (typeof p?.tab === "string" && known.includes(p.tab)) this.tab = p.tab;
4267
4779
  if (p?.scope === "page" || p?.scope === "all") this.scope = p.scope;
4268
4780
  if (typeof p?.statFilter === "string") this.statFilter = p.statFilter;
4269
4781
  if (typeof p?.repoFilter === "string") this.repoFilter = p.repoFilter;
@@ -4314,9 +4826,8 @@ var Loupe = (() => {
4314
4826
  d.classList.toggle("minimized", this.minimized);
4315
4827
  const openCount = this.comments.filter((c) => !isResolved(c)).length;
4316
4828
  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>`;
4317
- d.classList.toggle("tab-home", this.tab === "home");
4318
- d.classList.toggle("tab-comments", this.tab === "comments");
4319
- d.classList.toggle("tab-connect", this.tab === "connect");
4829
+ for (const t of this.tabList) d.classList.toggle(`tab-${t.id}`, this.tab === t.id);
4830
+ for (const [id, view] of this.viewEls) view.classList.toggle("on", id === this.tab);
4320
4831
  this.renderHome();
4321
4832
  d.querySelectorAll(".tabs .tab").forEach((b) => b.classList.toggle("on", b.dataset.tab === this.tab));
4322
4833
  this.posMenu.querySelectorAll("[data-pos]").forEach((b) => b.classList.toggle("on", b.dataset.pos === this.dockMode));
@@ -4468,6 +4979,10 @@ var Loupe = (() => {
4468
4979
  await this.store.update(c.id, { status });
4469
4980
  this.renderPins();
4470
4981
  this.renderList();
4982
+ this.addActivity({
4983
+ kind: status === "resolved" ? "comment.resolve" : "comment.reopen",
4984
+ label: `${status === "resolved" ? "Resolved" : "Reopened"} \u201C${c.title || c.body.split("\n")[0] || "comment"}\u201D`
4985
+ });
4471
4986
  };
4472
4987
  const del = el("button", "", "Delete");
4473
4988
  del.onclick = async (e) => {
@@ -4477,6 +4992,11 @@ var Loupe = (() => {
4477
4992
  this.resolved.delete(c.id);
4478
4993
  this.renderPins();
4479
4994
  this.renderList();
4995
+ this.addActivity({
4996
+ kind: "comment.delete",
4997
+ label: `Deleted \u201C${c.title || c.body.split("\n")[0] || "comment"}\u201D`,
4998
+ level: "warn"
4999
+ });
4480
5000
  };
4481
5001
  actions.append(doneBtn, del);
4482
5002
  detail.appendChild(actions);
@@ -4569,6 +5089,19 @@ var Loupe = (() => {
4569
5089
  return url;
4570
5090
  }
4571
5091
  }
5092
+ function normalizeEnvUrl(raw) {
5093
+ const s = raw.trim();
5094
+ if (!s) return null;
5095
+ try {
5096
+ const u = new URL(s);
5097
+ if (u.protocol !== "http:" && u.protocol !== "https:") return null;
5098
+ if (!u.hostname) return null;
5099
+ const path = u.pathname.replace(/\/+$/, "");
5100
+ return u.origin + path;
5101
+ } catch {
5102
+ return null;
5103
+ }
5104
+ }
4572
5105
  function dayLabel(iso) {
4573
5106
  const d = new Date(iso);
4574
5107
  if (Number.isNaN(d.getTime())) return "Earlier";
@@ -4685,6 +5218,41 @@ var Loupe = (() => {
4685
5218
  return normalizeStatus(c.status) === "resolved";
4686
5219
  }
4687
5220
 
5221
+ // src/connect.ts
5222
+ var escapeHtml2 = (s) => s.replace(/[&<>"']/g, (m) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[m]);
5223
+ function connectTab() {
5224
+ return {
5225
+ id: "connect",
5226
+ label: "Connect",
5227
+ hint: {
5228
+ title: "Hand it to Claude",
5229
+ body: "Add the MCP server to your client and it can list, read and answer this feedback \u2014 screenshot included."
5230
+ },
5231
+ render: (ctx) => {
5232
+ const apiHint = ctx.apiBase ?? "http://localhost:8787";
5233
+ const config = JSON.stringify(
5234
+ {
5235
+ mcpServers: {
5236
+ loupe: {
5237
+ command: "npx",
5238
+ args: ["-y", "@loupekit/mcp"],
5239
+ env: { LOUPE_API: apiHint, LOUPE_PROJECT_KEY: ctx.projectKey, LOUPE_ADMIN_KEY: "<your project secret>" }
5240
+ }
5241
+ }
5242
+ },
5243
+ null,
5244
+ 2
5245
+ );
5246
+ const steps = [
5247
+ ["Pin your feedback", "Use Inspect, Region, Record, or Note to leave comments right on the live app."],
5248
+ ["Add the Loupe MCP server", "Drop this into your Claude Code config so Claude can read this project's backlog:"],
5249
+ ["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."]
5250
+ ];
5251
+ 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>`;
5252
+ }
5253
+ };
5254
+ }
5255
+
4688
5256
  // src/index.ts
4689
5257
  var app = null;
4690
5258
  function init(config) {
@@ -4706,6 +5274,15 @@ var Loupe = (() => {
4706
5274
  app?.destroy();
4707
5275
  app = null;
4708
5276
  }
5277
+ function trackActivity(event) {
5278
+ app?.addActivity(event);
5279
+ }
5280
+ function setActivityStatus(status) {
5281
+ app?.setActivityStatus(status);
5282
+ }
5283
+ function clearActivity() {
5284
+ app?.clearActivity();
5285
+ }
4709
5286
  return __toCommonJS(src_exports);
4710
5287
  })();
4711
5288
  //# sourceMappingURL=index.global.js.map