@loupekit/sdk 0.10.13 → 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.
- package/README.md +53 -7
- package/dist/index.global.js +660 -83
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +660 -83
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.global.js
CHANGED
|
@@ -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
|
-
/*
|
|
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
|
-
.
|
|
335
|
-
.
|
|
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.
|
|
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="
|
|
2785
|
-
tab: "
|
|
2786
|
-
title: "
|
|
2787
|
-
body: "
|
|
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
|
-
|
|
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
|
|
3270
|
-
const b = el("button", "tab", label);
|
|
3271
|
-
b.dataset.tab =
|
|
3272
|
-
b.onclick = () => this.setTab(
|
|
3273
|
-
|
|
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
|
|
3328
|
-
this.
|
|
3329
|
-
|
|
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,
|
|
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
|
|
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.
|
|
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
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
4164
|
-
this.hintFor = this.tab;
|
|
4670
|
+
if (!slot) return;
|
|
4165
4671
|
slot.innerHTML = "";
|
|
4166
|
-
|
|
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
|
-
|
|
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(
|
|
4318
|
-
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
|