@loupekit/sdk 0.11.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -108,7 +108,8 @@ launcher. The launcher carries the comment count; one tap reopens the panel, and
108
108
  beside it expands the quick actions (pin a comment, drop a note, hide the markers, hide the
109
109
  launcher). Drag the launcher to move it anywhere — the spot is remembered, and **Reset launcher
110
110
  position** in the Settings menu puts it back. Hide it from a quick action, the Settings menu,
111
- `Alt+Shift+L`, or `hideLauncher()` / `showLauncher()` when it covers something on your page.
111
+ `Alt+Shift+L`, or `hideLauncher()` / `showLauncher()` when it covers something on your page. On a
112
+ touch screen a slim tab at the right edge of the screen brings a hidden launcher back.
112
113
  Call `destroy()` to tear it down. `init()` is idempotent — safe to call more
113
114
  than once. Pass `label` to change the brand name shown in the header.
114
115
 
@@ -124,6 +124,15 @@ var Loupe = (() => {
124
124
  display: none; flex-direction: column; align-items: flex-end; gap: 10px;
125
125
  }
126
126
  .fab-cluster.show { display: flex; }
127
+ /* The way back on a touch screen once the launcher is hidden (no keyboard for Alt+Shift+L):
128
+ a slim tab on the right edge, shown only while the panel is closed. */
129
+ .fab-handle {
130
+ position: fixed; z-index: 2147483003; right: 0; bottom: 96px; width: 12px; height: 56px;
131
+ display: none; padding: 0; border: 0; border-radius: 8px 0 0 8px;
132
+ background: var(--accent); opacity: .6; cursor: pointer; touch-action: manipulation;
133
+ }
134
+ .fab-handle.show { display: block; }
135
+ .fab-handle:hover, .fab-handle:focus-visible { opacity: 1; }
127
136
  /* A dragged launcher is anchored to its nearest edges (JS sets left/right/top/bottom),
128
137
  and the quick actions grow INTO the page: downward from the upper half, labels to the
129
138
  right from the left half. */
@@ -230,13 +239,15 @@ var Loupe = (() => {
230
239
  .brand .logo { font-size: 16px; line-height: 1; color: var(--accent); flex: none; }
231
240
  .brand .title { font-size: 13px; }
232
241
  .dctl { display: flex; align-items: center; gap: 2px; margin-left: auto; }
233
- .dctl button {
242
+ /* Only the header's own icon buttons. The position and settings popovers live inside
243
+ .dctl too, and a bare .dctl button squeezed their rows and swatches to 26px. */
244
+ .dctl > button, .dctl > .menu-wrap > button {
234
245
  display: inline-flex; align-items: center; justify-content: center;
235
246
  width: 26px; height: 26px; padding: 0; border: 0; border-radius: 6px;
236
247
  background: transparent; color: var(--muted); cursor: pointer;
237
248
  }
238
- .dctl button:hover { background: var(--bg-3); color: var(--ink); }
239
- .dctl button.on { background: var(--bg-3); color: var(--accent); }
249
+ .dctl > button:hover, .dctl > .menu-wrap > button:hover { background: var(--bg-3); color: var(--ink); }
250
+ .dctl > button.on, .dctl > .menu-wrap > button.on { background: var(--bg-3); color: var(--accent); }
240
251
  .dctl svg { display: block; }
241
252
  .dctl .gap { width: 1px; height: 16px; background: var(--line); margin: 0 4px; flex: none; }
242
253
 
@@ -855,8 +866,10 @@ var Loupe = (() => {
855
866
 
856
867
  /* ------------------------------------------------------------- companion chat */
857
868
  /* Same inset as the Activity view \u2014 without it the composer and its Send button sit
858
- flush against the panel's edge, which reads as a clipped button. */
859
- .chat-view { display: flex; flex-direction: column; gap: 8px; height: 100%; min-height: 0; padding: 10px 12px 16px; }
869
+ flush against the panel's edge, which reads as a clipped button. Scoped to .view.on:
870
+ a bare .chat-view display:flex outranked .view display:none by source
871
+ order, so the chat rendered under every other tab and swallowed their clicks. */
872
+ .view.on.chat-view { display: flex; flex-direction: column; gap: 8px; height: 100%; min-height: 0; padding: 10px 12px 16px; }
860
873
  .chat-tray {
861
874
  display: flex; flex-direction: column; gap: 4px; padding: 8px;
862
875
  border: 1px solid var(--line); border-radius: 10px; background: var(--bg-2);
@@ -1276,6 +1289,7 @@ var Loupe = (() => {
1276
1289
  function usable(el2) {
1277
1290
  if (el2.id === "loupe-root" || el2.closest("#loupe-root")) return false;
1278
1291
  if (el2 === document.body || el2 === document.documentElement) return false;
1292
+ if (typeof el2.checkVisibility === "function" && !el2.checkVisibility()) return false;
1279
1293
  return true;
1280
1294
  }
1281
1295
  function byXPath(xp) {
@@ -3927,7 +3941,7 @@ var Loupe = (() => {
3927
3941
  };
3928
3942
 
3929
3943
  // src/app.ts
3930
- var SDK_VERSION = true ? "0.11.0" : "dev";
3944
+ var SDK_VERSION = true ? "0.11.1" : "dev";
3931
3945
  var FAB_SIZE = 46;
3932
3946
  var FAB_DRAG_THRESHOLD = 6;
3933
3947
  var LAUNCHER_SHORTCUT = "Alt+Shift+L";
@@ -4077,6 +4091,8 @@ var Loupe = (() => {
4077
4091
  this.scope = "page";
4078
4092
  /** Every-page comments, fetched the first time the "All" scope is opened. */
4079
4093
  this.allComments = [];
4094
+ /** True once the project-wide list has been read, so an empty project still shows 0, not ⋯. */
4095
+ this.allLoaded = false;
4080
4096
  /** A clicked stat tile, which narrows the list. */
4081
4097
  this.statFilter = "";
4082
4098
  /** Repo filter, offered once the scope is "all". */
@@ -4256,6 +4272,20 @@ var Loupe = (() => {
4256
4272
  e.preventDefault();
4257
4273
  this.setLauncherHidden(!this.launcherHidden);
4258
4274
  };
4275
+ /**
4276
+ * The header popovers (position, settings) close on a click anywhere. The shadow-root
4277
+ * click listener in buildDom() only sees clicks inside the widget, so a click on the
4278
+ * host page used to leave the menu open over it. Capture phase, so a host that stops
4279
+ * propagation on its own controls still dismisses the menu.
4280
+ */
4281
+ this.onDocumentClick = (e) => {
4282
+ if (e.composedPath().includes(this.root)) return;
4283
+ this.closeMenus();
4284
+ };
4285
+ /** Escape closes an open popover, whether or not a tool is armed (onKey covers tools). */
4286
+ this.onMenuKey = (e) => {
4287
+ if (e.key === "Escape") this.closeMenus();
4288
+ };
4259
4289
  // ---- region ("free-size screenshot") selection ----------------------------
4260
4290
  this.onRegionDown = (e) => {
4261
4291
  if (this.mode !== "region" && this.mode !== "record") return;
@@ -4346,8 +4376,10 @@ var Loupe = (() => {
4346
4376
  const box = this.regionRect(c, elx);
4347
4377
  if (box) {
4348
4378
  const onScreen = box.x + box.w > 0 && box.x < window.innerWidth && box.y + box.h > 0 && box.y < window.innerHeight;
4349
- Object.assign(pin.style, { left: box.x + "px", top: box.y + "px", display: onScreen ? "grid" : "none" });
4350
- pin.classList.toggle("detached", !elx && !c.region?.rel);
4379
+ const px = box.x < 4 ? Math.min(4, box.x + box.w - 30) : box.x;
4380
+ const py = box.y < 4 ? Math.min(4, box.y + box.h - 30) : box.y;
4381
+ Object.assign(pin.style, { left: px + "px", top: py + "px", display: onScreen ? "grid" : "none" });
4382
+ pin.classList.toggle("detached", !(elx && relUsable(c.region?.rel)) && c.anchor.tag !== "region");
4351
4383
  } else {
4352
4384
  pin.style.display = "none";
4353
4385
  pin.classList.add("detached");
@@ -4358,7 +4390,7 @@ var Loupe = (() => {
4358
4390
  const rect = elx.getBoundingClientRect();
4359
4391
  const px = rect.left + c.offset.x * rect.width;
4360
4392
  const py = rect.top + c.offset.y * rect.height;
4361
- const onScreen = rect.bottom > 0 && rect.top < window.innerHeight && rect.width > 0;
4393
+ const onScreen = rect.bottom > 0 && rect.top < window.innerHeight && rect.right > 0 && rect.left < window.innerWidth && rect.width > 0;
4362
4394
  Object.assign(pin.style, { left: px + "px", top: py + "px", display: onScreen ? "grid" : "none" });
4363
4395
  pin.classList.remove("detached");
4364
4396
  } else {
@@ -4458,6 +4490,8 @@ var Loupe = (() => {
4458
4490
  this.loadProject();
4459
4491
  this.buildDom();
4460
4492
  document.addEventListener("keydown", this.onLauncherKey);
4493
+ document.addEventListener("click", this.onDocumentClick, true);
4494
+ document.addEventListener("keydown", this.onMenuKey);
4461
4495
  if (this.cfg.autoOpen) this.open = true;
4462
4496
  this.applyDockLayout();
4463
4497
  this.lastUrl = this.url;
@@ -4469,7 +4503,7 @@ var Loupe = (() => {
4469
4503
  this.startLiveThreads();
4470
4504
  this.startCompanionStream();
4471
4505
  void this.loadNotifications();
4472
- if (this.scope === "all") void this.loadAllComments();
4506
+ void this.loadAllComments();
4473
4507
  this.observe();
4474
4508
  this.watchNavigation();
4475
4509
  if (!this.tourDone && this.open && !this.isMobile()) this.startTour();
@@ -4530,6 +4564,11 @@ var Loupe = (() => {
4530
4564
  this.shadow.appendChild(this.composer);
4531
4565
  this.dock = this.buildDock();
4532
4566
  this.fabCluster = this.buildFabCluster();
4567
+ this.fabHandle = el("button", "fab-handle");
4568
+ this.fabHandle.type = "button";
4569
+ this.fabHandle.title = "Show the launcher";
4570
+ this.fabHandle.setAttribute("aria-label", "Show the launcher");
4571
+ this.fabHandle.onclick = () => this.setLauncherHidden(false);
4533
4572
  this.recBar = this.buildRecBar();
4534
4573
  this.toastEl = el("div", "toast");
4535
4574
  this.toastEl.setAttribute("role", "status");
@@ -4539,7 +4578,7 @@ var Loupe = (() => {
4539
4578
  this.tourSpot = el("div", "tour-spot");
4540
4579
  this.tourCard = el("div", "tour-card");
4541
4580
  this.tourEl.append(this.tourSpot, this.tourCard);
4542
- this.shadow.append(this.dock, this.fabCluster, this.recBar, this.toastEl, this.tourEl);
4581
+ this.shadow.append(this.dock, this.fabCluster, this.fabHandle, this.recBar, this.toastEl, this.tourEl);
4543
4582
  this.shadow.addEventListener("click", (e) => {
4544
4583
  const t = e.target;
4545
4584
  if (t?.closest && t.closest(".menu-wrap")) return;
@@ -5239,6 +5278,7 @@ var Loupe = (() => {
5239
5278
  async loadAllComments() {
5240
5279
  try {
5241
5280
  this.allComments = await this.store.listAll(this.cfg.projectKey);
5281
+ this.allLoaded = true;
5242
5282
  this.renderHome();
5243
5283
  this.renderList();
5244
5284
  } catch {
@@ -5279,7 +5319,7 @@ var Loupe = (() => {
5279
5319
  };
5280
5320
  });
5281
5321
  }
5282
- const allKnown = this.allComments.length > 0 || this.scope === "all";
5322
+ const allKnown = this.allLoaded || this.scope === "all";
5283
5323
  const counts = {
5284
5324
  page: String(this.comments.length),
5285
5325
  all: allKnown ? String(this.allComments.length) : "\u22EF"
@@ -5598,7 +5638,12 @@ var Loupe = (() => {
5598
5638
  };
5599
5639
  const markers = mini("markers", I_EYE, "Markers", "Show or hide the markers on this page");
5600
5640
  markers.onclick = () => this.toggleMarkers();
5601
- const hide = mini("hide", I_EYE_OFF, "Hide launcher", `Hide this launcher \u2014 ${LAUNCHER_SHORTCUT} brings it back`);
5641
+ const hide = mini(
5642
+ "hide",
5643
+ I_EYE_OFF,
5644
+ "Hide launcher",
5645
+ isTouchDevice() ? "Hide this launcher \u2014 the tab at the screen edge brings it back" : `Hide this launcher \u2014 ${LAUNCHER_SHORTCUT} brings it back`
5646
+ );
5602
5647
  hide.onclick = () => this.setLauncherHidden(true);
5603
5648
  minis.append(comment, note, markers, hide);
5604
5649
  if (this.tabList.some((t) => t.id === "connect")) {
@@ -5701,7 +5746,8 @@ var Loupe = (() => {
5701
5746
  this.applyDockLayout();
5702
5747
  this.renderSettings();
5703
5748
  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`);
5749
+ const back = isTouchDevice() ? "tap the tab at the right edge of the screen" : `press <kbd>${LAUNCHER_SHORTCUT}</kbd>`;
5750
+ this.toast(hidden ? `${escapeHtml(label)} launcher hidden \u2014 ${back} to bring it back` : `${escapeHtml(label)} launcher is back`);
5705
5751
  }
5706
5752
  /** Public seam for the host (`Loupe.showLauncher()`). */
5707
5753
  showLauncher() {
@@ -5765,26 +5811,28 @@ var Loupe = (() => {
5765
5811
  document.removeEventListener("pointercancel", this.onRegionCancel, true);
5766
5812
  }
5767
5813
  /**
5768
- * Anchor a dragged viewport rect to the element under its center so it survives
5769
- * reflow. `rel` (element-relative fractions) is preferred; document coords are the
5770
- * fallback. Shared by both the Region (screenshot) and Record (video) tools.
5814
+ * Anchor a dragged viewport rect to the smallest element that covers most of it, so it
5815
+ * survives reflow. The rule used to take the element under the region's CENTER, which
5816
+ * for a large region is some small control inside it: the stored fractions then ran to
5817
+ * −7 and +15, and on any other viewport the pin landed far from the area. `rel`
5818
+ * (element-relative fractions) is preferred when an anchor is found; document coords
5819
+ * are the fallback. Shared by both the Region (screenshot) and Record (video) tools.
5771
5820
  */
5772
5821
  regionFromViewport(vp) {
5773
5822
  const centerEl = this.pick(vp.x + vp.w / 2, vp.y + vp.h / 2);
5823
+ const anchorEl = regionContainer2(centerEl, vp);
5774
5824
  let rel;
5775
- if (centerEl) {
5776
- const er = centerEl.getBoundingClientRect();
5777
- if (er.width > 0 && er.height > 0) {
5778
- rel = {
5779
- fx: (vp.x - er.left) / er.width,
5780
- fy: (vp.y - er.top) / er.height,
5781
- fw: vp.w / er.width,
5782
- fh: vp.h / er.height
5783
- };
5784
- }
5825
+ if (anchorEl) {
5826
+ const er = anchorEl.getBoundingClientRect();
5827
+ rel = {
5828
+ fx: (vp.x - er.left) / er.width,
5829
+ fy: (vp.y - er.top) / er.height,
5830
+ fw: vp.w / er.width,
5831
+ fh: vp.h / er.height
5832
+ };
5785
5833
  }
5786
5834
  const region = { x: vp.x + window.scrollX, y: vp.y + window.scrollY, w: vp.w, h: vp.h, rel };
5787
- return { region, element: centerEl };
5835
+ return { region, element: anchorEl };
5788
5836
  }
5789
5837
  /**
5790
5838
  * Touch path for the Region tool: capture what is on screen right now and open the
@@ -6205,7 +6253,7 @@ var Loupe = (() => {
6205
6253
  */
6206
6254
  regionRect(c, elx) {
6207
6255
  const rel = c.region?.rel;
6208
- if (elx && rel) {
6256
+ if (elx && relUsable(rel)) {
6209
6257
  const r = elx.getBoundingClientRect();
6210
6258
  return { x: r.left + rel.fx * r.width, y: r.top + rel.fy * r.height, w: rel.fw * r.width, h: rel.fh * r.height };
6211
6259
  }
@@ -6535,6 +6583,7 @@ var Loupe = (() => {
6535
6583
  }
6536
6584
  d.querySelectorAll(".dctl [data-dock]").forEach((b) => b.classList.toggle("on", b.dataset.dock === this.dockMode));
6537
6585
  this.fabCluster.classList.toggle("show", !this.open && !this.launcherHidden);
6586
+ this.fabHandle.classList.toggle("show", !this.open && this.launcherHidden && isTouchDevice());
6538
6587
  this.applyFabPosition();
6539
6588
  this.applyFab();
6540
6589
  this.pushPage();
@@ -7807,6 +7856,8 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
7807
7856
  window.removeEventListener("pointercancel", this.onFabPointerUp);
7808
7857
  window.clearTimeout(this.toastTimer);
7809
7858
  document.removeEventListener("keydown", this.onLauncherKey);
7859
+ document.removeEventListener("click", this.onDocumentClick, true);
7860
+ document.removeEventListener("keydown", this.onMenuKey);
7810
7861
  document.removeEventListener("keydown", this.onKey, true);
7811
7862
  const de = document.documentElement;
7812
7863
  de.style.marginLeft = de.style.marginRight = de.style.marginBottom = "";
@@ -7987,7 +8038,40 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
7987
8038
  }
7988
8039
  function describeAnchor(c) {
7989
8040
  if (c.kind === "free") return "Free note \xB7 page-level";
7990
- return c.anchor.testid ? `[data-testid="${c.anchor.testid}"]` : c.anchor.cssPath;
8041
+ if (c.kind === "region" && c.region) {
8042
+ const size = `${Math.round(c.region.w)}\xD7${Math.round(c.region.h)}`;
8043
+ if (c.anchor.tag === "region") return `Area ${size} \xB7 page-level`;
8044
+ return `Area ${size} \xB7 ${relUsable(c.region.rel) ? "in" : "near"} ${anchorLabel(c.anchor)}`;
8045
+ }
8046
+ return anchorLabel(c.anchor);
8047
+ }
8048
+ function anchorLabel(a) {
8049
+ const tag = a.tag || "element";
8050
+ if (a.testid) return `${tag}[data-testid="${a.testid}"]`;
8051
+ const text = (a.text || "").replace(/\s+/g, " ").trim();
8052
+ if (text) return `${tag} \xB7 \u201C${text.length > 32 ? text.slice(0, 32).trimEnd() + "\u2026" : text}\u201D`;
8053
+ const attrs = a.attrs ?? {};
8054
+ const named = attrs["aria-label"] || attrs.title || attrs.placeholder || attrs.alt || attrs.name;
8055
+ if (named) return `${tag} \xB7 \u201C${named}\u201D`;
8056
+ return a.cssPath || tag;
8057
+ }
8058
+ var REGION_COVER_MIN = 0.6;
8059
+ function regionContainer2(from, vp) {
8060
+ const area = vp.w * vp.h;
8061
+ if (area <= 0) return null;
8062
+ for (let n = from; n && n !== document.body && n !== document.documentElement; n = n.parentElement) {
8063
+ const r = n.getBoundingClientRect();
8064
+ if (r.width <= 0 || r.height <= 0) continue;
8065
+ const ix = Math.max(0, Math.min(r.right, vp.x + vp.w) - Math.max(r.left, vp.x));
8066
+ const iy = Math.max(0, Math.min(r.bottom, vp.y + vp.h) - Math.max(r.top, vp.y));
8067
+ if (ix * iy / area >= REGION_COVER_MIN) return n;
8068
+ }
8069
+ return null;
8070
+ }
8071
+ function relUsable(rel) {
8072
+ if (!rel) return false;
8073
+ const lo = -1.5, hi = 2.5;
8074
+ return rel.fw > 0 && rel.fh > 0 && rel.fx > lo && rel.fy > lo && rel.fx + rel.fw < hi && rel.fy + rel.fh < hi;
7991
8075
  }
7992
8076
  function isResolved(c) {
7993
8077
  return normalizeStatus(c.status) === "resolved";