@loupekit/sdk 0.10.12 → 0.10.13-next.58

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.
@@ -36,6 +36,7 @@ var Loupe = (() => {
36
36
  Dark is the default; the host element gets .theme-light to flip to light. */
37
37
  :host {
38
38
  --accent: #6b73e6;
39
+ --accent-soft: rgba(107, 115, 230, 0.12);
39
40
  --pin: #ff5842;
40
41
  --bg: #14161d;
41
42
  --bg-2: #1b1e27;
@@ -47,6 +48,7 @@ var Loupe = (() => {
47
48
  }
48
49
  :host(.theme-light) {
49
50
  --accent: #4a55d6;
51
+ --accent-soft: rgba(74, 85, 214, 0.12);
50
52
  --pin: #ff5842;
51
53
  --bg: #ffffff;
52
54
  --bg-2: #f6f7fb;
@@ -63,7 +65,7 @@ var Loupe = (() => {
63
65
  .hl {
64
66
  position: fixed; pointer-events: none; z-index: 2147483001;
65
67
  border: 2px solid var(--accent);
66
- background: rgba(107, 115, 230, 0.12);
68
+ background: var(--accent-soft);
67
69
  border-radius: 4px; display: none;
68
70
  transition: all 60ms linear;
69
71
  }
@@ -76,7 +78,7 @@ var Loupe = (() => {
76
78
  /* region selection (during drag) + active-comment outline */
77
79
  .selbox {
78
80
  position: fixed; pointer-events: none; z-index: 2147483001; display: none;
79
- border: 2px dashed var(--accent); background: rgba(107, 115, 230, 0.14); border-radius: 4px;
81
+ border: 2px dashed var(--accent); background: var(--accent-soft); border-radius: 4px;
80
82
  }
81
83
  .region-box {
82
84
  position: fixed; pointer-events: none; z-index: 2147483001; display: none;
@@ -378,6 +380,12 @@ var Loupe = (() => {
378
380
 
379
381
  /* Timeline grouping (project scope) + the repo filter. */
380
382
  .daylabel { margin: 8px 2px 0; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); font-weight: 700; }
383
+ /* The page a comment belongs to, shown in the project scope (Settings \u2192 Page paths). */
384
+ .pathtag {
385
+ padding: 1px 6px; border-radius: 999px; background: var(--bg-3); color: var(--muted);
386
+ font-size: 10.5px; font-family: ui-monospace, Menlo, monospace;
387
+ max-width: 130px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
388
+ }
381
389
  .listhead .reposel {
382
390
  margin-left: 6px; font-size: 11px; padding: 3px 6px; border: 1px solid var(--line);
383
391
  border-radius: 7px; background: var(--bg-2); color: var(--ink);
@@ -425,6 +433,97 @@ var Loupe = (() => {
425
433
  @keyframes loupe-recpulse { 0%,100% { opacity: 1; } 50% { opacity: .25; } }
426
434
  @media (prefers-reduced-motion: reduce) { .recbar .recdot { animation: none; } }
427
435
 
436
+ /* ---------------------------------------------- header popovers (pos + settings) */
437
+ .menu-wrap { position: relative; }
438
+ .menu {
439
+ position: absolute; top: calc(100% + 6px); right: 0; z-index: 30;
440
+ min-width: 214px; padding: 8px; display: none;
441
+ background: var(--bg-2); border: 1px solid var(--line); border-radius: 12px; box-shadow: var(--shadow);
442
+ }
443
+ .menu.open { display: block; }
444
+ .menu-label {
445
+ margin: 2px 5px 6px; font-size: 10px; font-weight: 700; letter-spacing: .06em;
446
+ text-transform: uppercase; color: var(--muted);
447
+ }
448
+ /* dock-position grid: the four layouts as a 2x2 of buttons */
449
+ .pos-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
450
+ .pos-grid button {
451
+ display: flex; align-items: center; gap: 6px; padding: 7px 8px; cursor: pointer;
452
+ border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--muted);
453
+ font-size: 11.5px; font-weight: 600;
454
+ }
455
+ .pos-grid button:hover { border-color: var(--accent); color: var(--ink); }
456
+ .pos-grid button.on { border-color: var(--accent); color: var(--accent); }
457
+ .pos-grid button svg { width: 14px; height: 14px; flex: none; }
458
+ /* settings rows */
459
+ .menu-row {
460
+ display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%;
461
+ padding: 7px 6px; border: 0; border-radius: 8px; background: transparent; color: var(--ink);
462
+ font-size: 12.5px; text-align: left; cursor: pointer;
463
+ }
464
+ .menu-row:hover { background: var(--bg-3); }
465
+ .menu-sep { height: 1px; margin: 6px 4px; background: var(--line); }
466
+ /* on/off switch inside a settings row */
467
+ .sw { flex: none; position: relative; width: 30px; height: 17px; border-radius: 999px; background: var(--line); transition: background .12s; }
468
+ .sw::after { content: ""; position: absolute; top: 2px; left: 2px; width: 13px; height: 13px; border-radius: 50%; background: #fff; transition: transform .12s; }
469
+ .menu-row[aria-pressed="true"] .sw { background: var(--accent); }
470
+ .menu-row[aria-pressed="true"] .sw::after { transform: translateX(13px); }
471
+ /* accent swatches */
472
+ .acc-dots { display: flex; gap: 8px; padding: 4px 6px 2px; }
473
+ .acc-dot { width: 20px; height: 20px; border-radius: 50%; border: 2px solid transparent; padding: 0; cursor: pointer; }
474
+ .acc-dot.on { border-color: var(--ink); }
475
+
476
+ /* ---------------------------------------------------------------- minimize bar */
477
+ .minbar { display: none; align-items: center; gap: 8px; padding: 9px 10px; cursor: pointer; background: var(--bg-2); }
478
+ .dock.minimized .minbar { display: flex; }
479
+ .dock.minimized .tabs, .dock.minimized .view, .dock.minimized .resize,
480
+ .dock.minimized .dctl [data-role="min"], .dock.minimized .dctl .menu-wrap { display: none !important; }
481
+ .minbar .logo { color: var(--accent); font-size: 14px; }
482
+ .minbar .mtext { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: var(--muted); }
483
+ .minbar .mtext b { color: var(--ink); }
484
+ .minbar .mrestore { flex: none; padding: 0 2px; border: 0; background: transparent; color: var(--accent); font-size: 14px; cursor: pointer; }
485
+
486
+ /* ------------------------------------------------------- hint card (once per view) */
487
+ .hint {
488
+ position: relative; margin: 8px 12px 0; padding: 10px 28px 10px 11px;
489
+ border: 1px solid var(--accent); border-radius: 10px; background: var(--bg-2);
490
+ }
491
+ .hint-t { margin-bottom: 3px; font-size: 12.5px; font-weight: 700; color: var(--ink); }
492
+ .hint-b { font-size: 11.5px; line-height: 1.45; color: var(--muted); }
493
+ .hint-off { display: inline-block; margin-top: 6px; padding: 0; border: 0; background: transparent; color: var(--accent); font-size: 11px; text-decoration: underline; cursor: pointer; }
494
+ .hint-x {
495
+ position: absolute; top: 6px; right: 6px; width: 18px; height: 18px; padding: 0;
496
+ border: 0; border-radius: 5px; background: transparent; color: var(--muted); font-size: 13px; line-height: 1; cursor: pointer;
497
+ }
498
+ .hint-x:hover { background: var(--bg-3); color: var(--ink); }
499
+
500
+ /* ---------------------------------------------------------------- guided tour */
501
+ /* Non-modal on purpose: the dimmer and the spotlight are click-through, so a tour
502
+ can never trap someone mid-task. Only the card itself takes pointer events. */
503
+ .tour { position: fixed; inset: 0; z-index: 2147483200; display: none; pointer-events: none; }
504
+ .tour.open { display: block; }
505
+ .tour-spot {
506
+ position: fixed; border: 2px solid var(--accent); border-radius: 10px; pointer-events: none;
507
+ box-shadow: 0 0 0 9999px rgba(8, 10, 16, .62);
508
+ transition: all .18s ease;
509
+ }
510
+ .tour-card {
511
+ position: fixed; width: 252px; padding: 12px; pointer-events: auto;
512
+ background: var(--bg-2); border: 1px solid var(--line); border-radius: 12px; box-shadow: var(--shadow); color: var(--ink);
513
+ }
514
+ .tour-title { margin-bottom: 4px; font-size: 13px; font-weight: 700; }
515
+ .tour-body { font-size: 11.5px; line-height: 1.5; color: var(--muted); }
516
+ .tour-foot { display: flex; align-items: center; gap: 6px; margin-top: 10px; }
517
+ .tour-dots { display: flex; flex: 1; gap: 4px; }
518
+ .tour-dots i { width: 5px; height: 5px; border-radius: 50%; background: var(--line); }
519
+ .tour-dots i.on { background: var(--accent); }
520
+ .tour-foot button {
521
+ padding: 5px 9px; border: 1px solid var(--line); border-radius: 7px;
522
+ background: var(--bg); color: var(--ink); font-size: 11.5px; font-weight: 600; cursor: pointer;
523
+ }
524
+ .tour-foot .t-next { border-color: var(--accent); background: var(--accent); color: #fff; }
525
+ .tour-foot .t-skip { border: 0; background: transparent; color: var(--muted); }
526
+
428
527
  /* Mobile: left/right/float docking is a desktop affordance. On small screens the
429
528
  panel collapses to a bottom sheet that OVERLAYS the page (pushing a side dock
430
529
  here would squeeze the page to a useless sliver), regardless of the chosen dock
@@ -435,7 +534,8 @@ var Loupe = (() => {
435
534
  width: auto !important; height: 76vh !important;
436
535
  border-width: 1px 0 0 0 !important; border-radius: 16px 16px 0 0 !important;
437
536
  }
438
- .dctl [data-dock], .dctl .gap { display: none; } /* dock positions don't apply on mobile */
537
+ .dctl [data-role="pos"], .dctl .gap { display: none; } /* dock positions don't apply on mobile */
538
+ .menu { min-width: 190px; }
439
539
  .resize { display: none !important; }
440
540
  .tools button { flex: 1; justify-content: center; } /* full-width tap targets */
441
541
  .dock.mode-bottom .list { grid-template-columns: 1fr; }
@@ -525,10 +625,10 @@ var Loupe = (() => {
525
625
  const t = normText(el2.textContent);
526
626
  add(W.text, t === a.text ? 1 : t && (t.includes(a.text) || a.text.includes(t)) ? 0.5 : 0);
527
627
  }
528
- const attrKeys = Object.keys(a.attrs);
628
+ const attrKeys = Object.keys(a.attrs ?? {});
529
629
  if (attrKeys.length) {
530
630
  let matched = 0;
531
- for (const k of attrKeys) if (el2.getAttribute(k) === a.attrs[k]) matched++;
631
+ for (const k of attrKeys) if (el2.getAttribute(k) === (a.attrs ?? {})[k]) matched++;
532
632
  add(W.attrs, matched / attrKeys.length);
533
633
  }
534
634
  if (a.testid) add(W.testid, stableId(el2) === a.testid ? 1 : 0);
@@ -536,9 +636,11 @@ var Loupe = (() => {
536
636
  const r = el2.getBoundingClientRect();
537
637
  const cx = r.left + window.scrollX + r.width / 2;
538
638
  const cy = r.top + window.scrollY + r.height / 2;
539
- const ax = a.rect.x + a.rect.w / 2;
540
- const ay = a.rect.y + a.rect.h / 2;
541
- const diag = Math.hypot(a.viewport.w, a.viewport.h) || 1;
639
+ const ar = a.rect ?? { x: 0, y: 0, w: 0, h: 0 };
640
+ const av = a.viewport ?? { w: 0, h: 0 };
641
+ const ax = ar.x + ar.w / 2;
642
+ const ay = ar.y + ar.h / 2;
643
+ const diag = Math.hypot(av.w, av.h) || 1;
542
644
  const dist = Math.hypot(cx - ax, cy - ay);
543
645
  add(W.position, Math.max(0, 1 - dist / diag));
544
646
  return total ? sum / total : 0;
@@ -2648,7 +2750,48 @@ var Loupe = (() => {
2648
2750
  }
2649
2751
 
2650
2752
  // src/app.ts
2651
- var SDK_VERSION = true ? "0.10.12" : "dev";
2753
+ var SDK_VERSION = true ? "0.10.13" : "dev";
2754
+ var ACCENTS = [
2755
+ { id: "indigo", dark: "#6b73e6", light: "#4a55d6", soft: "rgba(107,115,230,0.12)" },
2756
+ { id: "violet", dark: "#a06be6", light: "#7c3fd4", soft: "rgba(160,107,230,0.14)" },
2757
+ { id: "teal", dark: "#2fb6a8", light: "#0f8f83", soft: "rgba(47,182,168,0.14)" },
2758
+ { id: "amber", dark: "#d99a2b", light: "#a9700f", soft: "rgba(217,154,43,0.14)" },
2759
+ { id: "rose", dark: "#e05c86", light: "#c2295a", soft: "rgba(224,92,134,0.14)" }
2760
+ ];
2761
+ var ACCENT_IDS = ACCENTS.map((a) => a.id);
2762
+ var TOUR = [
2763
+ // Home first: the panel already opens there, so the first step never moves the
2764
+ // user — the tour starts where they are.
2765
+ {
2766
+ sel: ".hstat",
2767
+ tab: "home",
2768
+ title: "Home shows what needs you",
2769
+ body: "Four tiles count open, needs-you, resolved and stale feedback. Click one to narrow the list to that bucket."
2770
+ },
2771
+ {
2772
+ sel: ".hscope",
2773
+ tab: "home",
2774
+ title: "This page, or the whole project",
2775
+ body: "Switch to All to see every page's feedback as a day-grouped timeline, with a repo filter."
2776
+ },
2777
+ {
2778
+ sel: ".tools",
2779
+ tab: "comments",
2780
+ title: "Pin feedback anywhere",
2781
+ body: "Inspect picks an element, Note drops a page-level comment, Region captures a rectangle, and Record films one."
2782
+ },
2783
+ {
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."
2788
+ }
2789
+ ];
2790
+ var HINTS = {
2791
+ 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
+ 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." }
2794
+ };
2652
2795
  var DOCK_MODES = ["left", "right", "bottom", "float"];
2653
2796
  var RECORD_MAX_MS = 2e4;
2654
2797
  var MAX_FILES = 10;
@@ -2727,6 +2870,20 @@ var Loupe = (() => {
2727
2870
  this.statFilter = "";
2728
2871
  /** Repo filter, offered once the scope is "all". */
2729
2872
  this.repoFilter = "";
2873
+ /** Accent preset id (see ACCENTS), applied as inline --accent / --accent-soft. */
2874
+ this.accent = "indigo";
2875
+ /** Collapsed to the one-line minimize bar. */
2876
+ this.minimized = false;
2877
+ /** The "help layer": FAB tooltips and the contextual hint cards. */
2878
+ this.hoverHints = true;
2879
+ /** Show each comment's page path (useful in the project scope). */
2880
+ this.showPaths = false;
2881
+ /** Views whose hint card has already been shown. */
2882
+ this.hintsSeen = /* @__PURE__ */ new Set();
2883
+ /** Current guided-tour step, or -1 when the tour is closed. */
2884
+ this.tourStep = -1;
2885
+ /** The first-run tour has been finished or skipped. */
2886
+ this.tourDone = false;
2730
2887
  /** Float-mode window geometry; (x<=0 && y<=0) → placed on first layout. */
2731
2888
  this.floatRect = { x: 0, y: 0, w: 380, h: 540 };
2732
2889
  this.floatDrag = null;
@@ -2851,7 +3008,12 @@ var Loupe = (() => {
2851
3008
  }
2852
3009
  let elx = this.resolved.get(c.id) ?? null;
2853
3010
  if (!elx || !elx.isConnected) {
2854
- const r = resolveAnchor(c.anchor);
3011
+ let r = null;
3012
+ try {
3013
+ r = resolveAnchor(c.anchor);
3014
+ } catch {
3015
+ r = null;
3016
+ }
2855
3017
  elx = r?.element ?? null;
2856
3018
  this.resolved.set(c.id, elx);
2857
3019
  }
@@ -2894,6 +3056,8 @@ var Loupe = (() => {
2894
3056
  }
2895
3057
  };
2896
3058
  this.onWinResize = () => this.applyDockLayout();
3059
+ /** Which view's hint has been handled this session (so it is not re-painted). */
3060
+ this.hintFor = null;
2897
3061
  // ---- float-mode drag + resize ---------------------------------------------
2898
3062
  this.onHeadPointerDown = (e) => {
2899
3063
  if (this.dockMode !== "float" || e.button !== 0 || this.isMobile()) return;
@@ -2959,6 +3123,7 @@ var Loupe = (() => {
2959
3123
  if (this.scope === "all") void this.loadAllComments();
2960
3124
  this.observe();
2961
3125
  this.watchNavigation();
3126
+ if (!this.tourDone && this.open && !this.isMobile()) this.startTour();
2962
3127
  if (this.cfg.autoOpen) this.setMode("inspect");
2963
3128
  }
2964
3129
  /**
@@ -3011,7 +3176,16 @@ var Loupe = (() => {
3011
3176
  this.dock = this.buildDock();
3012
3177
  this.fabCluster = this.buildFabCluster();
3013
3178
  this.recBar = this.buildRecBar();
3014
- this.shadow.append(this.dock, this.fabCluster, this.recBar);
3179
+ this.tourEl = el("div", "tour");
3180
+ this.tourSpot = el("div", "tour-spot");
3181
+ this.tourCard = el("div", "tour-card");
3182
+ this.tourEl.append(this.tourSpot, this.tourCard);
3183
+ this.shadow.append(this.dock, this.fabCluster, this.recBar, this.tourEl);
3184
+ this.shadow.addEventListener("click", (e) => {
3185
+ const t = e.target;
3186
+ if (t?.closest && t.closest(".menu-wrap")) return;
3187
+ this.closeMenus();
3188
+ });
3015
3189
  }
3016
3190
  /**
3017
3191
  * The dockable control panel:
@@ -3026,33 +3200,71 @@ var Loupe = (() => {
3026
3200
  brand.querySelector(".title").textContent = this.cfg.label ?? "Loupe";
3027
3201
  head.addEventListener("pointerdown", this.onHeadPointerDown);
3028
3202
  const ctl = el("div", "dctl");
3029
- const dockBtn = (mode, icon, title) => {
3030
- const b = el("button");
3031
- b.dataset.dock = mode;
3032
- b.title = title;
3033
- b.setAttribute("aria-label", title);
3034
- b.innerHTML = icon;
3035
- b.onclick = () => this.setDock(mode);
3036
- return b;
3037
- };
3038
- ctl.append(
3039
- dockBtn("left", I_DOCK_LEFT, "Dock to left"),
3040
- dockBtn("bottom", I_DOCK_BOTTOM, "Dock to bottom"),
3041
- dockBtn("right", I_DOCK_RIGHT, "Dock to right"),
3042
- dockBtn("float", I_FLOAT, "Float"),
3043
- el("span", "gap")
3044
- );
3203
+ const posWrap = el("div", "menu-wrap");
3204
+ const posBtn = el("button");
3205
+ posBtn.dataset.role = "pos";
3206
+ posBtn.title = "Panel position";
3207
+ posBtn.setAttribute("aria-label", "Panel position");
3208
+ posBtn.innerHTML = I_DOCK_RIGHT;
3209
+ this.posMenu = el("div", "menu");
3210
+ const posMeta = [
3211
+ { mode: "left", icon: I_DOCK_LEFT, label: "Left" },
3212
+ { mode: "bottom", icon: I_DOCK_BOTTOM, label: "Bottom" },
3213
+ { mode: "right", icon: I_DOCK_RIGHT, label: "Right" },
3214
+ { mode: "float", icon: I_FLOAT, label: "Float" }
3215
+ ];
3216
+ this.posMenu.innerHTML = `<div class="menu-label">Position</div><div class="pos-grid">` + posMeta.map((m) => `<button data-pos="${m.mode}">${m.icon}<span>${m.label}</span></button>`).join("") + `</div>`;
3217
+ this.posMenu.querySelectorAll("[data-pos]").forEach((b) => {
3218
+ b.onclick = () => {
3219
+ this.setDock(b.dataset.pos);
3220
+ this.closeMenus();
3221
+ };
3222
+ });
3223
+ posBtn.onclick = () => this.toggleMenu(this.posMenu);
3224
+ posWrap.append(posBtn, this.posMenu);
3045
3225
  this.themeBtn = el("button");
3046
3226
  this.themeBtn.dataset.role = "theme";
3047
3227
  this.themeBtn.onclick = () => this.toggleTheme();
3228
+ const setWrap = el("div", "menu-wrap");
3229
+ const setBtn = el("button");
3230
+ setBtn.dataset.role = "settings";
3231
+ setBtn.title = "Settings";
3232
+ setBtn.setAttribute("aria-label", "Settings");
3233
+ setBtn.innerHTML = I_GEAR;
3234
+ setBtn.onclick = () => this.toggleMenu(this.settingsMenu);
3235
+ 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>`;
3237
+ this.settingsMenu.querySelectorAll("[data-accent]").forEach((b) => {
3238
+ b.onclick = () => this.setAccent(b.dataset.accent);
3239
+ });
3240
+ this.settingsMenu.querySelectorAll("[data-set]").forEach((b) => {
3241
+ b.onclick = () => {
3242
+ const key = b.dataset.set;
3243
+ if (key === "tour") {
3244
+ this.closeMenus();
3245
+ this.startTour();
3246
+ return;
3247
+ }
3248
+ this.toggleSetting(key);
3249
+ };
3250
+ });
3251
+ setWrap.append(setBtn, this.settingsMenu);
3252
+ const minBtn = el("button");
3253
+ minBtn.dataset.role = "min";
3254
+ minBtn.title = "Minimize";
3255
+ minBtn.setAttribute("aria-label", "Minimize");
3256
+ minBtn.innerHTML = I_MINIMIZE;
3257
+ minBtn.onclick = () => this.setMinimized(true);
3048
3258
  const closeBtn = el("button");
3049
3259
  closeBtn.dataset.role = "close";
3050
3260
  closeBtn.title = "Close";
3051
3261
  closeBtn.setAttribute("aria-label", "Close");
3052
3262
  closeBtn.innerHTML = I_CLOSE;
3053
3263
  closeBtn.onclick = () => this.closeDock();
3054
- ctl.append(this.themeBtn, closeBtn);
3264
+ ctl.append(posWrap, this.themeBtn, setWrap, minBtn, closeBtn);
3055
3265
  head.append(brand, ctl);
3266
+ this.minBar = el("div", "minbar");
3267
+ this.minBar.onclick = () => this.setMinimized(false);
3056
3268
  const tabs = el("div", "tabs");
3057
3269
  const tabBtn = (key, label) => {
3058
3270
  const b = el("button", "tab", label);
@@ -3110,11 +3322,14 @@ var Loupe = (() => {
3110
3322
  const homeView = el("div", "view home-view");
3111
3323
  this.homeEl = homeView;
3112
3324
  const commentsView = el("div", "view comments-view");
3113
- commentsView.append(tools, listHead, this.listEl, this.buildIntegrations());
3325
+ this.commentsHint = el("div", "hint-slot");
3326
+ commentsView.append(this.commentsHint, tools, listHead, this.listEl, this.buildIntegrations());
3114
3327
  const connectView = this.buildConnectPanel();
3328
+ this.connectHint = el("div", "hint-slot");
3329
+ connectView.prepend(this.connectHint);
3115
3330
  const resize = el("div", "resize");
3116
3331
  resize.addEventListener("pointerdown", this.onResizeDown);
3117
- dock.append(head, tabs, homeView, commentsView, connectView, resize);
3332
+ dock.append(head, this.minBar, tabs, homeView, commentsView, connectView, resize);
3118
3333
  this.buildHomePanel();
3119
3334
  return dock;
3120
3335
  }
@@ -3125,7 +3340,7 @@ var Loupe = (() => {
3125
3340
  */
3126
3341
  buildHomePanel() {
3127
3342
  const title = this.cfg.label ?? "Loupe";
3128
- this.homeEl.innerHTML = `<div class="hstat" id="loupe-hstats"></div><div class="hscope"><button class="hscope-b" data-scope="page">This page</button><button class="hscope-b" data-scope="all">All</button><button class="hrefresh" title="Refresh" aria-label="Refresh">\u27F3</button></div><button class="hpin" data-role="home-pin">\u271B Pin feedback on this page</button><div class="hlabel">Recent</div><div class="hfeed" id="loupe-hfeed"></div><div class="hfoot">${escapeHtml(title)} \xB7 ${escapeHtml(this.cfg.repo ?? "no repo linked")}</div>`;
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>`;
3129
3344
  this.homeEl.querySelectorAll(".hscope-b").forEach((b) => {
3130
3345
  b.onclick = () => this.setScope(b.dataset.scope === "all" ? "all" : "page");
3131
3346
  });
@@ -3868,6 +4083,7 @@ var Loupe = (() => {
3868
4083
  if (this.tab === tab) return;
3869
4084
  if (tab !== "comments") this.setMode("off");
3870
4085
  this.tab = tab;
4086
+ this.hintFor = null;
3871
4087
  this.saveState();
3872
4088
  if (tab === "home") {
3873
4089
  this.renderHome();
@@ -3881,6 +4097,160 @@ var Loupe = (() => {
3881
4097
  this.saveState();
3882
4098
  this.applyDockLayout();
3883
4099
  }
4100
+ // ---- panel shell: popovers, minimize, accent ------------------------------
4101
+ toggleMenu(menu) {
4102
+ const wasOpen = menu.classList.contains("open");
4103
+ this.closeMenus();
4104
+ if (!wasOpen) menu.classList.add("open");
4105
+ }
4106
+ closeMenus() {
4107
+ this.posMenu?.classList.remove("open");
4108
+ this.settingsMenu?.classList.remove("open");
4109
+ }
4110
+ setAccent(id) {
4111
+ if (!ACCENT_IDS.includes(id)) return;
4112
+ this.accent = id;
4113
+ this.saveState();
4114
+ this.applyDockLayout();
4115
+ }
4116
+ setMinimized(v) {
4117
+ this.minimized = v;
4118
+ this.closeMenus();
4119
+ this.saveState();
4120
+ this.applyDockLayout();
4121
+ }
4122
+ /** Flip one of the "Show …" settings and repaint whatever it governs. */
4123
+ toggleSetting(key) {
4124
+ if (key === "markersHidden") this.markersHidden = !this.markersHidden;
4125
+ else if (key === "hoverHints") this.hoverHints = !this.hoverHints;
4126
+ else this.showPaths = !this.showPaths;
4127
+ this.saveState();
4128
+ if (key === "markersHidden") this.renderPins();
4129
+ if (key === "showPaths") {
4130
+ this.renderList();
4131
+ this.renderHome();
4132
+ }
4133
+ if (key === "hoverHints") this.hintFor = null;
4134
+ this.applyDockLayout();
4135
+ }
4136
+ /** Reflect the current state into the settings menu (never rebuild it). */
4137
+ renderSettings() {
4138
+ if (!this.settingsMenu) return;
4139
+ const on = {
4140
+ hoverHints: this.hoverHints,
4141
+ markersHidden: !this.markersHidden,
4142
+ showPaths: this.showPaths
4143
+ };
4144
+ this.settingsMenu.querySelectorAll("[data-set]").forEach((b) => {
4145
+ const key = b.dataset.set;
4146
+ if (key in on) b.setAttribute("aria-pressed", String(on[key]));
4147
+ });
4148
+ this.settingsMenu.querySelectorAll("[data-accent]").forEach((b) => b.classList.toggle("on", b.dataset.accent === this.accent));
4149
+ }
4150
+ /**
4151
+ * The contextual hint card for the active view. Shown at most once per view and
4152
+ * never again after that — dismissed, or switched off wholesale with "Turn off
4153
+ * hints" (the same switch as Settings → Hover hints).
4154
+ */
4155
+ renderHints() {
4156
+ if (!this.homeEl) return;
4157
+ const slots = {
4158
+ home: this.homeEl.querySelector("#loupe-hhint"),
4159
+ comments: this.commentsHint ?? null,
4160
+ connect: this.connectHint ?? null
4161
+ };
4162
+ const slot = slots[this.tab];
4163
+ if (!slot || this.hintFor === this.tab) return;
4164
+ this.hintFor = this.tab;
4165
+ slot.innerHTML = "";
4166
+ if (!this.hoverHints || this.minimized || this.hintsSeen.has(this.tab)) return;
4167
+ this.hintsSeen.add(this.tab);
4168
+ this.saveState();
4169
+ const hint = HINTS[this.tab];
4170
+ 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
+ slot.querySelector('[data-role="hint-off"]').onclick = () => {
4172
+ this.hoverHints = false;
4173
+ this.hintFor = null;
4174
+ this.saveState();
4175
+ this.applyDockLayout();
4176
+ };
4177
+ slot.querySelector(".hint-x").onclick = () => {
4178
+ slot.innerHTML = "";
4179
+ };
4180
+ }
4181
+ // ---- guided tour ----------------------------------------------------------
4182
+ startTour() {
4183
+ this.tourStep = 0;
4184
+ this.open = true;
4185
+ this.minimized = false;
4186
+ this.applyDockLayout();
4187
+ this.renderTour();
4188
+ }
4189
+ stopTour() {
4190
+ this.tourStep = -1;
4191
+ this.tourDone = true;
4192
+ this.saveState();
4193
+ this.tourEl.classList.remove("open");
4194
+ this.applyDockLayout();
4195
+ }
4196
+ gotoTour(step) {
4197
+ if (step < 0) {
4198
+ this.stopTour();
4199
+ return;
4200
+ }
4201
+ this.tourStep = step;
4202
+ this.renderTour();
4203
+ }
4204
+ /** Position the spotlight over the current step's target and lay out the card. */
4205
+ renderTour() {
4206
+ if (this.tourStep < 0 || this.tourStep >= TOUR.length) {
4207
+ this.tourEl.classList.remove("open");
4208
+ return;
4209
+ }
4210
+ const step = TOUR[this.tourStep];
4211
+ if (this.tab !== step.tab) this.setTab(step.tab);
4212
+ this.tourEl.classList.add("open");
4213
+ const pad = 4;
4214
+ const target = this.shadow.querySelector(step.sel);
4215
+ const box = target?.getBoundingClientRect();
4216
+ const r = box && box.width ? { left: box.left - pad, top: box.top - pad, width: box.width + pad * 2, height: box.height + pad * 2 } : { left: window.innerWidth / 2, top: window.innerHeight / 2, width: 0, height: 0 };
4217
+ this.tourSpot.style.left = `${r.left}px`;
4218
+ this.tourSpot.style.top = `${r.top}px`;
4219
+ this.tourSpot.style.width = `${r.width}px`;
4220
+ this.tourSpot.style.height = `${r.height}px`;
4221
+ const last = this.tourStep === TOUR.length - 1;
4222
+ this.tourCard.innerHTML = `<div class="tour-title">${escapeHtml(step.title)}</div><div class="tour-body">${escapeHtml(step.body)}</div><div class="tour-foot"><span class="tour-dots">${TOUR.map((_, i) => `<i class="${i === this.tourStep ? "on" : ""}"></i>`).join("")}</span>` + (this.tourStep > 0 ? `<button class="t-back">Back</button>` : "") + `<button class="t-skip">Skip</button><button class="t-next">${last ? "Done" : "Next"}</button></div>`;
4223
+ const W2 = 252;
4224
+ const H = this.tourCard.offsetHeight || 130;
4225
+ const overlapsTarget = (x, y) => x < r.left + r.width + 8 && x + W2 > r.left - 8 && y < r.top + r.height + 8 && y + H > r.top - 8;
4226
+ const candidates = [
4227
+ [r.left + r.width / 2 - W2 / 2, r.top + r.height + 12],
4228
+ // below
4229
+ [r.left + r.width / 2 - W2 / 2, r.top - H - 12],
4230
+ // above
4231
+ [r.left - W2 - 12, r.top + r.height / 2 - H / 2],
4232
+ // beside (left)
4233
+ [r.left + r.width + 12, r.top + r.height / 2 - H / 2]
4234
+ // beside (right)
4235
+ ];
4236
+ let cx = clampPx(candidates[0][0], 8, Math.max(8, window.innerWidth - W2 - 8));
4237
+ let cy = clampPx(candidates[0][1], 8, Math.max(8, window.innerHeight - H - 8));
4238
+ for (const [x, y] of candidates) {
4239
+ const px = clampPx(x, 8, Math.max(8, window.innerWidth - W2 - 8));
4240
+ const py = clampPx(y, 8, Math.max(8, window.innerHeight - H - 8));
4241
+ cx = px;
4242
+ cy = py;
4243
+ if (!overlapsTarget(px, py)) break;
4244
+ }
4245
+ this.tourCard.style.left = `${cx}px`;
4246
+ this.tourCard.style.top = `${cy}px`;
4247
+ this.tourCard.querySelector(".t-back")?.addEventListener("click", () => this.gotoTour(this.tourStep - 1));
4248
+ this.tourCard.querySelector(".t-skip").addEventListener("click", () => this.stopTour());
4249
+ this.tourCard.querySelector(".t-next").addEventListener("click", () => {
4250
+ if (last) this.stopTour();
4251
+ else this.gotoTour(this.tourStep + 1);
4252
+ });
4253
+ }
3884
4254
  /** Narrow viewports render the panel as a bottom sheet, not a side/float dock. */
3885
4255
  isMobile() {
3886
4256
  return window.innerWidth <= 640;
@@ -3899,6 +4269,12 @@ var Loupe = (() => {
3899
4269
  if (typeof p?.repoFilter === "string") this.repoFilter = p.repoFilter;
3900
4270
  if (p?.float && typeof p.float.w === "number") this.floatRect = { ...this.floatRect, ...p.float };
3901
4271
  if (typeof p?.markersHidden === "boolean") this.markersHidden = p.markersHidden;
4272
+ if (p?.accent && ACCENT_IDS.includes(p.accent)) this.accent = p.accent;
4273
+ if (typeof p?.minimized === "boolean") this.minimized = p.minimized;
4274
+ if (typeof p?.hoverHints === "boolean") this.hoverHints = p.hoverHints;
4275
+ if (typeof p?.showPaths === "boolean") this.showPaths = p.showPaths;
4276
+ if (typeof p?.tourDone === "boolean") this.tourDone = p.tourDone;
4277
+ if (Array.isArray(p?.hintsSeen)) this.hintsSeen = new Set(p.hintsSeen.filter((x) => typeof x === "string"));
3902
4278
  } catch {
3903
4279
  }
3904
4280
  }
@@ -3913,7 +4289,13 @@ var Loupe = (() => {
3913
4289
  markersHidden: this.markersHidden,
3914
4290
  scope: this.scope,
3915
4291
  statFilter: this.statFilter,
3916
- repoFilter: this.repoFilter
4292
+ repoFilter: this.repoFilter,
4293
+ accent: this.accent,
4294
+ minimized: this.minimized,
4295
+ hoverHints: this.hoverHints,
4296
+ showPaths: this.showPaths,
4297
+ tourDone: this.tourDone,
4298
+ hintsSeen: [...this.hintsSeen]
3917
4299
  }));
3918
4300
  } catch {
3919
4301
  }
@@ -3923,14 +4305,23 @@ var Loupe = (() => {
3923
4305
  this.root.classList.toggle("theme-light", this.theme === "light");
3924
4306
  this.themeBtn.title = this.theme === "dark" ? "Switch to light theme" : "Switch to dark theme";
3925
4307
  this.themeBtn.innerHTML = this.theme === "dark" ? I_SUN : I_MOON;
4308
+ const accent = ACCENTS.find((a) => a.id === this.accent) ?? ACCENTS[0];
4309
+ this.root.style.setProperty("--accent", this.theme === "light" ? accent.light : accent.dark);
4310
+ this.root.style.setProperty("--accent-soft", accent.soft);
3926
4311
  const d = this.dock;
3927
4312
  d.classList.toggle("open", this.open);
3928
4313
  for (const m of DOCK_MODES) d.classList.toggle("mode-" + m, this.dockMode === m);
4314
+ d.classList.toggle("minimized", this.minimized);
4315
+ const openCount = this.comments.filter((c) => !isResolved(c)).length;
4316
+ 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>`;
3929
4317
  d.classList.toggle("tab-home", this.tab === "home");
3930
4318
  d.classList.toggle("tab-comments", this.tab === "comments");
3931
4319
  d.classList.toggle("tab-connect", this.tab === "connect");
3932
4320
  this.renderHome();
3933
4321
  d.querySelectorAll(".tabs .tab").forEach((b) => b.classList.toggle("on", b.dataset.tab === this.tab));
4322
+ this.posMenu.querySelectorAll("[data-pos]").forEach((b) => b.classList.toggle("on", b.dataset.pos === this.dockMode));
4323
+ this.renderSettings();
4324
+ this.renderHints();
3934
4325
  if (this.dockMode === "float") {
3935
4326
  const vw = window.innerWidth, vh = window.innerHeight;
3936
4327
  let { x, y, w, h } = this.floatRect;
@@ -4032,6 +4423,9 @@ var Loupe = (() => {
4032
4423
  }
4033
4424
  if (isResolved(c)) top.appendChild(el("span", "badge done", "resolved"));
4034
4425
  else if (detached) top.appendChild(el("span", "badge detached", "element moved/removed"));
4426
+ if (this.showPaths && this.scope === "all" && c.url) {
4427
+ top.appendChild(el("span", "pathtag", shortPath(c.url)));
4428
+ }
4035
4429
  top.appendChild(el("span", "caret", open ? "\u25BE" : "\u25B8"));
4036
4430
  item.appendChild(top);
4037
4431
  const summary = c.title || (c.body.split("\n")[0] ?? "").slice(0, 140) || "(no description)";
@@ -4168,6 +4562,13 @@ var Loupe = (() => {
4168
4562
  if (h < 24) return `${h}h ago`;
4169
4563
  return `${Math.round(h / 24)}d ago`;
4170
4564
  }
4565
+ function shortPath(url) {
4566
+ try {
4567
+ return new URL(url, location.origin).pathname || "/";
4568
+ } catch {
4569
+ return url;
4570
+ }
4571
+ }
4171
4572
  function dayLabel(iso) {
4172
4573
  const d = new Date(iso);
4173
4574
  if (Number.isNaN(d.getTime())) return "Earlier";
@@ -4212,6 +4613,12 @@ var Loupe = (() => {
4212
4613
  var I_CLOSE = svg(
4213
4614
  `<path d="M4.2 4.2l7.6 7.6M11.8 4.2l-7.6 7.6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>`
4214
4615
  );
4616
+ var I_GEAR = svg(
4617
+ `<circle cx="8" cy="8" r="2.2" stroke="currentColor" stroke-width="1.4"/><path d="M8 1.6v1.5M8 12.9v1.5M1.6 8h1.5M12.9 8h1.5M3.5 3.5l1.1 1.1M11.4 11.4l1.1 1.1M12.5 3.5l-1.1 1.1M4.6 11.4l-1.1 1.1" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>`
4618
+ );
4619
+ var I_MINIMIZE = svg(
4620
+ `<path d="M3.5 8h9" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>`
4621
+ );
4215
4622
  var I_COMMENT = svg(
4216
4623
  `<path d="M2 3.1h12v7.4H6.5l-3.3 2.6v-2.6H2z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/><path d="M8 5.1v3.4M6.3 6.8h3.4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>`
4217
4624
  );