@loupekit/sdk 0.10.28 → 0.11.0

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.
@@ -24,12 +24,15 @@ var Loupe = (() => {
24
24
  clearActivity: () => clearActivity,
25
25
  connectTab: () => connectTab,
26
26
  destroy: () => destroy,
27
+ hideLauncher: () => hideLauncher,
27
28
  init: () => init,
28
29
  openTool: () => openTool,
29
30
  requestNavigation: () => requestNavigation2,
30
31
  setActivityStatus: () => setActivityStatus,
31
32
  setLocalAi: () => setLocalAi,
32
- trackActivity: () => trackActivity
33
+ showLauncher: () => showLauncher,
34
+ trackActivity: () => trackActivity,
35
+ version: () => SDK_VERSION
33
36
  });
34
37
 
35
38
  // src/styles.ts
@@ -121,6 +124,14 @@ var Loupe = (() => {
121
124
  display: none; flex-direction: column; align-items: flex-end; gap: 10px;
122
125
  }
123
126
  .fab-cluster.show { display: flex; }
127
+ /* A dragged launcher is anchored to its nearest edges (JS sets left/right/top/bottom),
128
+ and the quick actions grow INTO the page: downward from the upper half, labels to the
129
+ right from the left half. */
130
+ .fab-cluster.at-top { flex-direction: column-reverse; }
131
+ .fab-cluster.at-left, .fab-cluster.at-left .fab-minis { align-items: flex-start; }
132
+ .fab-cluster.at-left .fab-mini .fab-tip { right: auto; left: calc(100% + 8px); }
133
+ .fab-cluster.dragging .launcher { cursor: grabbing; border-color: var(--accent); }
134
+ .fab-cluster.dragging .fab-mini .fab-tip { display: none; }
124
135
 
125
136
  .fab-minis { display: none; flex-direction: column; align-items: flex-end; gap: 10px; }
126
137
  .fab-cluster.expanded .fab-minis { display: flex; }
@@ -129,7 +140,10 @@ var Loupe = (() => {
129
140
  .fab-cluster.expanded .fab-minis .fab-mini:nth-child(2) { animation-delay: 30ms; }
130
141
  .fab-cluster.expanded .fab-minis .fab-mini:nth-child(3) { animation-delay: 60ms; }
131
142
  .fab-cluster.expanded .fab-minis .fab-mini:nth-child(4) { animation-delay: 90ms; }
143
+ .fab-cluster.expanded .fab-minis .fab-mini:nth-child(5) { animation-delay: 120ms; }
132
144
  @keyframes loupe-fab-in { from { opacity: 0; transform: translateY(8px) scale(.9); } to { opacity: 1; transform: none; } }
145
+ .fab-cluster.at-top.expanded .fab-minis .fab-mini { animation-name: loupe-fab-in-down; }
146
+ @keyframes loupe-fab-in-down { from { opacity: 0; transform: translateY(-8px) scale(.9); } to { opacity: 1; transform: none; } }
133
147
 
134
148
  .fab-mini {
135
149
  position: relative; width: 40px; height: 40px; border-radius: 50%; padding: 0;
@@ -150,22 +164,32 @@ var Loupe = (() => {
150
164
  }
151
165
  .fab-mini:hover .fab-tip { opacity: 1; }
152
166
 
167
+ /* The launcher and its chevron share a box so the chevron can sit on the launcher's
168
+ corner while being its own button (tap = quick actions; the launcher = open). */
169
+ .fab-main { position: relative; display: inline-flex; }
153
170
  .launcher {
154
171
  position: relative; width: 46px; height: 46px; border-radius: 50%; padding: 0;
155
172
  border: 1px solid var(--line); background: var(--bg-2); color: var(--ink);
156
- cursor: pointer; display: inline-flex; align-items: center; justify-content: center;
173
+ cursor: grab; display: inline-flex; align-items: center; justify-content: center;
157
174
  box-shadow: var(--shadow);
175
+ touch-action: none; user-select: none; -webkit-user-select: none; /* drag with a finger too */
158
176
  }
159
177
  .launcher:hover { border-color: var(--accent); }
178
+ .launcher:active { cursor: grabbing; }
160
179
  .launcher .logo { font-size: 24px; line-height: 1; color: var(--accent); }
161
180
  /* Chevron pinned to the corner: up = actions are tucked away, down = they are out. */
162
- .launcher .lchev {
163
- position: absolute; right: -3px; bottom: -3px; width: 18px; height: 18px; border-radius: 50%;
181
+ .fab-more {
182
+ position: absolute; right: -3px; bottom: -3px; width: 20px; height: 20px; border-radius: 50%;
183
+ padding: 0; cursor: pointer; z-index: 1;
164
184
  background: var(--bg); border: 1px solid var(--line); color: var(--muted);
165
185
  display: grid; place-items: center; transition: transform 160ms cubic-bezier(.16, 1, .3, 1);
166
186
  }
167
- .launcher .lchev svg { width: 11px; height: 11px; display: block; }
168
- .fab-cluster.expanded .launcher .lchev { transform: rotate(180deg); }
187
+ .fab-more:hover { border-color: var(--accent); color: var(--accent); }
188
+ .fab-more .lchev { display: grid; place-items: center; }
189
+ .fab-more svg { width: 11px; height: 11px; display: block; }
190
+ .fab-cluster.expanded .fab-more { transform: rotate(180deg); }
191
+ .fab-cluster.at-top .fab-more { bottom: auto; top: -3px; transform: rotate(180deg); }
192
+ .fab-cluster.at-top.expanded .fab-more { transform: none; }
169
193
  .launcher .lcount {
170
194
  position: absolute; top: -5px; right: -5px; background: var(--pin); color: #fff;
171
195
  font-size: 10px; font-weight: 700; line-height: 1; border-radius: 999px; padding: 3px 6px;
@@ -250,7 +274,10 @@ var Loupe = (() => {
250
274
  .item .num { background: var(--pin); color: #fff; width: 20px; height: 20px; border-radius: 50%; font-size: 11px; font-weight: 700; display: grid; place-items: center; flex: none; }
251
275
  .item .num.detached { background: #9aa0af; }
252
276
  .item .num.done { background: #10935a; }
253
- .item .who { font-size: 12px; font-weight: 600; }
277
+ /* Author + absolute time, visible collapsed too. */
278
+ .item .who { display: flex; flex-wrap: wrap; gap: 0 4px; font-size: 11.5px; color: var(--muted); margin-top: 3px; }
279
+ .item .who b { color: var(--ink); font-weight: 600; }
280
+ .item .who time { font-variant-numeric: tabular-nums; white-space: nowrap; }
254
281
  .item .device { font-size: 10px; color: var(--muted); background: var(--bg-3); border-radius: 999px; padding: 1px 7px; white-space: nowrap; }
255
282
  .item .body { font-size: 13px; line-height: 1.4; }
256
283
  .item .meta { font-size: 11px; color: var(--muted); margin-top: 6px; font-family: ui-monospace, Menlo, monospace; word-break: break-all; }
@@ -445,6 +472,23 @@ var Loupe = (() => {
445
472
  @keyframes loupe-recpulse { 0%,100% { opacity: 1; } 50% { opacity: .25; } }
446
473
  @media (prefers-reduced-motion: reduce) { .recbar .recdot { animation: none; } }
447
474
 
475
+ /* ---------------------------------------------------------------- toast */
476
+ /* A short notice (e.g. how to bring a hidden launcher back). Click to dismiss. */
477
+ .toast {
478
+ position: fixed; z-index: 2147483006; bottom: 24px; left: 50%;
479
+ transform: translateX(-50%) translateY(8px); opacity: 0; pointer-events: none;
480
+ max-width: calc(100vw - 32px); padding: 9px 14px; border-radius: 999px; text-align: center;
481
+ background: var(--bg); color: var(--ink); border: 1px solid var(--line); box-shadow: var(--shadow);
482
+ font-size: 12px; font-weight: 600; line-height: 1.4;
483
+ transition: opacity 160ms ease, transform 160ms ease;
484
+ }
485
+ .toast.show { opacity: 1; transform: translateX(-50%); pointer-events: auto; cursor: pointer; }
486
+ .toast kbd {
487
+ font-family: ui-monospace, Menlo, monospace; font-size: 11px; padding: 1px 5px;
488
+ border: 1px solid var(--line); border-radius: 4px; background: var(--bg-3);
489
+ }
490
+ @media (prefers-reduced-motion: reduce) { .toast { transition: none; } }
491
+
448
492
  /* ---------------------------------------------- header popovers (pos + settings) */
449
493
  .menu-wrap { position: relative; }
450
494
  .menu {
@@ -619,6 +663,8 @@ var Loupe = (() => {
619
663
  }
620
664
  .menu-ver b { color: var(--ink); font-family: ui-monospace, Menlo, monospace; font-weight: 600; }
621
665
  .menu-mode { text-transform: uppercase; letter-spacing: .06em; font-size: 9.5px; }
666
+ /* The host package's version, shown only when it differs from this bundle's (a stale publish). */
667
+ .ver-stale { color: var(--pin); font-family: ui-monospace, Menlo, monospace; font-weight: 600; cursor: help; }
622
668
 
623
669
  /* ------------------------------------------------ lifecycle chips + review flow */
624
670
  .lifechip {
@@ -3881,7 +3927,10 @@ var Loupe = (() => {
3881
3927
  };
3882
3928
 
3883
3929
  // src/app.ts
3884
- var SDK_VERSION = true ? "0.10.28" : "dev";
3930
+ var SDK_VERSION = true ? "0.11.0" : "dev";
3931
+ var FAB_SIZE = 46;
3932
+ var FAB_DRAG_THRESHOLD = 6;
3933
+ var LAUNCHER_SHORTCUT = "Alt+Shift+L";
3885
3934
  var ACCENTS = [
3886
3935
  { id: "indigo", dark: "#6b73e6", light: "#4a55d6", soft: "rgba(107,115,230,0.12)" },
3887
3936
  { id: "violet", dark: "#a06be6", light: "#7c3fd4", soft: "rgba(160,107,230,0.14)" },
@@ -3988,6 +4037,21 @@ var Loupe = (() => {
3988
4037
  this.fabExpanded = false;
3989
4038
  /** Whether pin markers are hidden on the page (a quick-action toggle, persisted). */
3990
4039
  this.markersHidden = false;
4040
+ /**
4041
+ * Whether the collapsed launcher is hidden altogether (persisted). The way back is
4042
+ * Alt+Shift+L, Settings → Launcher, or the host calling `showLauncher()`.
4043
+ */
4044
+ this.launcherHidden = false;
4045
+ /**
4046
+ * Where the user dragged the launcher to — the launcher's top-left in viewport px —
4047
+ * or null for the default corner on the dock's side. Persisted; clamped on apply so
4048
+ * a position saved on a wide screen still lands inside a narrow one.
4049
+ */
4050
+ this.fabPos = null;
4051
+ /** The in-flight launcher drag: pointer start, launcher origin, and whether it has moved past the tap threshold. */
4052
+ this.fabDrag = null;
4053
+ /** Set for one tick after a drag so the click the browser fires afterwards does not open the panel. */
4054
+ this.fabSuppressClick = false;
3991
4055
  this.comments = [];
3992
4056
  /** Free-text filter over the list (title / body / author). */
3993
4057
  this.search = "";
@@ -4088,6 +4152,56 @@ var Loupe = (() => {
4088
4152
  this.lastReplyId = "";
4089
4153
  /** In-app notifications: mentions of you, newest first. */
4090
4154
  this.notifications = [];
4155
+ // ---- launcher: drag anywhere, hide, bring back ------------------------------
4156
+ this.onFabPointerDown = (e) => {
4157
+ if (e.pointerType === "mouse" && e.button !== 0) return;
4158
+ const target = e.currentTarget;
4159
+ const r = target.getBoundingClientRect();
4160
+ this.fabDrag = { px: e.clientX, py: e.clientY, ox: r.left, oy: r.top, moved: false };
4161
+ let captured = false;
4162
+ try {
4163
+ if (typeof target.setPointerCapture === "function") {
4164
+ target.setPointerCapture(e.pointerId);
4165
+ captured = true;
4166
+ }
4167
+ } catch {
4168
+ captured = false;
4169
+ }
4170
+ if (!captured) {
4171
+ window.addEventListener("pointermove", this.onFabPointerMove);
4172
+ window.addEventListener("pointerup", this.onFabPointerUp);
4173
+ window.addEventListener("pointercancel", this.onFabPointerUp);
4174
+ }
4175
+ };
4176
+ this.onFabPointerMove = (e) => {
4177
+ const d = this.fabDrag;
4178
+ if (!d) return;
4179
+ const dx = e.clientX - d.px, dy = e.clientY - d.py;
4180
+ if (!d.moved && Math.hypot(dx, dy) < FAB_DRAG_THRESHOLD) return;
4181
+ if (!d.moved) {
4182
+ d.moved = true;
4183
+ this.fabCluster.classList.add("dragging");
4184
+ this.collapseFab();
4185
+ }
4186
+ e.preventDefault();
4187
+ this.fabPos = this.clampFabPos(d.ox + dx, d.oy + dy);
4188
+ this.applyFabPosition();
4189
+ };
4190
+ this.onFabPointerUp = () => {
4191
+ window.removeEventListener("pointermove", this.onFabPointerMove);
4192
+ window.removeEventListener("pointerup", this.onFabPointerUp);
4193
+ window.removeEventListener("pointercancel", this.onFabPointerUp);
4194
+ const d = this.fabDrag;
4195
+ this.fabDrag = null;
4196
+ if (!d?.moved) return;
4197
+ this.fabCluster.classList.remove("dragging");
4198
+ this.saveState();
4199
+ this.renderSettings();
4200
+ this.fabSuppressClick = true;
4201
+ window.setTimeout(() => {
4202
+ this.fabSuppressClick = false;
4203
+ }, 0);
4204
+ };
4091
4205
  // ---- inspector ------------------------------------------------------------
4092
4206
  // Pointer Events, not mouse events: a touch drag on a phone never fires
4093
4207
  // mousemove/mouseup, so Region/Record selection was impossible on touch devices.
@@ -4130,6 +4244,18 @@ var Loupe = (() => {
4130
4244
  this.closeComposer();
4131
4245
  }
4132
4246
  };
4247
+ /**
4248
+ * Alt+Shift+L toggles the launcher — the way back once it has been hidden, so it is
4249
+ * listened for the whole time the widget is mounted (unlike Escape, which only
4250
+ * matters while a tool is armed). `code` is checked too: on some keyboard layouts
4251
+ * Alt+Shift+L yields a different `key`.
4252
+ */
4253
+ this.onLauncherKey = (e) => {
4254
+ if (!e.altKey || !e.shiftKey || e.ctrlKey || e.metaKey) return;
4255
+ if (e.code !== "KeyL" && (e.key ?? "").toLowerCase() !== "l") return;
4256
+ e.preventDefault();
4257
+ this.setLauncherHidden(!this.launcherHidden);
4258
+ };
4133
4259
  // ---- region ("free-size screenshot") selection ----------------------------
4134
4260
  this.onRegionDown = (e) => {
4135
4261
  if (this.mode !== "region" && this.mode !== "record") return;
@@ -4331,6 +4457,7 @@ var Loupe = (() => {
4331
4457
  this.loadState();
4332
4458
  this.loadProject();
4333
4459
  this.buildDom();
4460
+ document.addEventListener("keydown", this.onLauncherKey);
4334
4461
  if (this.cfg.autoOpen) this.open = true;
4335
4462
  this.applyDockLayout();
4336
4463
  this.lastUrl = this.url;
@@ -4404,11 +4531,15 @@ var Loupe = (() => {
4404
4531
  this.dock = this.buildDock();
4405
4532
  this.fabCluster = this.buildFabCluster();
4406
4533
  this.recBar = this.buildRecBar();
4534
+ this.toastEl = el("div", "toast");
4535
+ this.toastEl.setAttribute("role", "status");
4536
+ this.toastEl.setAttribute("aria-live", "polite");
4537
+ this.toastEl.onclick = () => this.toastEl.classList.remove("show");
4407
4538
  this.tourEl = el("div", "tour");
4408
4539
  this.tourSpot = el("div", "tour-spot");
4409
4540
  this.tourCard = el("div", "tour-card");
4410
4541
  this.tourEl.append(this.tourSpot, this.tourCard);
4411
- this.shadow.append(this.dock, this.fabCluster, this.recBar, this.tourEl);
4542
+ this.shadow.append(this.dock, this.fabCluster, this.recBar, this.toastEl, this.tourEl);
4412
4543
  this.shadow.addEventListener("click", (e) => {
4413
4544
  const t = e.target;
4414
4545
  if (t?.closest && t.closest(".menu-wrap")) return;
@@ -4461,7 +4592,7 @@ var Loupe = (() => {
4461
4592
  setBtn.innerHTML = I_GEAR;
4462
4593
  setBtn.onclick = () => this.toggleMenu(this.settingsMenu);
4463
4594
  this.settingsMenu = el("div", "menu");
4464
- 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>`;
4595
+ 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><button class="menu-row" data-set="launcherHidden" aria-pressed="true" title="The \u25CE button shown while the panel is closed. ${LAUNCHER_SHORTCUT} toggles it too."><span>Launcher</span><span class="sw"></span></button><div class="menu-sep"></div><button class="menu-row" data-set="fabReset" hidden><span>Reset launcher position</span></button><button class="menu-row" data-set="tour"><span>Restart tour</span></button><div class="menu-ver"><span>Loupe <b>v${escapeHtml(SDK_VERSION)}</b>${this.packageVersionNote()}</span><span class="menu-mode">${this.cfg.apiBase ? "server" : "offline"}</span></div>`;
4465
4596
  this.settingsMenu.querySelectorAll("[data-accent]").forEach((b) => {
4466
4597
  b.onclick = () => this.setAccent(b.dataset.accent);
4467
4598
  });
@@ -4473,6 +4604,11 @@ var Loupe = (() => {
4473
4604
  this.startTour();
4474
4605
  return;
4475
4606
  }
4607
+ if (key === "fabReset") {
4608
+ this.closeMenus();
4609
+ this.resetLauncherPosition();
4610
+ return;
4611
+ }
4476
4612
  this.toggleSetting(key);
4477
4613
  };
4478
4614
  });
@@ -5071,7 +5207,7 @@ var Loupe = (() => {
5071
5207
  */
5072
5208
  buildHomePanel() {
5073
5209
  const title = this.cfg.label ?? "Loupe";
5074
- 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="hnotif" id="loupe-hnotif"></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>`;
5210
+ 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="hnotif" id="loupe-hnotif"></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>${this.packageVersionNote()}</div>`;
5075
5211
  this.homeEl.querySelector('[data-role="proj-open"]').onclick = (e) => {
5076
5212
  e.stopPropagation();
5077
5213
  this.setProjectOpen(!this.projOpen);
@@ -5164,7 +5300,7 @@ var Loupe = (() => {
5164
5300
  feed.innerHTML = recent.length ? recent.map((c) => {
5165
5301
  const stage = STAGE_LABELS[normalizeStatus(c.status)];
5166
5302
  const prio = normalizePriority(c.priority);
5167
- return `<button class="hfeed-i" data-id="${escapeAttr(c.id)}"><span class="hfeed-t">${escapeHtml(c.title || (c.body.split("\n")[0] ?? "").slice(0, 60))}</span><span class="hfeed-m">${escapeHtml(c.author?.name ?? "")} \xB7 ${fmtAgo(c.createdAt)} \xB7 <span class="hfeed-s hfeed-s-${normalizeStatus(c.status)}">${stage}</span><span class="hfeed-p hfeed-p-${prio}">${PRIORITY_LABELS[prio]}</span></span></button>`;
5303
+ return `<button class="hfeed-i" data-id="${escapeAttr(c.id)}"><span class="hfeed-t">${escapeHtml(c.title || (c.body.split("\n")[0] ?? "").slice(0, 60))}</span><span class="hfeed-m">${escapeHtml(c.author?.name ?? "")} \xB7 <span title="${escapeAttr(this.fmtWhen(c.createdAt))}">${fmtAgo(c.createdAt)}</span> \xB7 <span class="hfeed-s hfeed-s-${normalizeStatus(c.status)}">${stage}</span><span class="hfeed-p hfeed-p-${prio}">${PRIORITY_LABELS[prio]}</span></span></button>`;
5168
5304
  }).join("") : `<div class="hempty">Nothing here yet.</div>`;
5169
5305
  feed.querySelectorAll(".hfeed-i").forEach((b) => {
5170
5306
  b.onclick = () => {
@@ -5431,20 +5567,21 @@ var Loupe = (() => {
5431
5567
  }
5432
5568
  /**
5433
5569
  * The collapsed-state FAB cluster. The primary brand button carries the comment
5434
- * count and toggles the quick actions out and back; the actions are the four
5435
- * ways into the product without opening the full panel first:
5436
- * pin a comment, drop a note, hide/show the markers already on the page, and
5437
- * jump straight to the Claude/MCP setup.
5570
+ * count and OPENS the panel — one tap, the thing most people came for. A drag on
5571
+ * it moves the whole cluster anywhere on screen (persisted). The chevron beside it
5572
+ * is its own button and toggles the quick actions: pin a comment, drop a note,
5573
+ * hide/show the markers already on the page, hide the launcher itself, and — when
5574
+ * the host registered one — jump straight to the Connect tab.
5438
5575
  */
5439
5576
  buildFabCluster() {
5440
5577
  const cluster = el("div", "fab-cluster");
5441
5578
  const minis = el("div", "fab-minis");
5442
- const mini = (role, icon, label, title) => {
5579
+ const mini = (role, icon, label2, title) => {
5443
5580
  const b = el("button", "fab-mini");
5444
5581
  b.dataset.fab = role;
5445
5582
  b.title = title;
5446
5583
  b.setAttribute("aria-label", title);
5447
- b.innerHTML = `${icon}<span class="fab-tip">${label}</span>`;
5584
+ b.innerHTML = `${icon}<span class="fab-tip">${label2}</span>`;
5448
5585
  return b;
5449
5586
  };
5450
5587
  const comment = mini("comment", I_COMMENT, "Pin comment", "Pin feedback on any element");
@@ -5461,7 +5598,9 @@ var Loupe = (() => {
5461
5598
  };
5462
5599
  const markers = mini("markers", I_EYE, "Markers", "Show or hide the markers on this page");
5463
5600
  markers.onclick = () => this.toggleMarkers();
5464
- minis.append(comment, note, markers);
5601
+ const hide = mini("hide", I_EYE_OFF, "Hide launcher", `Hide this launcher \u2014 ${LAUNCHER_SHORTCUT} brings it back`);
5602
+ hide.onclick = () => this.setLauncherHidden(true);
5603
+ minis.append(comment, note, markers, hide);
5465
5604
  if (this.tabList.some((t) => t.id === "connect")) {
5466
5605
  const connect = mini("connect", I_PLUG, "Connect Claude", "Set up the Claude/MCP connection");
5467
5606
  connect.onclick = () => {
@@ -5471,14 +5610,33 @@ var Loupe = (() => {
5471
5610
  };
5472
5611
  minis.appendChild(connect);
5473
5612
  }
5613
+ const label = this.cfg.label ?? "Loupe";
5474
5614
  const primary = el("button", "launcher");
5475
- primary.title = `Open ${this.cfg.label ?? "Loupe"}`;
5476
- primary.setAttribute("aria-label", `${this.cfg.label ?? "Loupe"} \u2014 quick actions`);
5477
- primary.setAttribute("aria-expanded", "false");
5478
- primary.innerHTML = `<span class="logo">\u25CE</span>${I_FAB_CHEVRON}<span class="lcount"></span>`;
5615
+ primary.title = `Open ${label} \u2014 drag to move`;
5616
+ primary.setAttribute("aria-label", `Open ${label}`);
5617
+ primary.innerHTML = `<span class="logo">\u25CE</span><span class="lcount"></span>`;
5479
5618
  this.fabBadge = primary.querySelector(".lcount");
5480
- primary.onclick = () => this.toggleFab();
5481
- cluster.append(minis, primary);
5619
+ primary.onclick = () => {
5620
+ if (this.fabSuppressClick) return;
5621
+ this.openDock();
5622
+ };
5623
+ primary.addEventListener("pointerdown", this.onFabPointerDown);
5624
+ primary.addEventListener("pointermove", this.onFabPointerMove);
5625
+ primary.addEventListener("pointerup", this.onFabPointerUp);
5626
+ primary.addEventListener("pointercancel", this.onFabPointerUp);
5627
+ const more = el("button", "fab-more");
5628
+ more.dataset.fab = "more";
5629
+ more.title = "Quick actions";
5630
+ more.setAttribute("aria-label", "Quick actions");
5631
+ more.setAttribute("aria-expanded", "false");
5632
+ more.innerHTML = I_FAB_CHEVRON;
5633
+ more.onclick = (e) => {
5634
+ e.stopPropagation();
5635
+ this.toggleFab();
5636
+ };
5637
+ const main = el("div", "fab-main");
5638
+ main.append(primary, more);
5639
+ cluster.append(minis, main);
5482
5640
  this.fabMinis = minis;
5483
5641
  return cluster;
5484
5642
  }
@@ -5487,6 +5645,79 @@ var Loupe = (() => {
5487
5645
  this.fabExpanded = !this.fabExpanded;
5488
5646
  this.applyFab();
5489
5647
  }
5648
+ /** Keep the launcher fully on screen, with a small margin so it never kisses the edge. */
5649
+ clampFabPos(x, y) {
5650
+ const m = 8;
5651
+ return {
5652
+ x: clampPx(Math.round(x), m, Math.max(m, window.innerWidth - FAB_SIZE - m)),
5653
+ y: clampPx(Math.round(y), m, Math.max(m, window.innerHeight - FAB_SIZE - m))
5654
+ };
5655
+ }
5656
+ /**
5657
+ * Place the cluster. A dragged launcher is anchored to the viewport edge it is
5658
+ * nearest, so the quick actions always grow INTO the page: minis open upward from
5659
+ * the lower half and downward from the upper half, and their labels point away
5660
+ * from the nearest side. With no saved position the cluster sits in the default
5661
+ * corner on the dock's side, so reopening feels like the panel sliding back in.
5662
+ */
5663
+ applyFabPosition() {
5664
+ const s = this.fabCluster.style;
5665
+ if (this.fabPos) {
5666
+ const { x, y } = this.clampFabPos(this.fabPos.x, this.fabPos.y);
5667
+ const atTop = y + FAB_SIZE / 2 < window.innerHeight / 2;
5668
+ const atLeft = x + FAB_SIZE / 2 < window.innerWidth / 2;
5669
+ s.left = atLeft ? `${x}px` : "auto";
5670
+ s.right = atLeft ? "auto" : `${Math.max(0, window.innerWidth - x - FAB_SIZE)}px`;
5671
+ s.top = atTop ? `${y}px` : "auto";
5672
+ s.bottom = atTop ? "auto" : `${Math.max(0, window.innerHeight - y - FAB_SIZE)}px`;
5673
+ this.fabCluster.classList.toggle("at-top", atTop);
5674
+ this.fabCluster.classList.toggle("at-left", atLeft);
5675
+ return;
5676
+ }
5677
+ const leftSide = this.dockMode === "left";
5678
+ s.left = leftSide ? "20px" : "auto";
5679
+ s.right = leftSide ? "auto" : "20px";
5680
+ s.top = "auto";
5681
+ s.bottom = "";
5682
+ this.fabCluster.classList.remove("at-top");
5683
+ this.fabCluster.classList.toggle("at-left", leftSide);
5684
+ }
5685
+ resetLauncherPosition() {
5686
+ this.fabPos = null;
5687
+ this.saveState();
5688
+ this.applyDockLayout();
5689
+ this.renderSettings();
5690
+ }
5691
+ /**
5692
+ * Hide or show the collapsed launcher. Hidden is persisted, and the way back is
5693
+ * announced at the moment of hiding — a control that vanishes with no stated way
5694
+ * to return is a support ticket, not a feature.
5695
+ */
5696
+ setLauncherHidden(hidden) {
5697
+ if (this.launcherHidden === hidden) return;
5698
+ this.launcherHidden = hidden;
5699
+ this.fabExpanded = false;
5700
+ this.saveState();
5701
+ this.applyDockLayout();
5702
+ this.renderSettings();
5703
+ const label = this.cfg.label ?? "Loupe";
5704
+ this.toast(hidden ? `${escapeHtml(label)} launcher hidden \u2014 press <kbd>${LAUNCHER_SHORTCUT}</kbd> to bring it back` : `${escapeHtml(label)} launcher is back`);
5705
+ }
5706
+ /** Public seam for the host (`Loupe.showLauncher()`). */
5707
+ showLauncher() {
5708
+ this.setLauncherHidden(false);
5709
+ }
5710
+ /** Public seam for the host (`Loupe.hideLauncher()`). */
5711
+ hideLauncher() {
5712
+ this.setLauncherHidden(true);
5713
+ }
5714
+ /** A short notice at the bottom of the viewport. `html` is trusted markup built by the caller. */
5715
+ toast(html, ms = 6e3) {
5716
+ this.toastEl.innerHTML = html;
5717
+ this.toastEl.classList.add("show");
5718
+ window.clearTimeout(this.toastTimer);
5719
+ this.toastTimer = window.setTimeout(() => this.toastEl.classList.remove("show"), ms);
5720
+ }
5490
5721
  collapseFab() {
5491
5722
  if (!this.fabExpanded) return;
5492
5723
  this.fabExpanded = false;
@@ -5501,9 +5732,9 @@ var Loupe = (() => {
5501
5732
  /** Reflect cluster expansion + marker visibility into the DOM. */
5502
5733
  applyFab() {
5503
5734
  this.fabCluster.classList.toggle("expanded", this.fabExpanded);
5504
- const primary = this.fabCluster.querySelector(".launcher");
5505
- primary?.setAttribute("aria-expanded", String(this.fabExpanded));
5506
- primary?.setAttribute("aria-label", this.fabExpanded ? "Close quick actions" : `Open ${this.cfg.label ?? "Loupe"} quick actions`);
5735
+ const more = this.fabCluster.querySelector('[data-fab="more"]');
5736
+ more?.setAttribute("aria-expanded", String(this.fabExpanded));
5737
+ more?.setAttribute("aria-label", this.fabExpanded ? "Close quick actions" : "Quick actions");
5507
5738
  const markers = this.fabCluster.querySelector('[data-fab="markers"]');
5508
5739
  markers?.classList.toggle("on", this.markersHidden);
5509
5740
  markers?.setAttribute("aria-pressed", String(this.markersHidden));
@@ -6067,6 +6298,10 @@ var Loupe = (() => {
6067
6298
  }
6068
6299
  /** Flip one of the "Show …" settings and repaint whatever it governs. */
6069
6300
  toggleSetting(key) {
6301
+ if (key === "launcherHidden") {
6302
+ this.setLauncherHidden(!this.launcherHidden);
6303
+ return;
6304
+ }
6070
6305
  if (key === "markersHidden") this.markersHidden = !this.markersHidden;
6071
6306
  else if (key === "hoverHints") this.hoverHints = !this.hoverHints;
6072
6307
  else this.showPaths = !this.showPaths;
@@ -6085,11 +6320,13 @@ var Loupe = (() => {
6085
6320
  const on = {
6086
6321
  hoverHints: this.hoverHints,
6087
6322
  markersHidden: !this.markersHidden,
6088
- showPaths: this.showPaths
6323
+ showPaths: this.showPaths,
6324
+ launcherHidden: !this.launcherHidden
6089
6325
  };
6090
6326
  this.settingsMenu.querySelectorAll("[data-set]").forEach((b) => {
6091
6327
  const key = b.dataset.set;
6092
6328
  if (key in on) b.setAttribute("aria-pressed", String(on[key]));
6329
+ if (key === "fabReset") b.hidden = !this.fabPos;
6093
6330
  });
6094
6331
  this.settingsMenu.querySelectorAll("[data-accent]").forEach((b) => b.classList.toggle("on", b.dataset.accent === this.accent));
6095
6332
  }
@@ -6223,6 +6460,8 @@ var Loupe = (() => {
6223
6460
  if (typeof p?.repoFilter === "string") this.repoFilter = p.repoFilter;
6224
6461
  if (p?.float && typeof p.float.w === "number") this.floatRect = { ...this.floatRect, ...p.float };
6225
6462
  if (typeof p?.markersHidden === "boolean") this.markersHidden = p.markersHidden;
6463
+ if (typeof p?.launcherHidden === "boolean") this.launcherHidden = p.launcherHidden;
6464
+ if (p?.fab && typeof p.fab.x === "number" && typeof p.fab.y === "number") this.fabPos = { x: p.fab.x, y: p.fab.y };
6226
6465
  if (p?.accent && ACCENT_IDS.includes(p.accent)) this.accent = p.accent;
6227
6466
  if (typeof p?.minimized === "boolean") this.minimized = p.minimized;
6228
6467
  if (typeof p?.hoverHints === "boolean") this.hoverHints = p.hoverHints;
@@ -6241,6 +6480,8 @@ var Loupe = (() => {
6241
6480
  tab: this.tab,
6242
6481
  float: this.floatRect,
6243
6482
  markersHidden: this.markersHidden,
6483
+ launcherHidden: this.launcherHidden,
6484
+ fab: this.fabPos,
6244
6485
  scope: this.scope,
6245
6486
  statFilter: this.statFilter,
6246
6487
  repoFilter: this.repoFilter,
@@ -6293,10 +6534,8 @@ var Loupe = (() => {
6293
6534
  for (const p of ["left", "top", "right", "bottom", "width", "height"]) d.style[p] = "";
6294
6535
  }
6295
6536
  d.querySelectorAll(".dctl [data-dock]").forEach((b) => b.classList.toggle("on", b.dataset.dock === this.dockMode));
6296
- this.fabCluster.classList.toggle("show", !this.open);
6297
- const leftSide = this.dockMode === "left";
6298
- this.fabCluster.style.left = leftSide ? "20px" : "auto";
6299
- this.fabCluster.style.right = leftSide ? "auto" : "20px";
6537
+ this.fabCluster.classList.toggle("show", !this.open && !this.launcherHidden);
6538
+ this.applyFabPosition();
6300
6539
  this.applyFab();
6301
6540
  this.pushPage();
6302
6541
  }
@@ -6425,6 +6664,10 @@ var Loupe = (() => {
6425
6664
  item.appendChild(top);
6426
6665
  const summary = c.title || (c.body.split("\n")[0] ?? "").slice(0, 140) || "(no description)";
6427
6666
  item.appendChild(el("div", "summary", summary));
6667
+ const who = el("div", "who");
6668
+ const when = this.fmtWhen(c.createdAt);
6669
+ who.innerHTML = `<b>${escapeHtml(c.author?.name?.trim() || "Unknown")}</b>` + (when ? ` \xB7 <time datetime="${escapeAttr(c.createdAt)}" title="${escapeAttr(fmtAgo(c.createdAt))}">${escapeHtml(when)}</time>` : "");
6670
+ item.appendChild(who);
6428
6671
  const detail = el("div", "detail");
6429
6672
  const repliesForState = this.messages.get(c.id) ?? [];
6430
6673
  const lastMsg = repliesForState.length ? repliesForState[repliesForState.length - 1] : void 0;
@@ -6997,6 +7240,7 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
6997
7240
  el("b", "msg-name", m.author.name),
6998
7241
  el("span", "msg-when", fmtAgo(m.createdAt))
6999
7242
  );
7243
+ head.lastElementChild.title = this.fmtWhen(m.createdAt);
7000
7244
  if (fromAgent) head.appendChild(el("span", "msg-tag", "agent"));
7001
7245
  const body = el("div", "msg-body");
7002
7246
  for (const seg of mentionSegments(m.body, parseMentions(m.body))) {
@@ -7525,6 +7769,21 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
7525
7769
  const elx = this.resolved.get(id);
7526
7770
  if (elx) elx.scrollIntoView({ behavior: "smooth", block: "center" });
7527
7771
  }
7772
+ /** Absolute timestamp in the host's configured time zone and locale (see `LoupeConfig.timeZone`). */
7773
+ fmtWhen(iso) {
7774
+ return fmtAbsolute(iso, this.cfg.locale, this.cfg.timeZone);
7775
+ }
7776
+ /**
7777
+ * " · package v0.11.0", flagged, when the host's package version differs from this
7778
+ * bundle's. The two agree on a healthy install; they differ exactly when the
7779
+ * package was upgraded but its published JS was not — the trap this makes visible.
7780
+ */
7781
+ packageVersionNote() {
7782
+ const pkg = (this.cfg.packageVersion ?? "").trim().replace(/^v/i, "");
7783
+ if (!pkg || pkg === SDK_VERSION) return "";
7784
+ const why = `This widget bundle is v${SDK_VERSION} but the installed package is v${pkg}. Re-publish the package assets so the served bundle matches.`;
7785
+ return ` \xB7 <span class="ver-stale" title="${escapeAttr(why)}">package v${escapeHtml(pkg)}</span>`;
7786
+ }
7528
7787
  destroy() {
7529
7788
  this.liveSource?.close();
7530
7789
  this.liveSource = void 0;
@@ -7543,6 +7802,11 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
7543
7802
  window.removeEventListener("pointerup", this.onHeadPointerUp);
7544
7803
  window.removeEventListener("pointermove", this.onResizeMove);
7545
7804
  window.removeEventListener("pointerup", this.onResizeUp);
7805
+ window.removeEventListener("pointermove", this.onFabPointerMove);
7806
+ window.removeEventListener("pointerup", this.onFabPointerUp);
7807
+ window.removeEventListener("pointercancel", this.onFabPointerUp);
7808
+ window.clearTimeout(this.toastTimer);
7809
+ document.removeEventListener("keydown", this.onLauncherKey);
7546
7810
  document.removeEventListener("keydown", this.onKey, true);
7547
7811
  const de = document.documentElement;
7548
7812
  de.style.marginLeft = de.style.marginRight = de.style.marginBottom = "";
@@ -7569,6 +7833,27 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
7569
7833
  if (h < 24) return `${h}h ago`;
7570
7834
  return `${Math.round(h / 24)}d ago`;
7571
7835
  }
7836
+ var warnedBadTimeZone = false;
7837
+ function fmtAbsolute(iso, locale, timeZone) {
7838
+ const t = Date.parse(iso);
7839
+ if (Number.isNaN(t)) return "";
7840
+ const opts = {
7841
+ day: "numeric",
7842
+ month: "short",
7843
+ year: "numeric",
7844
+ hour: "numeric",
7845
+ minute: "2-digit"
7846
+ };
7847
+ try {
7848
+ return new Intl.DateTimeFormat(locale || void 0, { ...opts, timeZone: timeZone || void 0 }).format(t);
7849
+ } catch (err) {
7850
+ if (!warnedBadTimeZone) {
7851
+ warnedBadTimeZone = true;
7852
+ console.warn(`[loupe] unusable timeZone/locale (${timeZone ?? "-"} / ${locale ?? "-"}); using the browser's`, err);
7853
+ }
7854
+ return new Intl.DateTimeFormat(void 0, opts).format(t);
7855
+ }
7856
+ }
7572
7857
  function shortPath(url) {
7573
7858
  try {
7574
7859
  return new URL(url, location.origin).pathname || "/";
@@ -7651,6 +7936,9 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
7651
7936
  var I_PLUG = svg(
7652
7937
  `<path d="M6 1.8v3.1M10 1.8v3.1" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><path d="M4.4 4.9h7.2v2.3a3.6 3.6 0 0 1-7.2 0z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/><path d="M8 10.8v3.4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>`
7653
7938
  );
7939
+ var I_EYE_OFF = svg(
7940
+ `<path d="M2 2l12 12M6.6 6.7A2 2 0 0 0 9.3 9.4M4.3 4.5C2.9 5.4 1.9 6.7 1.5 8c1.1 3 3.7 5 6.5 5 1.3 0 2.5-.4 3.6-1.1M6.9 3.2C7.3 3.1 7.6 3 8 3c2.8 0 5.4 2 6.5 5-.3.8-.8 1.6-1.4 2.3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
7941
+ );
7654
7942
  var I_FAB_CHEVRON = `<span class="lchev">${svg(`<path d="M4.4 9.6 8 6l3.6 3.6" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>`)}</span>`;
7655
7943
  var REGION_ICON = `<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true"><rect x="1.5" y="2.5" width="12" height="10" rx="1.5" stroke="currentColor" stroke-width="1.4" stroke-dasharray="2.4 1.8"/></svg>`;
7656
7944
  var NOTE_ICON = `<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true"><path d="M2 2.5h11v7.5H6l-3 2.5v-2.5H2z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/></svg>`;
@@ -7779,6 +8067,12 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
7779
8067
  function openTool(tool) {
7780
8068
  app?.openTool(tool);
7781
8069
  }
8070
+ function showLauncher() {
8071
+ app?.showLauncher();
8072
+ }
8073
+ function hideLauncher() {
8074
+ app?.hideLauncher();
8075
+ }
7782
8076
  return __toCommonJS(src_exports);
7783
8077
  })();
7784
8078
  //# sourceMappingURL=index.global.js.map