@loupekit/sdk 0.10.12 → 0.10.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ var STYLES = (
9
9
  Dark is the default; the host element gets .theme-light to flip to light. */
10
10
  :host {
11
11
  --accent: #6b73e6;
12
+ --accent-soft: rgba(107, 115, 230, 0.12);
12
13
  --pin: #ff5842;
13
14
  --bg: #14161d;
14
15
  --bg-2: #1b1e27;
@@ -20,6 +21,7 @@ var STYLES = (
20
21
  }
21
22
  :host(.theme-light) {
22
23
  --accent: #4a55d6;
24
+ --accent-soft: rgba(74, 85, 214, 0.12);
23
25
  --pin: #ff5842;
24
26
  --bg: #ffffff;
25
27
  --bg-2: #f6f7fb;
@@ -36,7 +38,7 @@ var STYLES = (
36
38
  .hl {
37
39
  position: fixed; pointer-events: none; z-index: 2147483001;
38
40
  border: 2px solid var(--accent);
39
- background: rgba(107, 115, 230, 0.12);
41
+ background: var(--accent-soft);
40
42
  border-radius: 4px; display: none;
41
43
  transition: all 60ms linear;
42
44
  }
@@ -49,7 +51,7 @@ var STYLES = (
49
51
  /* region selection (during drag) + active-comment outline */
50
52
  .selbox {
51
53
  position: fixed; pointer-events: none; z-index: 2147483001; display: none;
52
- border: 2px dashed var(--accent); background: rgba(107, 115, 230, 0.14); border-radius: 4px;
54
+ border: 2px dashed var(--accent); background: var(--accent-soft); border-radius: 4px;
53
55
  }
54
56
  .region-box {
55
57
  position: fixed; pointer-events: none; z-index: 2147483001; display: none;
@@ -351,6 +353,12 @@ var STYLES = (
351
353
 
352
354
  /* Timeline grouping (project scope) + the repo filter. */
353
355
  .daylabel { margin: 8px 2px 0; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); font-weight: 700; }
356
+ /* The page a comment belongs to, shown in the project scope (Settings \u2192 Page paths). */
357
+ .pathtag {
358
+ padding: 1px 6px; border-radius: 999px; background: var(--bg-3); color: var(--muted);
359
+ font-size: 10.5px; font-family: ui-monospace, Menlo, monospace;
360
+ max-width: 130px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
361
+ }
354
362
  .listhead .reposel {
355
363
  margin-left: 6px; font-size: 11px; padding: 3px 6px; border: 1px solid var(--line);
356
364
  border-radius: 7px; background: var(--bg-2); color: var(--ink);
@@ -398,6 +406,97 @@ var STYLES = (
398
406
  @keyframes loupe-recpulse { 0%,100% { opacity: 1; } 50% { opacity: .25; } }
399
407
  @media (prefers-reduced-motion: reduce) { .recbar .recdot { animation: none; } }
400
408
 
409
+ /* ---------------------------------------------- header popovers (pos + settings) */
410
+ .menu-wrap { position: relative; }
411
+ .menu {
412
+ position: absolute; top: calc(100% + 6px); right: 0; z-index: 30;
413
+ min-width: 214px; padding: 8px; display: none;
414
+ background: var(--bg-2); border: 1px solid var(--line); border-radius: 12px; box-shadow: var(--shadow);
415
+ }
416
+ .menu.open { display: block; }
417
+ .menu-label {
418
+ margin: 2px 5px 6px; font-size: 10px; font-weight: 700; letter-spacing: .06em;
419
+ text-transform: uppercase; color: var(--muted);
420
+ }
421
+ /* dock-position grid: the four layouts as a 2x2 of buttons */
422
+ .pos-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
423
+ .pos-grid button {
424
+ display: flex; align-items: center; gap: 6px; padding: 7px 8px; cursor: pointer;
425
+ border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--muted);
426
+ font-size: 11.5px; font-weight: 600;
427
+ }
428
+ .pos-grid button:hover { border-color: var(--accent); color: var(--ink); }
429
+ .pos-grid button.on { border-color: var(--accent); color: var(--accent); }
430
+ .pos-grid button svg { width: 14px; height: 14px; flex: none; }
431
+ /* settings rows */
432
+ .menu-row {
433
+ display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%;
434
+ padding: 7px 6px; border: 0; border-radius: 8px; background: transparent; color: var(--ink);
435
+ font-size: 12.5px; text-align: left; cursor: pointer;
436
+ }
437
+ .menu-row:hover { background: var(--bg-3); }
438
+ .menu-sep { height: 1px; margin: 6px 4px; background: var(--line); }
439
+ /* on/off switch inside a settings row */
440
+ .sw { flex: none; position: relative; width: 30px; height: 17px; border-radius: 999px; background: var(--line); transition: background .12s; }
441
+ .sw::after { content: ""; position: absolute; top: 2px; left: 2px; width: 13px; height: 13px; border-radius: 50%; background: #fff; transition: transform .12s; }
442
+ .menu-row[aria-pressed="true"] .sw { background: var(--accent); }
443
+ .menu-row[aria-pressed="true"] .sw::after { transform: translateX(13px); }
444
+ /* accent swatches */
445
+ .acc-dots { display: flex; gap: 8px; padding: 4px 6px 2px; }
446
+ .acc-dot { width: 20px; height: 20px; border-radius: 50%; border: 2px solid transparent; padding: 0; cursor: pointer; }
447
+ .acc-dot.on { border-color: var(--ink); }
448
+
449
+ /* ---------------------------------------------------------------- minimize bar */
450
+ .minbar { display: none; align-items: center; gap: 8px; padding: 9px 10px; cursor: pointer; background: var(--bg-2); }
451
+ .dock.minimized .minbar { display: flex; }
452
+ .dock.minimized .tabs, .dock.minimized .view, .dock.minimized .resize,
453
+ .dock.minimized .dctl [data-role="min"], .dock.minimized .dctl .menu-wrap { display: none !important; }
454
+ .minbar .logo { color: var(--accent); font-size: 14px; }
455
+ .minbar .mtext { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: var(--muted); }
456
+ .minbar .mtext b { color: var(--ink); }
457
+ .minbar .mrestore { flex: none; padding: 0 2px; border: 0; background: transparent; color: var(--accent); font-size: 14px; cursor: pointer; }
458
+
459
+ /* ------------------------------------------------------- hint card (once per view) */
460
+ .hint {
461
+ position: relative; margin: 8px 12px 0; padding: 10px 28px 10px 11px;
462
+ border: 1px solid var(--accent); border-radius: 10px; background: var(--bg-2);
463
+ }
464
+ .hint-t { margin-bottom: 3px; font-size: 12.5px; font-weight: 700; color: var(--ink); }
465
+ .hint-b { font-size: 11.5px; line-height: 1.45; color: var(--muted); }
466
+ .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; }
467
+ .hint-x {
468
+ position: absolute; top: 6px; right: 6px; width: 18px; height: 18px; padding: 0;
469
+ border: 0; border-radius: 5px; background: transparent; color: var(--muted); font-size: 13px; line-height: 1; cursor: pointer;
470
+ }
471
+ .hint-x:hover { background: var(--bg-3); color: var(--ink); }
472
+
473
+ /* ---------------------------------------------------------------- guided tour */
474
+ /* Non-modal on purpose: the dimmer and the spotlight are click-through, so a tour
475
+ can never trap someone mid-task. Only the card itself takes pointer events. */
476
+ .tour { position: fixed; inset: 0; z-index: 2147483200; display: none; pointer-events: none; }
477
+ .tour.open { display: block; }
478
+ .tour-spot {
479
+ position: fixed; border: 2px solid var(--accent); border-radius: 10px; pointer-events: none;
480
+ box-shadow: 0 0 0 9999px rgba(8, 10, 16, .62);
481
+ transition: all .18s ease;
482
+ }
483
+ .tour-card {
484
+ position: fixed; width: 252px; padding: 12px; pointer-events: auto;
485
+ background: var(--bg-2); border: 1px solid var(--line); border-radius: 12px; box-shadow: var(--shadow); color: var(--ink);
486
+ }
487
+ .tour-title { margin-bottom: 4px; font-size: 13px; font-weight: 700; }
488
+ .tour-body { font-size: 11.5px; line-height: 1.5; color: var(--muted); }
489
+ .tour-foot { display: flex; align-items: center; gap: 6px; margin-top: 10px; }
490
+ .tour-dots { display: flex; flex: 1; gap: 4px; }
491
+ .tour-dots i { width: 5px; height: 5px; border-radius: 50%; background: var(--line); }
492
+ .tour-dots i.on { background: var(--accent); }
493
+ .tour-foot button {
494
+ padding: 5px 9px; border: 1px solid var(--line); border-radius: 7px;
495
+ background: var(--bg); color: var(--ink); font-size: 11.5px; font-weight: 600; cursor: pointer;
496
+ }
497
+ .tour-foot .t-next { border-color: var(--accent); background: var(--accent); color: #fff; }
498
+ .tour-foot .t-skip { border: 0; background: transparent; color: var(--muted); }
499
+
401
500
  /* Mobile: left/right/float docking is a desktop affordance. On small screens the
402
501
  panel collapses to a bottom sheet that OVERLAYS the page (pushing a side dock
403
502
  here would squeeze the page to a useless sliver), regardless of the chosen dock
@@ -408,7 +507,8 @@ var STYLES = (
408
507
  width: auto !important; height: 76vh !important;
409
508
  border-width: 1px 0 0 0 !important; border-radius: 16px 16px 0 0 !important;
410
509
  }
411
- .dctl [data-dock], .dctl .gap { display: none; } /* dock positions don't apply on mobile */
510
+ .dctl [data-role="pos"], .dctl .gap { display: none; } /* dock positions don't apply on mobile */
511
+ .menu { min-width: 190px; }
412
512
  .resize { display: none !important; }
413
513
  .tools button { flex: 1; justify-content: center; } /* full-width tap targets */
414
514
  .dock.mode-bottom .list { grid-template-columns: 1fr; }
@@ -498,10 +598,10 @@ function similarity(a, el2, via) {
498
598
  const t = normText(el2.textContent);
499
599
  add(W.text, t === a.text ? 1 : t && (t.includes(a.text) || a.text.includes(t)) ? 0.5 : 0);
500
600
  }
501
- const attrKeys = Object.keys(a.attrs);
601
+ const attrKeys = Object.keys(a.attrs ?? {});
502
602
  if (attrKeys.length) {
503
603
  let matched = 0;
504
- for (const k of attrKeys) if (el2.getAttribute(k) === a.attrs[k]) matched++;
604
+ for (const k of attrKeys) if (el2.getAttribute(k) === (a.attrs ?? {})[k]) matched++;
505
605
  add(W.attrs, matched / attrKeys.length);
506
606
  }
507
607
  if (a.testid) add(W.testid, stableId(el2) === a.testid ? 1 : 0);
@@ -509,9 +609,11 @@ function similarity(a, el2, via) {
509
609
  const r = el2.getBoundingClientRect();
510
610
  const cx = r.left + window.scrollX + r.width / 2;
511
611
  const cy = r.top + window.scrollY + r.height / 2;
512
- const ax = a.rect.x + a.rect.w / 2;
513
- const ay = a.rect.y + a.rect.h / 2;
514
- const diag = Math.hypot(a.viewport.w, a.viewport.h) || 1;
612
+ const ar = a.rect ?? { x: 0, y: 0, w: 0, h: 0 };
613
+ const av = a.viewport ?? { w: 0, h: 0 };
614
+ const ax = ar.x + ar.w / 2;
615
+ const ay = ar.y + ar.h / 2;
616
+ const diag = Math.hypot(av.w, av.h) || 1;
515
617
  const dist = Math.hypot(cx - ax, cy - ay);
516
618
  add(W.position, Math.max(0, 1 - dist / diag));
517
619
  return total ? sum / total : 0;
@@ -2621,7 +2723,48 @@ function normalizePriority(value) {
2621
2723
  }
2622
2724
 
2623
2725
  // src/app.ts
2624
- var SDK_VERSION = true ? "0.10.12" : "dev";
2726
+ var SDK_VERSION = true ? "0.10.13" : "dev";
2727
+ var ACCENTS = [
2728
+ { id: "indigo", dark: "#6b73e6", light: "#4a55d6", soft: "rgba(107,115,230,0.12)" },
2729
+ { id: "violet", dark: "#a06be6", light: "#7c3fd4", soft: "rgba(160,107,230,0.14)" },
2730
+ { id: "teal", dark: "#2fb6a8", light: "#0f8f83", soft: "rgba(47,182,168,0.14)" },
2731
+ { id: "amber", dark: "#d99a2b", light: "#a9700f", soft: "rgba(217,154,43,0.14)" },
2732
+ { id: "rose", dark: "#e05c86", light: "#c2295a", soft: "rgba(224,92,134,0.14)" }
2733
+ ];
2734
+ var ACCENT_IDS = ACCENTS.map((a) => a.id);
2735
+ var TOUR = [
2736
+ // Home first: the panel already opens there, so the first step never moves the
2737
+ // user — the tour starts where they are.
2738
+ {
2739
+ sel: ".hstat",
2740
+ tab: "home",
2741
+ title: "Home shows what needs you",
2742
+ body: "Four tiles count open, needs-you, resolved and stale feedback. Click one to narrow the list to that bucket."
2743
+ },
2744
+ {
2745
+ sel: ".hscope",
2746
+ tab: "home",
2747
+ title: "This page, or the whole project",
2748
+ body: "Switch to All to see every page's feedback as a day-grouped timeline, with a repo filter."
2749
+ },
2750
+ {
2751
+ sel: ".tools",
2752
+ tab: "comments",
2753
+ title: "Pin feedback anywhere",
2754
+ body: "Inspect picks an element, Note drops a page-level comment, Region captures a rectangle, and Record films one."
2755
+ },
2756
+ {
2757
+ sel: '.tabs [data-tab="connect"]',
2758
+ tab: "connect",
2759
+ title: "Hand it to Claude",
2760
+ body: "Connect Claude wires up the MCP server so an agent reads this feedback with its element context."
2761
+ }
2762
+ ];
2763
+ var HINTS = {
2764
+ home: { title: "Your triage at a glance", body: "The tiles count this page by default. Switch to All for the whole project, or click a tile to jump straight to that bucket." },
2765
+ comments: { title: "Pin, note or record", body: "Inspect selects an element, Note comments anywhere on the page, Region screenshots a rectangle, and Record captures video of one." },
2766
+ connect: { title: "Claude reads these", body: "Add the MCP server to your client and it can list, read and answer this feedback \u2014 screenshot included." }
2767
+ };
2625
2768
  var DOCK_MODES = ["left", "right", "bottom", "float"];
2626
2769
  var RECORD_MAX_MS = 2e4;
2627
2770
  var MAX_FILES = 10;
@@ -2700,6 +2843,20 @@ var LoupeApp = class {
2700
2843
  this.statFilter = "";
2701
2844
  /** Repo filter, offered once the scope is "all". */
2702
2845
  this.repoFilter = "";
2846
+ /** Accent preset id (see ACCENTS), applied as inline --accent / --accent-soft. */
2847
+ this.accent = "indigo";
2848
+ /** Collapsed to the one-line minimize bar. */
2849
+ this.minimized = false;
2850
+ /** The "help layer": FAB tooltips and the contextual hint cards. */
2851
+ this.hoverHints = true;
2852
+ /** Show each comment's page path (useful in the project scope). */
2853
+ this.showPaths = false;
2854
+ /** Views whose hint card has already been shown. */
2855
+ this.hintsSeen = /* @__PURE__ */ new Set();
2856
+ /** Current guided-tour step, or -1 when the tour is closed. */
2857
+ this.tourStep = -1;
2858
+ /** The first-run tour has been finished or skipped. */
2859
+ this.tourDone = false;
2703
2860
  /** Float-mode window geometry; (x<=0 && y<=0) → placed on first layout. */
2704
2861
  this.floatRect = { x: 0, y: 0, w: 380, h: 540 };
2705
2862
  this.floatDrag = null;
@@ -2824,7 +2981,12 @@ var LoupeApp = class {
2824
2981
  }
2825
2982
  let elx = this.resolved.get(c.id) ?? null;
2826
2983
  if (!elx || !elx.isConnected) {
2827
- const r = resolveAnchor(c.anchor);
2984
+ let r = null;
2985
+ try {
2986
+ r = resolveAnchor(c.anchor);
2987
+ } catch {
2988
+ r = null;
2989
+ }
2828
2990
  elx = r?.element ?? null;
2829
2991
  this.resolved.set(c.id, elx);
2830
2992
  }
@@ -2867,6 +3029,8 @@ var LoupeApp = class {
2867
3029
  }
2868
3030
  };
2869
3031
  this.onWinResize = () => this.applyDockLayout();
3032
+ /** Which view's hint has been handled this session (so it is not re-painted). */
3033
+ this.hintFor = null;
2870
3034
  // ---- float-mode drag + resize ---------------------------------------------
2871
3035
  this.onHeadPointerDown = (e) => {
2872
3036
  if (this.dockMode !== "float" || e.button !== 0 || this.isMobile()) return;
@@ -2932,6 +3096,7 @@ var LoupeApp = class {
2932
3096
  if (this.scope === "all") void this.loadAllComments();
2933
3097
  this.observe();
2934
3098
  this.watchNavigation();
3099
+ if (!this.tourDone && this.open && !this.isMobile()) this.startTour();
2935
3100
  if (this.cfg.autoOpen) this.setMode("inspect");
2936
3101
  }
2937
3102
  /**
@@ -2984,7 +3149,16 @@ var LoupeApp = class {
2984
3149
  this.dock = this.buildDock();
2985
3150
  this.fabCluster = this.buildFabCluster();
2986
3151
  this.recBar = this.buildRecBar();
2987
- this.shadow.append(this.dock, this.fabCluster, this.recBar);
3152
+ this.tourEl = el("div", "tour");
3153
+ this.tourSpot = el("div", "tour-spot");
3154
+ this.tourCard = el("div", "tour-card");
3155
+ this.tourEl.append(this.tourSpot, this.tourCard);
3156
+ this.shadow.append(this.dock, this.fabCluster, this.recBar, this.tourEl);
3157
+ this.shadow.addEventListener("click", (e) => {
3158
+ const t = e.target;
3159
+ if (t?.closest && t.closest(".menu-wrap")) return;
3160
+ this.closeMenus();
3161
+ });
2988
3162
  }
2989
3163
  /**
2990
3164
  * The dockable control panel:
@@ -2999,33 +3173,71 @@ var LoupeApp = class {
2999
3173
  brand.querySelector(".title").textContent = this.cfg.label ?? "Loupe";
3000
3174
  head.addEventListener("pointerdown", this.onHeadPointerDown);
3001
3175
  const ctl = el("div", "dctl");
3002
- const dockBtn = (mode, icon, title) => {
3003
- const b = el("button");
3004
- b.dataset.dock = mode;
3005
- b.title = title;
3006
- b.setAttribute("aria-label", title);
3007
- b.innerHTML = icon;
3008
- b.onclick = () => this.setDock(mode);
3009
- return b;
3010
- };
3011
- ctl.append(
3012
- dockBtn("left", I_DOCK_LEFT, "Dock to left"),
3013
- dockBtn("bottom", I_DOCK_BOTTOM, "Dock to bottom"),
3014
- dockBtn("right", I_DOCK_RIGHT, "Dock to right"),
3015
- dockBtn("float", I_FLOAT, "Float"),
3016
- el("span", "gap")
3017
- );
3176
+ const posWrap = el("div", "menu-wrap");
3177
+ const posBtn = el("button");
3178
+ posBtn.dataset.role = "pos";
3179
+ posBtn.title = "Panel position";
3180
+ posBtn.setAttribute("aria-label", "Panel position");
3181
+ posBtn.innerHTML = I_DOCK_RIGHT;
3182
+ this.posMenu = el("div", "menu");
3183
+ const posMeta = [
3184
+ { mode: "left", icon: I_DOCK_LEFT, label: "Left" },
3185
+ { mode: "bottom", icon: I_DOCK_BOTTOM, label: "Bottom" },
3186
+ { mode: "right", icon: I_DOCK_RIGHT, label: "Right" },
3187
+ { mode: "float", icon: I_FLOAT, label: "Float" }
3188
+ ];
3189
+ 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>`;
3190
+ this.posMenu.querySelectorAll("[data-pos]").forEach((b) => {
3191
+ b.onclick = () => {
3192
+ this.setDock(b.dataset.pos);
3193
+ this.closeMenus();
3194
+ };
3195
+ });
3196
+ posBtn.onclick = () => this.toggleMenu(this.posMenu);
3197
+ posWrap.append(posBtn, this.posMenu);
3018
3198
  this.themeBtn = el("button");
3019
3199
  this.themeBtn.dataset.role = "theme";
3020
3200
  this.themeBtn.onclick = () => this.toggleTheme();
3201
+ const setWrap = el("div", "menu-wrap");
3202
+ const setBtn = el("button");
3203
+ setBtn.dataset.role = "settings";
3204
+ setBtn.title = "Settings";
3205
+ setBtn.setAttribute("aria-label", "Settings");
3206
+ setBtn.innerHTML = I_GEAR;
3207
+ setBtn.onclick = () => this.toggleMenu(this.settingsMenu);
3208
+ this.settingsMenu = el("div", "menu");
3209
+ this.settingsMenu.innerHTML = `<div class="menu-label">Appearance</div><div class="acc-dots">` + ACCENTS.map((a) => `<button class="acc-dot" data-accent="${a.id}" style="background:${a.dark}" title="${a.id}" aria-label="${a.id} accent"></button>`).join("") + `</div><div class="menu-sep"></div><div class="menu-label">Show</div><button class="menu-row" data-set="hoverHints" aria-pressed="true"><span>Hover hints</span><span class="sw"></span></button><button class="menu-row" data-set="markersHidden" aria-pressed="true"><span>Markers</span><span class="sw"></span></button><button class="menu-row" data-set="showPaths" aria-pressed="false"><span>Page paths</span><span class="sw"></span></button><div class="menu-sep"></div><button class="menu-row" data-set="tour"><span>Restart tour</span></button>`;
3210
+ this.settingsMenu.querySelectorAll("[data-accent]").forEach((b) => {
3211
+ b.onclick = () => this.setAccent(b.dataset.accent);
3212
+ });
3213
+ this.settingsMenu.querySelectorAll("[data-set]").forEach((b) => {
3214
+ b.onclick = () => {
3215
+ const key = b.dataset.set;
3216
+ if (key === "tour") {
3217
+ this.closeMenus();
3218
+ this.startTour();
3219
+ return;
3220
+ }
3221
+ this.toggleSetting(key);
3222
+ };
3223
+ });
3224
+ setWrap.append(setBtn, this.settingsMenu);
3225
+ const minBtn = el("button");
3226
+ minBtn.dataset.role = "min";
3227
+ minBtn.title = "Minimize";
3228
+ minBtn.setAttribute("aria-label", "Minimize");
3229
+ minBtn.innerHTML = I_MINIMIZE;
3230
+ minBtn.onclick = () => this.setMinimized(true);
3021
3231
  const closeBtn = el("button");
3022
3232
  closeBtn.dataset.role = "close";
3023
3233
  closeBtn.title = "Close";
3024
3234
  closeBtn.setAttribute("aria-label", "Close");
3025
3235
  closeBtn.innerHTML = I_CLOSE;
3026
3236
  closeBtn.onclick = () => this.closeDock();
3027
- ctl.append(this.themeBtn, closeBtn);
3237
+ ctl.append(posWrap, this.themeBtn, setWrap, minBtn, closeBtn);
3028
3238
  head.append(brand, ctl);
3239
+ this.minBar = el("div", "minbar");
3240
+ this.minBar.onclick = () => this.setMinimized(false);
3029
3241
  const tabs = el("div", "tabs");
3030
3242
  const tabBtn = (key, label) => {
3031
3243
  const b = el("button", "tab", label);
@@ -3083,11 +3295,14 @@ var LoupeApp = class {
3083
3295
  const homeView = el("div", "view home-view");
3084
3296
  this.homeEl = homeView;
3085
3297
  const commentsView = el("div", "view comments-view");
3086
- commentsView.append(tools, listHead, this.listEl, this.buildIntegrations());
3298
+ this.commentsHint = el("div", "hint-slot");
3299
+ commentsView.append(this.commentsHint, tools, listHead, this.listEl, this.buildIntegrations());
3087
3300
  const connectView = this.buildConnectPanel();
3301
+ this.connectHint = el("div", "hint-slot");
3302
+ connectView.prepend(this.connectHint);
3088
3303
  const resize = el("div", "resize");
3089
3304
  resize.addEventListener("pointerdown", this.onResizeDown);
3090
- dock.append(head, tabs, homeView, commentsView, connectView, resize);
3305
+ dock.append(head, this.minBar, tabs, homeView, commentsView, connectView, resize);
3091
3306
  this.buildHomePanel();
3092
3307
  return dock;
3093
3308
  }
@@ -3098,7 +3313,7 @@ var LoupeApp = class {
3098
3313
  */
3099
3314
  buildHomePanel() {
3100
3315
  const title = this.cfg.label ?? "Loupe";
3101
- this.homeEl.innerHTML = `<div class="hstat" id="loupe-hstats"></div><div class="hscope"><button class="hscope-b" data-scope="page">This page</button><button class="hscope-b" data-scope="all">All</button><button class="hrefresh" title="Refresh" aria-label="Refresh">\u27F3</button></div><button class="hpin" data-role="home-pin">\u271B Pin feedback on this page</button><div class="hlabel">Recent</div><div class="hfeed" id="loupe-hfeed"></div><div class="hfoot">${escapeHtml(title)} \xB7 ${escapeHtml(this.cfg.repo ?? "no repo linked")}</div>`;
3316
+ this.homeEl.innerHTML = `<div class="hint-slot" id="loupe-hhint"></div><div class="hstat" id="loupe-hstats"></div><div class="hscope"><button class="hscope-b" data-scope="page">This page</button><button class="hscope-b" data-scope="all">All</button><button class="hrefresh" title="Refresh" aria-label="Refresh">\u27F3</button></div><button class="hpin" data-role="home-pin">\u271B Pin feedback on this page</button><div class="hlabel">Recent</div><div class="hfeed" id="loupe-hfeed"></div><div class="hfoot">${escapeHtml(title)} \xB7 ${escapeHtml(this.cfg.repo ?? "no repo linked")}</div>`;
3102
3317
  this.homeEl.querySelectorAll(".hscope-b").forEach((b) => {
3103
3318
  b.onclick = () => this.setScope(b.dataset.scope === "all" ? "all" : "page");
3104
3319
  });
@@ -3841,6 +4056,7 @@ var LoupeApp = class {
3841
4056
  if (this.tab === tab) return;
3842
4057
  if (tab !== "comments") this.setMode("off");
3843
4058
  this.tab = tab;
4059
+ this.hintFor = null;
3844
4060
  this.saveState();
3845
4061
  if (tab === "home") {
3846
4062
  this.renderHome();
@@ -3854,6 +4070,160 @@ var LoupeApp = class {
3854
4070
  this.saveState();
3855
4071
  this.applyDockLayout();
3856
4072
  }
4073
+ // ---- panel shell: popovers, minimize, accent ------------------------------
4074
+ toggleMenu(menu) {
4075
+ const wasOpen = menu.classList.contains("open");
4076
+ this.closeMenus();
4077
+ if (!wasOpen) menu.classList.add("open");
4078
+ }
4079
+ closeMenus() {
4080
+ this.posMenu?.classList.remove("open");
4081
+ this.settingsMenu?.classList.remove("open");
4082
+ }
4083
+ setAccent(id) {
4084
+ if (!ACCENT_IDS.includes(id)) return;
4085
+ this.accent = id;
4086
+ this.saveState();
4087
+ this.applyDockLayout();
4088
+ }
4089
+ setMinimized(v) {
4090
+ this.minimized = v;
4091
+ this.closeMenus();
4092
+ this.saveState();
4093
+ this.applyDockLayout();
4094
+ }
4095
+ /** Flip one of the "Show …" settings and repaint whatever it governs. */
4096
+ toggleSetting(key) {
4097
+ if (key === "markersHidden") this.markersHidden = !this.markersHidden;
4098
+ else if (key === "hoverHints") this.hoverHints = !this.hoverHints;
4099
+ else this.showPaths = !this.showPaths;
4100
+ this.saveState();
4101
+ if (key === "markersHidden") this.renderPins();
4102
+ if (key === "showPaths") {
4103
+ this.renderList();
4104
+ this.renderHome();
4105
+ }
4106
+ if (key === "hoverHints") this.hintFor = null;
4107
+ this.applyDockLayout();
4108
+ }
4109
+ /** Reflect the current state into the settings menu (never rebuild it). */
4110
+ renderSettings() {
4111
+ if (!this.settingsMenu) return;
4112
+ const on = {
4113
+ hoverHints: this.hoverHints,
4114
+ markersHidden: !this.markersHidden,
4115
+ showPaths: this.showPaths
4116
+ };
4117
+ this.settingsMenu.querySelectorAll("[data-set]").forEach((b) => {
4118
+ const key = b.dataset.set;
4119
+ if (key in on) b.setAttribute("aria-pressed", String(on[key]));
4120
+ });
4121
+ this.settingsMenu.querySelectorAll("[data-accent]").forEach((b) => b.classList.toggle("on", b.dataset.accent === this.accent));
4122
+ }
4123
+ /**
4124
+ * The contextual hint card for the active view. Shown at most once per view and
4125
+ * never again after that — dismissed, or switched off wholesale with "Turn off
4126
+ * hints" (the same switch as Settings → Hover hints).
4127
+ */
4128
+ renderHints() {
4129
+ if (!this.homeEl) return;
4130
+ const slots = {
4131
+ home: this.homeEl.querySelector("#loupe-hhint"),
4132
+ comments: this.commentsHint ?? null,
4133
+ connect: this.connectHint ?? null
4134
+ };
4135
+ const slot = slots[this.tab];
4136
+ if (!slot || this.hintFor === this.tab) return;
4137
+ this.hintFor = this.tab;
4138
+ slot.innerHTML = "";
4139
+ if (!this.hoverHints || this.minimized || this.hintsSeen.has(this.tab)) return;
4140
+ this.hintsSeen.add(this.tab);
4141
+ this.saveState();
4142
+ const hint = HINTS[this.tab];
4143
+ slot.innerHTML = `<div class="hint"><div class="hint-t">${escapeHtml(hint.title)}</div><div class="hint-b">${escapeHtml(hint.body)}</div><button class="hint-off" data-role="hint-off">Turn off hints</button><button class="hint-x" aria-label="Dismiss hint">\u2715</button></div>`;
4144
+ slot.querySelector('[data-role="hint-off"]').onclick = () => {
4145
+ this.hoverHints = false;
4146
+ this.hintFor = null;
4147
+ this.saveState();
4148
+ this.applyDockLayout();
4149
+ };
4150
+ slot.querySelector(".hint-x").onclick = () => {
4151
+ slot.innerHTML = "";
4152
+ };
4153
+ }
4154
+ // ---- guided tour ----------------------------------------------------------
4155
+ startTour() {
4156
+ this.tourStep = 0;
4157
+ this.open = true;
4158
+ this.minimized = false;
4159
+ this.applyDockLayout();
4160
+ this.renderTour();
4161
+ }
4162
+ stopTour() {
4163
+ this.tourStep = -1;
4164
+ this.tourDone = true;
4165
+ this.saveState();
4166
+ this.tourEl.classList.remove("open");
4167
+ this.applyDockLayout();
4168
+ }
4169
+ gotoTour(step) {
4170
+ if (step < 0) {
4171
+ this.stopTour();
4172
+ return;
4173
+ }
4174
+ this.tourStep = step;
4175
+ this.renderTour();
4176
+ }
4177
+ /** Position the spotlight over the current step's target and lay out the card. */
4178
+ renderTour() {
4179
+ if (this.tourStep < 0 || this.tourStep >= TOUR.length) {
4180
+ this.tourEl.classList.remove("open");
4181
+ return;
4182
+ }
4183
+ const step = TOUR[this.tourStep];
4184
+ if (this.tab !== step.tab) this.setTab(step.tab);
4185
+ this.tourEl.classList.add("open");
4186
+ const pad = 4;
4187
+ const target = this.shadow.querySelector(step.sel);
4188
+ const box = target?.getBoundingClientRect();
4189
+ 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 };
4190
+ this.tourSpot.style.left = `${r.left}px`;
4191
+ this.tourSpot.style.top = `${r.top}px`;
4192
+ this.tourSpot.style.width = `${r.width}px`;
4193
+ this.tourSpot.style.height = `${r.height}px`;
4194
+ const last = this.tourStep === TOUR.length - 1;
4195
+ 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>`;
4196
+ const W2 = 252;
4197
+ const H = this.tourCard.offsetHeight || 130;
4198
+ 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;
4199
+ const candidates = [
4200
+ [r.left + r.width / 2 - W2 / 2, r.top + r.height + 12],
4201
+ // below
4202
+ [r.left + r.width / 2 - W2 / 2, r.top - H - 12],
4203
+ // above
4204
+ [r.left - W2 - 12, r.top + r.height / 2 - H / 2],
4205
+ // beside (left)
4206
+ [r.left + r.width + 12, r.top + r.height / 2 - H / 2]
4207
+ // beside (right)
4208
+ ];
4209
+ let cx = clampPx(candidates[0][0], 8, Math.max(8, window.innerWidth - W2 - 8));
4210
+ let cy = clampPx(candidates[0][1], 8, Math.max(8, window.innerHeight - H - 8));
4211
+ for (const [x, y] of candidates) {
4212
+ const px = clampPx(x, 8, Math.max(8, window.innerWidth - W2 - 8));
4213
+ const py = clampPx(y, 8, Math.max(8, window.innerHeight - H - 8));
4214
+ cx = px;
4215
+ cy = py;
4216
+ if (!overlapsTarget(px, py)) break;
4217
+ }
4218
+ this.tourCard.style.left = `${cx}px`;
4219
+ this.tourCard.style.top = `${cy}px`;
4220
+ this.tourCard.querySelector(".t-back")?.addEventListener("click", () => this.gotoTour(this.tourStep - 1));
4221
+ this.tourCard.querySelector(".t-skip").addEventListener("click", () => this.stopTour());
4222
+ this.tourCard.querySelector(".t-next").addEventListener("click", () => {
4223
+ if (last) this.stopTour();
4224
+ else this.gotoTour(this.tourStep + 1);
4225
+ });
4226
+ }
3857
4227
  /** Narrow viewports render the panel as a bottom sheet, not a side/float dock. */
3858
4228
  isMobile() {
3859
4229
  return window.innerWidth <= 640;
@@ -3872,6 +4242,12 @@ var LoupeApp = class {
3872
4242
  if (typeof p?.repoFilter === "string") this.repoFilter = p.repoFilter;
3873
4243
  if (p?.float && typeof p.float.w === "number") this.floatRect = { ...this.floatRect, ...p.float };
3874
4244
  if (typeof p?.markersHidden === "boolean") this.markersHidden = p.markersHidden;
4245
+ if (p?.accent && ACCENT_IDS.includes(p.accent)) this.accent = p.accent;
4246
+ if (typeof p?.minimized === "boolean") this.minimized = p.minimized;
4247
+ if (typeof p?.hoverHints === "boolean") this.hoverHints = p.hoverHints;
4248
+ if (typeof p?.showPaths === "boolean") this.showPaths = p.showPaths;
4249
+ if (typeof p?.tourDone === "boolean") this.tourDone = p.tourDone;
4250
+ if (Array.isArray(p?.hintsSeen)) this.hintsSeen = new Set(p.hintsSeen.filter((x) => typeof x === "string"));
3875
4251
  } catch {
3876
4252
  }
3877
4253
  }
@@ -3886,7 +4262,13 @@ var LoupeApp = class {
3886
4262
  markersHidden: this.markersHidden,
3887
4263
  scope: this.scope,
3888
4264
  statFilter: this.statFilter,
3889
- repoFilter: this.repoFilter
4265
+ repoFilter: this.repoFilter,
4266
+ accent: this.accent,
4267
+ minimized: this.minimized,
4268
+ hoverHints: this.hoverHints,
4269
+ showPaths: this.showPaths,
4270
+ tourDone: this.tourDone,
4271
+ hintsSeen: [...this.hintsSeen]
3890
4272
  }));
3891
4273
  } catch {
3892
4274
  }
@@ -3896,14 +4278,23 @@ var LoupeApp = class {
3896
4278
  this.root.classList.toggle("theme-light", this.theme === "light");
3897
4279
  this.themeBtn.title = this.theme === "dark" ? "Switch to light theme" : "Switch to dark theme";
3898
4280
  this.themeBtn.innerHTML = this.theme === "dark" ? I_SUN : I_MOON;
4281
+ const accent = ACCENTS.find((a) => a.id === this.accent) ?? ACCENTS[0];
4282
+ this.root.style.setProperty("--accent", this.theme === "light" ? accent.light : accent.dark);
4283
+ this.root.style.setProperty("--accent-soft", accent.soft);
3899
4284
  const d = this.dock;
3900
4285
  d.classList.toggle("open", this.open);
3901
4286
  for (const m of DOCK_MODES) d.classList.toggle("mode-" + m, this.dockMode === m);
4287
+ d.classList.toggle("minimized", this.minimized);
4288
+ const openCount = this.comments.filter((c) => !isResolved(c)).length;
4289
+ 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>`;
3902
4290
  d.classList.toggle("tab-home", this.tab === "home");
3903
4291
  d.classList.toggle("tab-comments", this.tab === "comments");
3904
4292
  d.classList.toggle("tab-connect", this.tab === "connect");
3905
4293
  this.renderHome();
3906
4294
  d.querySelectorAll(".tabs .tab").forEach((b) => b.classList.toggle("on", b.dataset.tab === this.tab));
4295
+ this.posMenu.querySelectorAll("[data-pos]").forEach((b) => b.classList.toggle("on", b.dataset.pos === this.dockMode));
4296
+ this.renderSettings();
4297
+ this.renderHints();
3907
4298
  if (this.dockMode === "float") {
3908
4299
  const vw = window.innerWidth, vh = window.innerHeight;
3909
4300
  let { x, y, w, h } = this.floatRect;
@@ -4005,6 +4396,9 @@ var LoupeApp = class {
4005
4396
  }
4006
4397
  if (isResolved(c)) top.appendChild(el("span", "badge done", "resolved"));
4007
4398
  else if (detached) top.appendChild(el("span", "badge detached", "element moved/removed"));
4399
+ if (this.showPaths && this.scope === "all" && c.url) {
4400
+ top.appendChild(el("span", "pathtag", shortPath(c.url)));
4401
+ }
4008
4402
  top.appendChild(el("span", "caret", open ? "\u25BE" : "\u25B8"));
4009
4403
  item.appendChild(top);
4010
4404
  const summary = c.title || (c.body.split("\n")[0] ?? "").slice(0, 140) || "(no description)";
@@ -4141,6 +4535,13 @@ function fmtAgo(iso) {
4141
4535
  if (h < 24) return `${h}h ago`;
4142
4536
  return `${Math.round(h / 24)}d ago`;
4143
4537
  }
4538
+ function shortPath(url) {
4539
+ try {
4540
+ return new URL(url, location.origin).pathname || "/";
4541
+ } catch {
4542
+ return url;
4543
+ }
4544
+ }
4144
4545
  function dayLabel(iso) {
4145
4546
  const d = new Date(iso);
4146
4547
  if (Number.isNaN(d.getTime())) return "Earlier";
@@ -4185,6 +4586,12 @@ var I_MOON = svg(
4185
4586
  var I_CLOSE = svg(
4186
4587
  `<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"/>`
4187
4588
  );
4589
+ var I_GEAR = svg(
4590
+ `<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"/>`
4591
+ );
4592
+ var I_MINIMIZE = svg(
4593
+ `<path d="M3.5 8h9" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>`
4594
+ );
4188
4595
  var I_COMMENT = svg(
4189
4596
  `<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"/>`
4190
4597
  );