@loupekit/sdk 0.10.28 → 0.11.0-next.76

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
@@ -87,6 +87,14 @@ var STYLES = (
87
87
  display: none; flex-direction: column; align-items: flex-end; gap: 10px;
88
88
  }
89
89
  .fab-cluster.show { display: flex; }
90
+ /* A dragged launcher is anchored to its nearest edges (JS sets left/right/top/bottom),
91
+ and the quick actions grow INTO the page: downward from the upper half, labels to the
92
+ right from the left half. */
93
+ .fab-cluster.at-top { flex-direction: column-reverse; }
94
+ .fab-cluster.at-left, .fab-cluster.at-left .fab-minis { align-items: flex-start; }
95
+ .fab-cluster.at-left .fab-mini .fab-tip { right: auto; left: calc(100% + 8px); }
96
+ .fab-cluster.dragging .launcher { cursor: grabbing; border-color: var(--accent); }
97
+ .fab-cluster.dragging .fab-mini .fab-tip { display: none; }
90
98
 
91
99
  .fab-minis { display: none; flex-direction: column; align-items: flex-end; gap: 10px; }
92
100
  .fab-cluster.expanded .fab-minis { display: flex; }
@@ -95,7 +103,10 @@ var STYLES = (
95
103
  .fab-cluster.expanded .fab-minis .fab-mini:nth-child(2) { animation-delay: 30ms; }
96
104
  .fab-cluster.expanded .fab-minis .fab-mini:nth-child(3) { animation-delay: 60ms; }
97
105
  .fab-cluster.expanded .fab-minis .fab-mini:nth-child(4) { animation-delay: 90ms; }
106
+ .fab-cluster.expanded .fab-minis .fab-mini:nth-child(5) { animation-delay: 120ms; }
98
107
  @keyframes loupe-fab-in { from { opacity: 0; transform: translateY(8px) scale(.9); } to { opacity: 1; transform: none; } }
108
+ .fab-cluster.at-top.expanded .fab-minis .fab-mini { animation-name: loupe-fab-in-down; }
109
+ @keyframes loupe-fab-in-down { from { opacity: 0; transform: translateY(-8px) scale(.9); } to { opacity: 1; transform: none; } }
99
110
 
100
111
  .fab-mini {
101
112
  position: relative; width: 40px; height: 40px; border-radius: 50%; padding: 0;
@@ -116,22 +127,32 @@ var STYLES = (
116
127
  }
117
128
  .fab-mini:hover .fab-tip { opacity: 1; }
118
129
 
130
+ /* The launcher and its chevron share a box so the chevron can sit on the launcher's
131
+ corner while being its own button (tap = quick actions; the launcher = open). */
132
+ .fab-main { position: relative; display: inline-flex; }
119
133
  .launcher {
120
134
  position: relative; width: 46px; height: 46px; border-radius: 50%; padding: 0;
121
135
  border: 1px solid var(--line); background: var(--bg-2); color: var(--ink);
122
- cursor: pointer; display: inline-flex; align-items: center; justify-content: center;
136
+ cursor: grab; display: inline-flex; align-items: center; justify-content: center;
123
137
  box-shadow: var(--shadow);
138
+ touch-action: none; user-select: none; -webkit-user-select: none; /* drag with a finger too */
124
139
  }
125
140
  .launcher:hover { border-color: var(--accent); }
141
+ .launcher:active { cursor: grabbing; }
126
142
  .launcher .logo { font-size: 24px; line-height: 1; color: var(--accent); }
127
143
  /* Chevron pinned to the corner: up = actions are tucked away, down = they are out. */
128
- .launcher .lchev {
129
- position: absolute; right: -3px; bottom: -3px; width: 18px; height: 18px; border-radius: 50%;
144
+ .fab-more {
145
+ position: absolute; right: -3px; bottom: -3px; width: 20px; height: 20px; border-radius: 50%;
146
+ padding: 0; cursor: pointer; z-index: 1;
130
147
  background: var(--bg); border: 1px solid var(--line); color: var(--muted);
131
148
  display: grid; place-items: center; transition: transform 160ms cubic-bezier(.16, 1, .3, 1);
132
149
  }
133
- .launcher .lchev svg { width: 11px; height: 11px; display: block; }
134
- .fab-cluster.expanded .launcher .lchev { transform: rotate(180deg); }
150
+ .fab-more:hover { border-color: var(--accent); color: var(--accent); }
151
+ .fab-more .lchev { display: grid; place-items: center; }
152
+ .fab-more svg { width: 11px; height: 11px; display: block; }
153
+ .fab-cluster.expanded .fab-more { transform: rotate(180deg); }
154
+ .fab-cluster.at-top .fab-more { bottom: auto; top: -3px; transform: rotate(180deg); }
155
+ .fab-cluster.at-top.expanded .fab-more { transform: none; }
135
156
  .launcher .lcount {
136
157
  position: absolute; top: -5px; right: -5px; background: var(--pin); color: #fff;
137
158
  font-size: 10px; font-weight: 700; line-height: 1; border-radius: 999px; padding: 3px 6px;
@@ -216,7 +237,10 @@ var STYLES = (
216
237
  .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; }
217
238
  .item .num.detached { background: #9aa0af; }
218
239
  .item .num.done { background: #10935a; }
219
- .item .who { font-size: 12px; font-weight: 600; }
240
+ /* Author + absolute time, visible collapsed too. */
241
+ .item .who { display: flex; flex-wrap: wrap; gap: 0 4px; font-size: 11.5px; color: var(--muted); margin-top: 3px; }
242
+ .item .who b { color: var(--ink); font-weight: 600; }
243
+ .item .who time { font-variant-numeric: tabular-nums; white-space: nowrap; }
220
244
  .item .device { font-size: 10px; color: var(--muted); background: var(--bg-3); border-radius: 999px; padding: 1px 7px; white-space: nowrap; }
221
245
  .item .body { font-size: 13px; line-height: 1.4; }
222
246
  .item .meta { font-size: 11px; color: var(--muted); margin-top: 6px; font-family: ui-monospace, Menlo, monospace; word-break: break-all; }
@@ -411,6 +435,23 @@ var STYLES = (
411
435
  @keyframes loupe-recpulse { 0%,100% { opacity: 1; } 50% { opacity: .25; } }
412
436
  @media (prefers-reduced-motion: reduce) { .recbar .recdot { animation: none; } }
413
437
 
438
+ /* ---------------------------------------------------------------- toast */
439
+ /* A short notice (e.g. how to bring a hidden launcher back). Click to dismiss. */
440
+ .toast {
441
+ position: fixed; z-index: 2147483006; bottom: 24px; left: 50%;
442
+ transform: translateX(-50%) translateY(8px); opacity: 0; pointer-events: none;
443
+ max-width: calc(100vw - 32px); padding: 9px 14px; border-radius: 999px; text-align: center;
444
+ background: var(--bg); color: var(--ink); border: 1px solid var(--line); box-shadow: var(--shadow);
445
+ font-size: 12px; font-weight: 600; line-height: 1.4;
446
+ transition: opacity 160ms ease, transform 160ms ease;
447
+ }
448
+ .toast.show { opacity: 1; transform: translateX(-50%); pointer-events: auto; cursor: pointer; }
449
+ .toast kbd {
450
+ font-family: ui-monospace, Menlo, monospace; font-size: 11px; padding: 1px 5px;
451
+ border: 1px solid var(--line); border-radius: 4px; background: var(--bg-3);
452
+ }
453
+ @media (prefers-reduced-motion: reduce) { .toast { transition: none; } }
454
+
414
455
  /* ---------------------------------------------- header popovers (pos + settings) */
415
456
  .menu-wrap { position: relative; }
416
457
  .menu {
@@ -585,6 +626,8 @@ var STYLES = (
585
626
  }
586
627
  .menu-ver b { color: var(--ink); font-family: ui-monospace, Menlo, monospace; font-weight: 600; }
587
628
  .menu-mode { text-transform: uppercase; letter-spacing: .06em; font-size: 9.5px; }
629
+ /* The host package's version, shown only when it differs from this bundle's (a stale publish). */
630
+ .ver-stale { color: var(--pin); font-family: ui-monospace, Menlo, monospace; font-weight: 600; cursor: help; }
588
631
 
589
632
  /* ------------------------------------------------ lifecycle chips + review flow */
590
633
  .lifechip {
@@ -3847,7 +3890,10 @@ var HttpAdapter = class {
3847
3890
  };
3848
3891
 
3849
3892
  // src/app.ts
3850
- var SDK_VERSION = true ? "0.10.28" : "dev";
3893
+ var SDK_VERSION = true ? "0.11.0" : "dev";
3894
+ var FAB_SIZE = 46;
3895
+ var FAB_DRAG_THRESHOLD = 6;
3896
+ var LAUNCHER_SHORTCUT = "Alt+Shift+L";
3851
3897
  var ACCENTS = [
3852
3898
  { id: "indigo", dark: "#6b73e6", light: "#4a55d6", soft: "rgba(107,115,230,0.12)" },
3853
3899
  { id: "violet", dark: "#a06be6", light: "#7c3fd4", soft: "rgba(160,107,230,0.14)" },
@@ -3954,6 +4000,21 @@ var LoupeApp = class {
3954
4000
  this.fabExpanded = false;
3955
4001
  /** Whether pin markers are hidden on the page (a quick-action toggle, persisted). */
3956
4002
  this.markersHidden = false;
4003
+ /**
4004
+ * Whether the collapsed launcher is hidden altogether (persisted). The way back is
4005
+ * Alt+Shift+L, Settings → Launcher, or the host calling `showLauncher()`.
4006
+ */
4007
+ this.launcherHidden = false;
4008
+ /**
4009
+ * Where the user dragged the launcher to — the launcher's top-left in viewport px —
4010
+ * or null for the default corner on the dock's side. Persisted; clamped on apply so
4011
+ * a position saved on a wide screen still lands inside a narrow one.
4012
+ */
4013
+ this.fabPos = null;
4014
+ /** The in-flight launcher drag: pointer start, launcher origin, and whether it has moved past the tap threshold. */
4015
+ this.fabDrag = null;
4016
+ /** Set for one tick after a drag so the click the browser fires afterwards does not open the panel. */
4017
+ this.fabSuppressClick = false;
3957
4018
  this.comments = [];
3958
4019
  /** Free-text filter over the list (title / body / author). */
3959
4020
  this.search = "";
@@ -4054,6 +4115,56 @@ var LoupeApp = class {
4054
4115
  this.lastReplyId = "";
4055
4116
  /** In-app notifications: mentions of you, newest first. */
4056
4117
  this.notifications = [];
4118
+ // ---- launcher: drag anywhere, hide, bring back ------------------------------
4119
+ this.onFabPointerDown = (e) => {
4120
+ if (e.pointerType === "mouse" && e.button !== 0) return;
4121
+ const target = e.currentTarget;
4122
+ const r = target.getBoundingClientRect();
4123
+ this.fabDrag = { px: e.clientX, py: e.clientY, ox: r.left, oy: r.top, moved: false };
4124
+ let captured = false;
4125
+ try {
4126
+ if (typeof target.setPointerCapture === "function") {
4127
+ target.setPointerCapture(e.pointerId);
4128
+ captured = true;
4129
+ }
4130
+ } catch {
4131
+ captured = false;
4132
+ }
4133
+ if (!captured) {
4134
+ window.addEventListener("pointermove", this.onFabPointerMove);
4135
+ window.addEventListener("pointerup", this.onFabPointerUp);
4136
+ window.addEventListener("pointercancel", this.onFabPointerUp);
4137
+ }
4138
+ };
4139
+ this.onFabPointerMove = (e) => {
4140
+ const d = this.fabDrag;
4141
+ if (!d) return;
4142
+ const dx = e.clientX - d.px, dy = e.clientY - d.py;
4143
+ if (!d.moved && Math.hypot(dx, dy) < FAB_DRAG_THRESHOLD) return;
4144
+ if (!d.moved) {
4145
+ d.moved = true;
4146
+ this.fabCluster.classList.add("dragging");
4147
+ this.collapseFab();
4148
+ }
4149
+ e.preventDefault();
4150
+ this.fabPos = this.clampFabPos(d.ox + dx, d.oy + dy);
4151
+ this.applyFabPosition();
4152
+ };
4153
+ this.onFabPointerUp = () => {
4154
+ window.removeEventListener("pointermove", this.onFabPointerMove);
4155
+ window.removeEventListener("pointerup", this.onFabPointerUp);
4156
+ window.removeEventListener("pointercancel", this.onFabPointerUp);
4157
+ const d = this.fabDrag;
4158
+ this.fabDrag = null;
4159
+ if (!d?.moved) return;
4160
+ this.fabCluster.classList.remove("dragging");
4161
+ this.saveState();
4162
+ this.renderSettings();
4163
+ this.fabSuppressClick = true;
4164
+ window.setTimeout(() => {
4165
+ this.fabSuppressClick = false;
4166
+ }, 0);
4167
+ };
4057
4168
  // ---- inspector ------------------------------------------------------------
4058
4169
  // Pointer Events, not mouse events: a touch drag on a phone never fires
4059
4170
  // mousemove/mouseup, so Region/Record selection was impossible on touch devices.
@@ -4096,6 +4207,18 @@ var LoupeApp = class {
4096
4207
  this.closeComposer();
4097
4208
  }
4098
4209
  };
4210
+ /**
4211
+ * Alt+Shift+L toggles the launcher — the way back once it has been hidden, so it is
4212
+ * listened for the whole time the widget is mounted (unlike Escape, which only
4213
+ * matters while a tool is armed). `code` is checked too: on some keyboard layouts
4214
+ * Alt+Shift+L yields a different `key`.
4215
+ */
4216
+ this.onLauncherKey = (e) => {
4217
+ if (!e.altKey || !e.shiftKey || e.ctrlKey || e.metaKey) return;
4218
+ if (e.code !== "KeyL" && (e.key ?? "").toLowerCase() !== "l") return;
4219
+ e.preventDefault();
4220
+ this.setLauncherHidden(!this.launcherHidden);
4221
+ };
4099
4222
  // ---- region ("free-size screenshot") selection ----------------------------
4100
4223
  this.onRegionDown = (e) => {
4101
4224
  if (this.mode !== "region" && this.mode !== "record") return;
@@ -4297,6 +4420,7 @@ var LoupeApp = class {
4297
4420
  this.loadState();
4298
4421
  this.loadProject();
4299
4422
  this.buildDom();
4423
+ document.addEventListener("keydown", this.onLauncherKey);
4300
4424
  if (this.cfg.autoOpen) this.open = true;
4301
4425
  this.applyDockLayout();
4302
4426
  this.lastUrl = this.url;
@@ -4370,11 +4494,15 @@ var LoupeApp = class {
4370
4494
  this.dock = this.buildDock();
4371
4495
  this.fabCluster = this.buildFabCluster();
4372
4496
  this.recBar = this.buildRecBar();
4497
+ this.toastEl = el("div", "toast");
4498
+ this.toastEl.setAttribute("role", "status");
4499
+ this.toastEl.setAttribute("aria-live", "polite");
4500
+ this.toastEl.onclick = () => this.toastEl.classList.remove("show");
4373
4501
  this.tourEl = el("div", "tour");
4374
4502
  this.tourSpot = el("div", "tour-spot");
4375
4503
  this.tourCard = el("div", "tour-card");
4376
4504
  this.tourEl.append(this.tourSpot, this.tourCard);
4377
- this.shadow.append(this.dock, this.fabCluster, this.recBar, this.tourEl);
4505
+ this.shadow.append(this.dock, this.fabCluster, this.recBar, this.toastEl, this.tourEl);
4378
4506
  this.shadow.addEventListener("click", (e) => {
4379
4507
  const t = e.target;
4380
4508
  if (t?.closest && t.closest(".menu-wrap")) return;
@@ -4427,7 +4555,7 @@ var LoupeApp = class {
4427
4555
  setBtn.innerHTML = I_GEAR;
4428
4556
  setBtn.onclick = () => this.toggleMenu(this.settingsMenu);
4429
4557
  this.settingsMenu = el("div", "menu");
4430
- 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>`;
4558
+ 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>`;
4431
4559
  this.settingsMenu.querySelectorAll("[data-accent]").forEach((b) => {
4432
4560
  b.onclick = () => this.setAccent(b.dataset.accent);
4433
4561
  });
@@ -4439,6 +4567,11 @@ var LoupeApp = class {
4439
4567
  this.startTour();
4440
4568
  return;
4441
4569
  }
4570
+ if (key === "fabReset") {
4571
+ this.closeMenus();
4572
+ this.resetLauncherPosition();
4573
+ return;
4574
+ }
4442
4575
  this.toggleSetting(key);
4443
4576
  };
4444
4577
  });
@@ -5037,7 +5170,7 @@ var LoupeApp = class {
5037
5170
  */
5038
5171
  buildHomePanel() {
5039
5172
  const title = this.cfg.label ?? "Loupe";
5040
- 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>`;
5173
+ 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>`;
5041
5174
  this.homeEl.querySelector('[data-role="proj-open"]').onclick = (e) => {
5042
5175
  e.stopPropagation();
5043
5176
  this.setProjectOpen(!this.projOpen);
@@ -5130,7 +5263,7 @@ var LoupeApp = class {
5130
5263
  feed.innerHTML = recent.length ? recent.map((c) => {
5131
5264
  const stage = STAGE_LABELS[normalizeStatus(c.status)];
5132
5265
  const prio = normalizePriority(c.priority);
5133
- 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>`;
5266
+ 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>`;
5134
5267
  }).join("") : `<div class="hempty">Nothing here yet.</div>`;
5135
5268
  feed.querySelectorAll(".hfeed-i").forEach((b) => {
5136
5269
  b.onclick = () => {
@@ -5397,20 +5530,21 @@ var LoupeApp = class {
5397
5530
  }
5398
5531
  /**
5399
5532
  * The collapsed-state FAB cluster. The primary brand button carries the comment
5400
- * count and toggles the quick actions out and back; the actions are the four
5401
- * ways into the product without opening the full panel first:
5402
- * pin a comment, drop a note, hide/show the markers already on the page, and
5403
- * jump straight to the Claude/MCP setup.
5533
+ * count and OPENS the panel — one tap, the thing most people came for. A drag on
5534
+ * it moves the whole cluster anywhere on screen (persisted). The chevron beside it
5535
+ * is its own button and toggles the quick actions: pin a comment, drop a note,
5536
+ * hide/show the markers already on the page, hide the launcher itself, and — when
5537
+ * the host registered one — jump straight to the Connect tab.
5404
5538
  */
5405
5539
  buildFabCluster() {
5406
5540
  const cluster = el("div", "fab-cluster");
5407
5541
  const minis = el("div", "fab-minis");
5408
- const mini = (role, icon, label, title) => {
5542
+ const mini = (role, icon, label2, title) => {
5409
5543
  const b = el("button", "fab-mini");
5410
5544
  b.dataset.fab = role;
5411
5545
  b.title = title;
5412
5546
  b.setAttribute("aria-label", title);
5413
- b.innerHTML = `${icon}<span class="fab-tip">${label}</span>`;
5547
+ b.innerHTML = `${icon}<span class="fab-tip">${label2}</span>`;
5414
5548
  return b;
5415
5549
  };
5416
5550
  const comment = mini("comment", I_COMMENT, "Pin comment", "Pin feedback on any element");
@@ -5427,7 +5561,9 @@ var LoupeApp = class {
5427
5561
  };
5428
5562
  const markers = mini("markers", I_EYE, "Markers", "Show or hide the markers on this page");
5429
5563
  markers.onclick = () => this.toggleMarkers();
5430
- minis.append(comment, note, markers);
5564
+ const hide = mini("hide", I_EYE_OFF, "Hide launcher", `Hide this launcher \u2014 ${LAUNCHER_SHORTCUT} brings it back`);
5565
+ hide.onclick = () => this.setLauncherHidden(true);
5566
+ minis.append(comment, note, markers, hide);
5431
5567
  if (this.tabList.some((t) => t.id === "connect")) {
5432
5568
  const connect = mini("connect", I_PLUG, "Connect Claude", "Set up the Claude/MCP connection");
5433
5569
  connect.onclick = () => {
@@ -5437,14 +5573,33 @@ var LoupeApp = class {
5437
5573
  };
5438
5574
  minis.appendChild(connect);
5439
5575
  }
5576
+ const label = this.cfg.label ?? "Loupe";
5440
5577
  const primary = el("button", "launcher");
5441
- primary.title = `Open ${this.cfg.label ?? "Loupe"}`;
5442
- primary.setAttribute("aria-label", `${this.cfg.label ?? "Loupe"} \u2014 quick actions`);
5443
- primary.setAttribute("aria-expanded", "false");
5444
- primary.innerHTML = `<span class="logo">\u25CE</span>${I_FAB_CHEVRON}<span class="lcount"></span>`;
5578
+ primary.title = `Open ${label} \u2014 drag to move`;
5579
+ primary.setAttribute("aria-label", `Open ${label}`);
5580
+ primary.innerHTML = `<span class="logo">\u25CE</span><span class="lcount"></span>`;
5445
5581
  this.fabBadge = primary.querySelector(".lcount");
5446
- primary.onclick = () => this.toggleFab();
5447
- cluster.append(minis, primary);
5582
+ primary.onclick = () => {
5583
+ if (this.fabSuppressClick) return;
5584
+ this.openDock();
5585
+ };
5586
+ primary.addEventListener("pointerdown", this.onFabPointerDown);
5587
+ primary.addEventListener("pointermove", this.onFabPointerMove);
5588
+ primary.addEventListener("pointerup", this.onFabPointerUp);
5589
+ primary.addEventListener("pointercancel", this.onFabPointerUp);
5590
+ const more = el("button", "fab-more");
5591
+ more.dataset.fab = "more";
5592
+ more.title = "Quick actions";
5593
+ more.setAttribute("aria-label", "Quick actions");
5594
+ more.setAttribute("aria-expanded", "false");
5595
+ more.innerHTML = I_FAB_CHEVRON;
5596
+ more.onclick = (e) => {
5597
+ e.stopPropagation();
5598
+ this.toggleFab();
5599
+ };
5600
+ const main = el("div", "fab-main");
5601
+ main.append(primary, more);
5602
+ cluster.append(minis, main);
5448
5603
  this.fabMinis = minis;
5449
5604
  return cluster;
5450
5605
  }
@@ -5453,6 +5608,79 @@ var LoupeApp = class {
5453
5608
  this.fabExpanded = !this.fabExpanded;
5454
5609
  this.applyFab();
5455
5610
  }
5611
+ /** Keep the launcher fully on screen, with a small margin so it never kisses the edge. */
5612
+ clampFabPos(x, y) {
5613
+ const m = 8;
5614
+ return {
5615
+ x: clampPx(Math.round(x), m, Math.max(m, window.innerWidth - FAB_SIZE - m)),
5616
+ y: clampPx(Math.round(y), m, Math.max(m, window.innerHeight - FAB_SIZE - m))
5617
+ };
5618
+ }
5619
+ /**
5620
+ * Place the cluster. A dragged launcher is anchored to the viewport edge it is
5621
+ * nearest, so the quick actions always grow INTO the page: minis open upward from
5622
+ * the lower half and downward from the upper half, and their labels point away
5623
+ * from the nearest side. With no saved position the cluster sits in the default
5624
+ * corner on the dock's side, so reopening feels like the panel sliding back in.
5625
+ */
5626
+ applyFabPosition() {
5627
+ const s = this.fabCluster.style;
5628
+ if (this.fabPos) {
5629
+ const { x, y } = this.clampFabPos(this.fabPos.x, this.fabPos.y);
5630
+ const atTop = y + FAB_SIZE / 2 < window.innerHeight / 2;
5631
+ const atLeft = x + FAB_SIZE / 2 < window.innerWidth / 2;
5632
+ s.left = atLeft ? `${x}px` : "auto";
5633
+ s.right = atLeft ? "auto" : `${Math.max(0, window.innerWidth - x - FAB_SIZE)}px`;
5634
+ s.top = atTop ? `${y}px` : "auto";
5635
+ s.bottom = atTop ? "auto" : `${Math.max(0, window.innerHeight - y - FAB_SIZE)}px`;
5636
+ this.fabCluster.classList.toggle("at-top", atTop);
5637
+ this.fabCluster.classList.toggle("at-left", atLeft);
5638
+ return;
5639
+ }
5640
+ const leftSide = this.dockMode === "left";
5641
+ s.left = leftSide ? "20px" : "auto";
5642
+ s.right = leftSide ? "auto" : "20px";
5643
+ s.top = "auto";
5644
+ s.bottom = "";
5645
+ this.fabCluster.classList.remove("at-top");
5646
+ this.fabCluster.classList.toggle("at-left", leftSide);
5647
+ }
5648
+ resetLauncherPosition() {
5649
+ this.fabPos = null;
5650
+ this.saveState();
5651
+ this.applyDockLayout();
5652
+ this.renderSettings();
5653
+ }
5654
+ /**
5655
+ * Hide or show the collapsed launcher. Hidden is persisted, and the way back is
5656
+ * announced at the moment of hiding — a control that vanishes with no stated way
5657
+ * to return is a support ticket, not a feature.
5658
+ */
5659
+ setLauncherHidden(hidden) {
5660
+ if (this.launcherHidden === hidden) return;
5661
+ this.launcherHidden = hidden;
5662
+ this.fabExpanded = false;
5663
+ this.saveState();
5664
+ this.applyDockLayout();
5665
+ this.renderSettings();
5666
+ const label = this.cfg.label ?? "Loupe";
5667
+ this.toast(hidden ? `${escapeHtml(label)} launcher hidden \u2014 press <kbd>${LAUNCHER_SHORTCUT}</kbd> to bring it back` : `${escapeHtml(label)} launcher is back`);
5668
+ }
5669
+ /** Public seam for the host (`Loupe.showLauncher()`). */
5670
+ showLauncher() {
5671
+ this.setLauncherHidden(false);
5672
+ }
5673
+ /** Public seam for the host (`Loupe.hideLauncher()`). */
5674
+ hideLauncher() {
5675
+ this.setLauncherHidden(true);
5676
+ }
5677
+ /** A short notice at the bottom of the viewport. `html` is trusted markup built by the caller. */
5678
+ toast(html, ms = 6e3) {
5679
+ this.toastEl.innerHTML = html;
5680
+ this.toastEl.classList.add("show");
5681
+ window.clearTimeout(this.toastTimer);
5682
+ this.toastTimer = window.setTimeout(() => this.toastEl.classList.remove("show"), ms);
5683
+ }
5456
5684
  collapseFab() {
5457
5685
  if (!this.fabExpanded) return;
5458
5686
  this.fabExpanded = false;
@@ -5467,9 +5695,9 @@ var LoupeApp = class {
5467
5695
  /** Reflect cluster expansion + marker visibility into the DOM. */
5468
5696
  applyFab() {
5469
5697
  this.fabCluster.classList.toggle("expanded", this.fabExpanded);
5470
- const primary = this.fabCluster.querySelector(".launcher");
5471
- primary?.setAttribute("aria-expanded", String(this.fabExpanded));
5472
- primary?.setAttribute("aria-label", this.fabExpanded ? "Close quick actions" : `Open ${this.cfg.label ?? "Loupe"} quick actions`);
5698
+ const more = this.fabCluster.querySelector('[data-fab="more"]');
5699
+ more?.setAttribute("aria-expanded", String(this.fabExpanded));
5700
+ more?.setAttribute("aria-label", this.fabExpanded ? "Close quick actions" : "Quick actions");
5473
5701
  const markers = this.fabCluster.querySelector('[data-fab="markers"]');
5474
5702
  markers?.classList.toggle("on", this.markersHidden);
5475
5703
  markers?.setAttribute("aria-pressed", String(this.markersHidden));
@@ -6033,6 +6261,10 @@ var LoupeApp = class {
6033
6261
  }
6034
6262
  /** Flip one of the "Show …" settings and repaint whatever it governs. */
6035
6263
  toggleSetting(key) {
6264
+ if (key === "launcherHidden") {
6265
+ this.setLauncherHidden(!this.launcherHidden);
6266
+ return;
6267
+ }
6036
6268
  if (key === "markersHidden") this.markersHidden = !this.markersHidden;
6037
6269
  else if (key === "hoverHints") this.hoverHints = !this.hoverHints;
6038
6270
  else this.showPaths = !this.showPaths;
@@ -6051,11 +6283,13 @@ var LoupeApp = class {
6051
6283
  const on = {
6052
6284
  hoverHints: this.hoverHints,
6053
6285
  markersHidden: !this.markersHidden,
6054
- showPaths: this.showPaths
6286
+ showPaths: this.showPaths,
6287
+ launcherHidden: !this.launcherHidden
6055
6288
  };
6056
6289
  this.settingsMenu.querySelectorAll("[data-set]").forEach((b) => {
6057
6290
  const key = b.dataset.set;
6058
6291
  if (key in on) b.setAttribute("aria-pressed", String(on[key]));
6292
+ if (key === "fabReset") b.hidden = !this.fabPos;
6059
6293
  });
6060
6294
  this.settingsMenu.querySelectorAll("[data-accent]").forEach((b) => b.classList.toggle("on", b.dataset.accent === this.accent));
6061
6295
  }
@@ -6189,6 +6423,8 @@ var LoupeApp = class {
6189
6423
  if (typeof p?.repoFilter === "string") this.repoFilter = p.repoFilter;
6190
6424
  if (p?.float && typeof p.float.w === "number") this.floatRect = { ...this.floatRect, ...p.float };
6191
6425
  if (typeof p?.markersHidden === "boolean") this.markersHidden = p.markersHidden;
6426
+ if (typeof p?.launcherHidden === "boolean") this.launcherHidden = p.launcherHidden;
6427
+ if (p?.fab && typeof p.fab.x === "number" && typeof p.fab.y === "number") this.fabPos = { x: p.fab.x, y: p.fab.y };
6192
6428
  if (p?.accent && ACCENT_IDS.includes(p.accent)) this.accent = p.accent;
6193
6429
  if (typeof p?.minimized === "boolean") this.minimized = p.minimized;
6194
6430
  if (typeof p?.hoverHints === "boolean") this.hoverHints = p.hoverHints;
@@ -6207,6 +6443,8 @@ var LoupeApp = class {
6207
6443
  tab: this.tab,
6208
6444
  float: this.floatRect,
6209
6445
  markersHidden: this.markersHidden,
6446
+ launcherHidden: this.launcherHidden,
6447
+ fab: this.fabPos,
6210
6448
  scope: this.scope,
6211
6449
  statFilter: this.statFilter,
6212
6450
  repoFilter: this.repoFilter,
@@ -6259,10 +6497,8 @@ var LoupeApp = class {
6259
6497
  for (const p of ["left", "top", "right", "bottom", "width", "height"]) d.style[p] = "";
6260
6498
  }
6261
6499
  d.querySelectorAll(".dctl [data-dock]").forEach((b) => b.classList.toggle("on", b.dataset.dock === this.dockMode));
6262
- this.fabCluster.classList.toggle("show", !this.open);
6263
- const leftSide = this.dockMode === "left";
6264
- this.fabCluster.style.left = leftSide ? "20px" : "auto";
6265
- this.fabCluster.style.right = leftSide ? "auto" : "20px";
6500
+ this.fabCluster.classList.toggle("show", !this.open && !this.launcherHidden);
6501
+ this.applyFabPosition();
6266
6502
  this.applyFab();
6267
6503
  this.pushPage();
6268
6504
  }
@@ -6391,6 +6627,10 @@ var LoupeApp = class {
6391
6627
  item.appendChild(top);
6392
6628
  const summary = c.title || (c.body.split("\n")[0] ?? "").slice(0, 140) || "(no description)";
6393
6629
  item.appendChild(el("div", "summary", summary));
6630
+ const who = el("div", "who");
6631
+ const when = this.fmtWhen(c.createdAt);
6632
+ 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>` : "");
6633
+ item.appendChild(who);
6394
6634
  const detail = el("div", "detail");
6395
6635
  const repliesForState = this.messages.get(c.id) ?? [];
6396
6636
  const lastMsg = repliesForState.length ? repliesForState[repliesForState.length - 1] : void 0;
@@ -6963,6 +7203,7 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
6963
7203
  el("b", "msg-name", m.author.name),
6964
7204
  el("span", "msg-when", fmtAgo(m.createdAt))
6965
7205
  );
7206
+ head.lastElementChild.title = this.fmtWhen(m.createdAt);
6966
7207
  if (fromAgent) head.appendChild(el("span", "msg-tag", "agent"));
6967
7208
  const body = el("div", "msg-body");
6968
7209
  for (const seg of mentionSegments(m.body, parseMentions(m.body))) {
@@ -7491,6 +7732,21 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
7491
7732
  const elx = this.resolved.get(id);
7492
7733
  if (elx) elx.scrollIntoView({ behavior: "smooth", block: "center" });
7493
7734
  }
7735
+ /** Absolute timestamp in the host's configured time zone and locale (see `LoupeConfig.timeZone`). */
7736
+ fmtWhen(iso) {
7737
+ return fmtAbsolute(iso, this.cfg.locale, this.cfg.timeZone);
7738
+ }
7739
+ /**
7740
+ * " · package v0.11.0", flagged, when the host's package version differs from this
7741
+ * bundle's. The two agree on a healthy install; they differ exactly when the
7742
+ * package was upgraded but its published JS was not — the trap this makes visible.
7743
+ */
7744
+ packageVersionNote() {
7745
+ const pkg = (this.cfg.packageVersion ?? "").trim().replace(/^v/i, "");
7746
+ if (!pkg || pkg === SDK_VERSION) return "";
7747
+ 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.`;
7748
+ return ` \xB7 <span class="ver-stale" title="${escapeAttr(why)}">package v${escapeHtml(pkg)}</span>`;
7749
+ }
7494
7750
  destroy() {
7495
7751
  this.liveSource?.close();
7496
7752
  this.liveSource = void 0;
@@ -7509,6 +7765,11 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
7509
7765
  window.removeEventListener("pointerup", this.onHeadPointerUp);
7510
7766
  window.removeEventListener("pointermove", this.onResizeMove);
7511
7767
  window.removeEventListener("pointerup", this.onResizeUp);
7768
+ window.removeEventListener("pointermove", this.onFabPointerMove);
7769
+ window.removeEventListener("pointerup", this.onFabPointerUp);
7770
+ window.removeEventListener("pointercancel", this.onFabPointerUp);
7771
+ window.clearTimeout(this.toastTimer);
7772
+ document.removeEventListener("keydown", this.onLauncherKey);
7512
7773
  document.removeEventListener("keydown", this.onKey, true);
7513
7774
  const de = document.documentElement;
7514
7775
  de.style.marginLeft = de.style.marginRight = de.style.marginBottom = "";
@@ -7535,6 +7796,27 @@ function fmtAgo(iso) {
7535
7796
  if (h < 24) return `${h}h ago`;
7536
7797
  return `${Math.round(h / 24)}d ago`;
7537
7798
  }
7799
+ var warnedBadTimeZone = false;
7800
+ function fmtAbsolute(iso, locale, timeZone) {
7801
+ const t = Date.parse(iso);
7802
+ if (Number.isNaN(t)) return "";
7803
+ const opts = {
7804
+ day: "numeric",
7805
+ month: "short",
7806
+ year: "numeric",
7807
+ hour: "numeric",
7808
+ minute: "2-digit"
7809
+ };
7810
+ try {
7811
+ return new Intl.DateTimeFormat(locale || void 0, { ...opts, timeZone: timeZone || void 0 }).format(t);
7812
+ } catch (err) {
7813
+ if (!warnedBadTimeZone) {
7814
+ warnedBadTimeZone = true;
7815
+ console.warn(`[loupe] unusable timeZone/locale (${timeZone ?? "-"} / ${locale ?? "-"}); using the browser's`, err);
7816
+ }
7817
+ return new Intl.DateTimeFormat(void 0, opts).format(t);
7818
+ }
7819
+ }
7538
7820
  function shortPath(url) {
7539
7821
  try {
7540
7822
  return new URL(url, location.origin).pathname || "/";
@@ -7617,6 +7899,9 @@ var I_EYE = svg(
7617
7899
  var I_PLUG = svg(
7618
7900
  `<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"/>`
7619
7901
  );
7902
+ var I_EYE_OFF = svg(
7903
+ `<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"/>`
7904
+ );
7620
7905
  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>`;
7621
7906
  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>`;
7622
7907
  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>`;
@@ -7745,15 +8030,24 @@ function setLocalAi(config) {
7745
8030
  function openTool(tool) {
7746
8031
  app?.openTool(tool);
7747
8032
  }
8033
+ function showLauncher() {
8034
+ app?.showLauncher();
8035
+ }
8036
+ function hideLauncher() {
8037
+ app?.hideLauncher();
8038
+ }
7748
8039
  export {
7749
8040
  clearActivity,
7750
8041
  connectTab,
7751
8042
  destroy,
8043
+ hideLauncher,
7752
8044
  init,
7753
8045
  openTool,
7754
8046
  requestNavigation2 as requestNavigation,
7755
8047
  setActivityStatus,
7756
8048
  setLocalAi,
7757
- trackActivity
8049
+ showLauncher,
8050
+ trackActivity,
8051
+ SDK_VERSION as version
7758
8052
  };
7759
8053
  //# sourceMappingURL=index.js.map