@autono/pinbox-toolbar 0.1.0 → 0.8.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.
@@ -1,12 +1,19 @@
1
1
  //#region src/targeting/dom.ts
2
2
  /**
3
- * Deepest element under (clientX, clientY), or null when the hit is the page
4
- * chrome itself (html/body) or something the caller ignores (our own overlay).
3
+ * Deepest element under (clientX, clientY) that the caller does not ignore, or null when there is
4
+ * nothing there but page chrome (html/body).
5
+ *
6
+ * Looks THROUGH our own overlay rather than giving up at it. The single-element form could not:
7
+ * the drag-aim grip sits exactly on the point being aimed at, so it is always the topmost thing
8
+ * under the crosshair, and every probe came back "nothing" the moment touch aiming existed.
5
9
  */
6
10
  function hitTest(doc, x, y, ignore) {
7
- const el = doc.elementFromPoint(x, y);
8
- if (!el || el === doc.body || el === doc.documentElement) return null;
9
- return ignore(el) ? null : el;
11
+ const stack = doc.elementsFromPoint?.(x, y) ?? [doc.elementFromPoint(x, y)];
12
+ for (const el of stack) {
13
+ if (!el || el === doc.body || el === doc.documentElement) return null;
14
+ if (!ignore(el)) return el;
15
+ }
16
+ return null;
10
17
  }
11
18
  /** CLASS-or-TAG display name with a sibling index when needed (prototype nodeName). */
12
19
  function nodeName(el) {
@@ -146,6 +153,44 @@ function isFixed(win, el) {
146
153
  }
147
154
  return false;
148
155
  }
156
+ /** Beyond this an element is not a thing you pinned, it is a region. Too many to rewrite as a set. */
157
+ const MAX_RUNS = 40;
158
+ const MAX_RUN_LENGTH = 200;
159
+ /** Text that is not content: a script body or a stylesheet is not something to rewrite. */
160
+ const NON_CONTENT = /* @__PURE__ */ new Set([
161
+ "SCRIPT",
162
+ "STYLE",
163
+ "NOSCRIPT",
164
+ "TEMPLATE"
165
+ ]);
166
+ /**
167
+ * The element's text, split the way the browser stores it: one entry per run of characters.
168
+ *
169
+ * This walks TEXT NODES, not elements, and that distinction is the whole point — it makes no
170
+ * assumption about how a site is built. A heading is one run. A nav bar is one per link. A
171
+ * paragraph with a bold word in the middle is three, in reading order, including the halves either
172
+ * side of the bold. An earlier version keyed off "elements with no element children", which
173
+ * quietly lost the "Hello " in `<p>Hello <b>world</b></p>` — text a person can obviously see and
174
+ * would obviously expect to be able to change.
175
+ *
176
+ * `nearbyText` runs them all together, which is fine to read and useless to edit: it cannot tell
177
+ * an agent that "work approach people contact" is four separate places. This can.
178
+ */
179
+ function textRuns(el) {
180
+ const runs = [];
181
+ const walk = (node) => {
182
+ if (node.nodeType === 3) {
183
+ const text = (node.nodeValue ?? "").trim();
184
+ if (text.length > 0) runs.push(text.slice(0, MAX_RUN_LENGTH));
185
+ return runs.length <= MAX_RUNS;
186
+ }
187
+ if (node.nodeType !== 1 || NON_CONTENT.has(node.tagName)) return true;
188
+ for (const child of node.childNodes) if (!walk(child)) return false;
189
+ return true;
190
+ };
191
+ if (!walk(el) || runs.length === 0) return void 0;
192
+ return runs;
193
+ }
149
194
  function buildContext(win, el) {
150
195
  const context = {};
151
196
  if (el.classList.length > 0) context.classes = [...el.classList];
@@ -157,6 +202,8 @@ function buildContext(win, el) {
157
202
  if (nearby !== void 0) context.nearbyText = nearby;
158
203
  const selected = selectedText(win, el);
159
204
  if (selected !== void 0) context.selectedText = selected;
205
+ const runs = textRuns(el);
206
+ if (runs !== void 0) context.textRuns = runs;
160
207
  return Object.keys(context).length > 0 ? context : void 0;
161
208
  }
162
209
  /** Fills PinInput.target/env from a chosen element (shapes come from the pin schema). */
@@ -176,6 +223,14 @@ function captureTarget(el, opts) {
176
223
  fixed: isFixed(win, el)
177
224
  };
178
225
  if (opts?.anchor !== void 0) target.anchor = opts.anchor;
226
+ if (opts?.at !== void 0 && r.width > 0 && r.height > 0) {
227
+ const fx = (opts.at.x - (r.left + win.scrollX)) / r.width;
228
+ const fy = (opts.at.y - (r.top + win.scrollY)) / r.height;
229
+ if (fx >= 0 && fx <= 1 && fy >= 0 && fy <= 1) target.spot = {
230
+ x: fx,
231
+ y: fy
232
+ };
233
+ }
179
234
  const context = buildContext(win, el);
180
235
  if (context !== void 0) target.context = context;
181
236
  return {
@@ -940,6 +995,118 @@ var HubTransport = class {
940
995
  }
941
996
  };
942
997
  //#endregion
998
+ //#region src/ui/aim.ts
999
+ /**
1000
+ * True when aiming has to be done by dragging rather than by pointing.
1001
+ *
1002
+ * Two independent reasons, either of which is sufficient. A coarse pointer means there is no
1003
+ * hover to follow at all — the mouse crosshair cannot work, whatever the screen size. The width
1004
+ * check is the design's own rule (720px) and catches the case a media query cannot: a device that
1005
+ * reports a fine pointer but is being used at phone width.
1006
+ */
1007
+ function needsDragAim(win) {
1008
+ return win.matchMedia?.("(pointer: coarse)").matches === true || win.innerWidth < 720;
1009
+ }
1010
+ /**
1011
+ * Where the reticle starts.
1012
+ *
1013
+ * Slightly above centre: the confirm bar owns the bottom of the screen, and a reticle that opens
1014
+ * underneath your own thumb is one you have to move before you can even see it.
1015
+ */
1016
+ function startPoint(win) {
1017
+ return {
1018
+ x: win.innerWidth / 2,
1019
+ y: win.innerHeight * .42
1020
+ };
1021
+ }
1022
+ const MARKUP = "<div class=\"h\"></div><div class=\"v\"></div><div class=\"grip\" aria-label=\"Pin position — arrow keys to aim\" tabindex=\"0\"><i></i></div><div class=\"bar\"><span class=\"lab\" role=\"status\" aria-live=\"polite\"></span><button type=\"button\" class=\"cancel\" data-aim=\"cancel\">CANCEL</button><button type=\"button\" class=\"ok\" data-aim=\"confirm\">PIN IT HERE</button></div>";
1023
+ function createAim(doc, handlers) {
1024
+ const win = doc.defaultView;
1025
+ const root = doc.createElement("div");
1026
+ root.className = "pb-aim";
1027
+ root.innerHTML = MARKUP;
1028
+ const h = root.querySelector(".h");
1029
+ const v = root.querySelector(".v");
1030
+ const grip = root.querySelector(".grip");
1031
+ const label = root.querySelector(".lab");
1032
+ const point = {
1033
+ x: 0,
1034
+ y: 0
1035
+ };
1036
+ /** Grab offset, so the reticle does not jump to your fingertip when you take hold of it. */
1037
+ let grab = null;
1038
+ function put(x, y) {
1039
+ point.x = Math.max(0, Math.min(win.innerWidth, x));
1040
+ point.y = Math.max(0, Math.min(win.innerHeight, y));
1041
+ h.style.top = `${point.y}px`;
1042
+ v.style.left = `${point.x}px`;
1043
+ grip.style.left = `${point.x}px`;
1044
+ grip.style.top = `${point.y}px`;
1045
+ }
1046
+ const onPointerMove = (e) => {
1047
+ if (grab === null) return;
1048
+ e.preventDefault();
1049
+ put(e.clientX + grab.dx, e.clientY + grab.dy);
1050
+ handlers.onAim(point.x, point.y);
1051
+ };
1052
+ const onPointerUp = () => {
1053
+ grab = null;
1054
+ };
1055
+ grip.addEventListener("pointerdown", (e) => {
1056
+ e.preventDefault();
1057
+ e.stopPropagation();
1058
+ grab = {
1059
+ dx: point.x - e.clientX,
1060
+ dy: point.y - e.clientY
1061
+ };
1062
+ grip.setPointerCapture?.(e.pointerId);
1063
+ });
1064
+ grip.addEventListener("keydown", (e) => {
1065
+ const step = e.shiftKey ? 20 : 2;
1066
+ const delta = {
1067
+ ArrowLeft: [-step, 0],
1068
+ ArrowRight: [step, 0],
1069
+ ArrowUp: [0, -step],
1070
+ ArrowDown: [0, step]
1071
+ }[e.key];
1072
+ if (!delta) return;
1073
+ e.preventDefault();
1074
+ put(point.x + delta[0], point.y + delta[1]);
1075
+ handlers.onAim(point.x, point.y);
1076
+ });
1077
+ root.addEventListener("click", (e) => {
1078
+ const action = e.target.closest?.("[data-aim]")?.getAttribute("data-aim");
1079
+ if (!action) return;
1080
+ e.preventDefault();
1081
+ e.stopPropagation();
1082
+ if (action === "confirm") handlers.onConfirm();
1083
+ else handlers.onCancel();
1084
+ });
1085
+ win.addEventListener("pointermove", onPointerMove, { passive: false });
1086
+ win.addEventListener("pointerup", onPointerUp);
1087
+ win.addEventListener("pointercancel", onPointerUp);
1088
+ return {
1089
+ root,
1090
+ point,
1091
+ show(x, y) {
1092
+ put(x, y);
1093
+ root.classList.add("on");
1094
+ },
1095
+ hide() {
1096
+ grab = null;
1097
+ root.classList.remove("on");
1098
+ },
1099
+ setLabel(text) {
1100
+ label.textContent = text;
1101
+ },
1102
+ destroy() {
1103
+ win.removeEventListener("pointermove", onPointerMove);
1104
+ win.removeEventListener("pointerup", onPointerUp);
1105
+ win.removeEventListener("pointercancel", onPointerUp);
1106
+ }
1107
+ };
1108
+ }
1109
+ //#endregion
943
1110
  //#region src/ui/bar.ts
944
1111
  const PIN_ICON = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\"><rect x=\"3\" y=\"1.5\" width=\"10\" height=\"6.5\" rx=\"1\"/><path d=\"M8 8v6.5\"/></svg>";
945
1112
  const INBOX_ICON = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\"><path d=\"M1.8 8.5h3.4l1 2h3.6l1-2h3.4\"/><path d=\"M2.6 3.2h10.8l1.2 5.3v4a1 1 0 01-1 1H2.4a1 1 0 01-1-1v-4z\"/></svg>";
@@ -1048,6 +1215,31 @@ function messageHtml(m) {
1048
1215
  const via = origin ? `<span class="via-tag"><span>${esc(origin)}</span></span>` : "";
1049
1216
  return `<div class="pb-msg you"><div class="pb-av${mirror ? " via" : ""}">${esc(initials)}</div><div class="col"><div class="line"><span class="who">${esc(who)}</span><span class="tm">${esc(timeOf(m.at))}</span>${via}</div><div class="txt">${esc(m.text)}</div>${attachmentsHtml(m)}</div></div>`;
1050
1217
  }
1218
+ /** The agent has the message and has not answered yet. Its own node, so patching never rebuilds. */
1219
+ const TYPING_HTML = "<div class=\"pb-typing\"><div class=\"pb-av agent\">AI</div><div class=\"dots\"><i></i><i></i><i></i></div><div class=\"lbl\">THINKING</div></div>";
1220
+ /**
1221
+ * Show or hide the "working on it" row.
1222
+ *
1223
+ * Without it the card sits silent from the moment you comment until the answer lands, which reads
1224
+ * as nothing happening — the single most common report on the demo.
1225
+ */
1226
+ function patchTyping(threadEl, pending) {
1227
+ const existing = threadEl.querySelector("[data-iid=\"pb-typing\"]");
1228
+ if (!pending) {
1229
+ existing?.remove();
1230
+ return;
1231
+ }
1232
+ if (existing) {
1233
+ threadEl.appendChild(existing);
1234
+ return;
1235
+ }
1236
+ const node = threadEl.ownerDocument.createElement("div");
1237
+ node.className = "pb-msg-w";
1238
+ node.setAttribute("data-iid", "pb-typing");
1239
+ node.innerHTML = TYPING_HTML;
1240
+ threadEl.appendChild(node);
1241
+ threadEl.scrollTop = threadEl.scrollHeight;
1242
+ }
1051
1243
  /** Keyed thread patching: appends/patches [data-iid] nodes only, never rebuilds. */
1052
1244
  function patchThread(threadEl, messages) {
1053
1245
  let appended = false;
@@ -1213,6 +1405,22 @@ function viewOf(state) {
1213
1405
  };
1214
1406
  }
1215
1407
  /** Render the thread card for a state snapshot: the active pin, or the draft. */
1408
+ /**
1409
+ * The pin's own text, as the first message in its thread.
1410
+ *
1411
+ * A pin stores what you wrote on the pin itself, not in the thread — so a card that renders only
1412
+ * `thread` shows an empty box the moment you hit Comment, and your words look lost. They are not
1413
+ * lost; they were never drawn.
1414
+ */
1415
+ function pinAsMessage(pin) {
1416
+ return {
1417
+ id: `pin:${pin.id}`,
1418
+ pinId: pin.id,
1419
+ role: "human",
1420
+ text: pin.text,
1421
+ at: pin.createdAt
1422
+ };
1423
+ }
1216
1424
  function renderCard(root, state, actions) {
1217
1425
  const card = ensureShell(root);
1218
1426
  const ctx = ctxByCard.get(card);
@@ -1226,7 +1434,7 @@ function renderCard(root, state, actions) {
1226
1434
  if (ctx.pid !== view.pid) {
1227
1435
  ctx.pid = view.pid;
1228
1436
  ctx.parts = {};
1229
- buildSkeleton(card, ctx, view.pid === "draft", view.thread.length > 0);
1437
+ buildSkeleton(card, ctx, view.pid === "draft", view.pin !== null || view.thread.length > 0);
1230
1438
  }
1231
1439
  card.hidden = false;
1232
1440
  const queued = view.pin !== null && state.queuedIds.has(view.pin.id);
@@ -1235,9 +1443,13 @@ function renderCard(root, state, actions) {
1235
1443
  setPart(card, ctx, "hd", hdHtml(view.n, view.label, statusLabel, resolvable));
1236
1444
  setPart(card, ctx, "link", linkHtml(view.pin));
1237
1445
  setPart(card, ctx, "verify", verifyHtml(view.status));
1238
- setPart(card, ctx, "row", rowHtml(view.thread.length > 0));
1446
+ const messages = view.pin === null ? view.thread : [pinAsMessage(view.pin), ...view.thread];
1447
+ setPart(card, ctx, "row", rowHtml(messages.length > 0));
1239
1448
  const threadEl = card.querySelector("[data-ref=\"thread\"]");
1240
- if (threadEl) patchThread(threadEl, view.thread);
1449
+ if (threadEl) {
1450
+ patchThread(threadEl, messages);
1451
+ patchTyping(threadEl, !queued && view.pin?.status === "open" && view.status === "waiting");
1452
+ }
1241
1453
  position(card, view.at);
1242
1454
  }
1243
1455
  //#endregion
@@ -1337,11 +1549,19 @@ function createDrawer(doc, on) {
1337
1549
  //#region src/ui/pins.ts
1338
1550
  /** The prototype's `_h` innerHTML memo, kept off the DOM node. */
1339
1551
  const chipMemo = /* @__PURE__ */ new WeakMap();
1340
- /** Needle anchor point for a committed pin: center of the captured target rect. */
1341
- function pinPoint(r) {
1552
+ /**
1553
+ * Where the needle lands: the point inside the element that was actually clicked, when the pin
1554
+ * recorded one, else the centre of its box.
1555
+ *
1556
+ * `spot` is a fraction of the element, so the pin still tracks the element when it moves or
1557
+ * resizes — it just stops sliding to the middle of a wide block the moment you commit it.
1558
+ */
1559
+ function pinPoint(r, spot) {
1560
+ const fx = spot?.x ?? .5;
1561
+ const fy = spot?.y ?? .5;
1342
1562
  return {
1343
- x: r.x + r.width / 2,
1344
- y: r.y + r.height / 2
1563
+ x: r.x + r.width * fx,
1564
+ y: r.y + r.height * fy
1345
1565
  };
1346
1566
  }
1347
1567
  /** Chip contents (prototype chipBtnInner, lines 546–550): number + linked-channel tag,
@@ -1383,21 +1603,28 @@ function renderPins(layer, state) {
1383
1603
  const placed = [];
1384
1604
  visible.forEach((pin, i) => {
1385
1605
  const rect = pin.target?.rect;
1386
- if (rect !== void 0) placed.push({
1606
+ if (rect === void 0) return;
1607
+ const spot = pin.target?.spot;
1608
+ placed.push(spot === void 0 ? {
1387
1609
  pin,
1388
1610
  n: i + 1,
1389
1611
  rect
1612
+ } : {
1613
+ pin,
1614
+ n: i + 1,
1615
+ rect,
1616
+ spot
1390
1617
  });
1391
1618
  });
1392
1619
  const keys = new Set(placed.map((entry) => entry.pin.id));
1393
1620
  if (state.draft) keys.add("draft");
1394
1621
  for (const node of [...layer.children]) if (!keys.has(node.getAttribute("data-pin") ?? "")) node.remove();
1395
- for (const { pin, n, rect } of placed) {
1622
+ for (const { pin, n, rect, spot } of placed) {
1396
1623
  const node = ensureNode(layer, pin.id, false);
1397
1624
  const hot = pin.id === state.activePinId;
1398
1625
  const queued = state.queuedIds.has(pin.id);
1399
1626
  node.classList.toggle("queued", queued);
1400
- patchNode(node, pinPoint(rect), hot, chipInner(n, pin, queued));
1627
+ patchNode(node, pinPoint(rect, spot), hot, chipInner(n, pin, queued));
1401
1628
  }
1402
1629
  if (state.draft) patchNode(ensureNode(layer, "draft", true), state.draft.placedAt, true, chipInner(visible.length + 1, null));
1403
1630
  }
@@ -1538,6 +1765,26 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
1538
1765
  .pb-reticle .box { position: absolute; width: 15px; height: 15px; margin: -8px 0 0 -8px; border: 1px solid var(--pb-amber); border-radius: 2px; }
1539
1766
  .pb-reticle .ro { position: absolute; margin: 14px 0 0 14px; padding: 3px 6px; background: var(--pb-amber); color: var(--pb-amber-ink); font-family: var(--pb-font-mono); font-size: 9.5px; letter-spacing: .12em; border-radius: 2px; white-space: nowrap; }
1540
1767
 
1768
+ /* Drag-to-aim, for touch. The layer never takes pointer events — only the grip and the bar do —
1769
+ so what is under the crosshair can still be probed, and the page underneath is still visible. */
1770
+ /* Above the command bar (90), below the shortcuts modal (120). The confirm bar sits at the very
1771
+ bottom of the screen, where the command bar already is — under it, CONFIRM was unclickable. */
1772
+ .pb-aim { position: fixed; inset: 0; z-index: 100; display: none; pointer-events: none; }
1773
+ .pb-aim.on { display: block; animation: pb-fade 160ms ease-out both; }
1774
+ .pb-aim .h { position: absolute; left: 0; right: 0; height: 1px; background: color-mix(in srgb, var(--pb-amber) 30%, transparent); }
1775
+ .pb-aim .v { position: absolute; top: 0; bottom: 0; width: 1px; background: color-mix(in srgb, var(--pb-amber) 30%, transparent); }
1776
+ /* 72px: a finger-sized target, per the design. Smaller and you cannot hold it accurately;
1777
+ touch-action:none is what stops the page scrolling instead of the reticle moving. */
1778
+ .pb-aim .grip { position: absolute; width: 72px; height: 72px; margin: -36px 0 0 -36px; border-radius: 999px; border: 1px solid var(--pb-amber); background: var(--pb-amber-soft); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); display: flex; align-items: center; justify-content: center; pointer-events: auto; touch-action: none; cursor: grab; }
1779
+ .pb-aim .grip:active { cursor: grabbing; }
1780
+ .pb-aim .grip i { width: 10px; height: 10px; border-radius: 999px; background: var(--pb-amber); box-shadow: 0 0 0 3px var(--pb-canvas); }
1781
+ .pb-aim .bar { position: absolute; left: 12px; right: 12px; bottom: 12px; display: flex; align-items: center; gap: 8px; padding: 7px; background: var(--pb-bar); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border: 1px solid var(--pb-line-2); border-radius: 4px; box-shadow: var(--pb-shadow); pointer-events: auto; }
1782
+ .pb-aim .bar .lab { flex: 1; min-width: 0; padding-left: 8px; font-family: var(--pb-font-mono); font-size: 10px; letter-spacing: .14em; color: var(--pb-fg3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1783
+ /* 48px tall: the minimum a thumb hits reliably. */
1784
+ .pb-aim .bar button { height: 48px; border-radius: 2px; font-family: var(--pb-font-mono); font-size: 11px; letter-spacing: .16em; cursor: pointer; }
1785
+ .pb-aim .bar .cancel { flex: none; padding: 0 18px; border: 1px solid var(--pb-line-2); background: transparent; color: var(--pb-fg2); }
1786
+ .pb-aim .bar .ok { flex: none; padding: 0 20px; border: none; background: var(--pb-amber); color: var(--pb-amber-ink); }
1787
+
1541
1788
  .pb-pin { position: absolute; }
1542
1789
  .pb-pin.resolving { animation: pb-resolve 380ms var(--pb-ease) forwards; }
1543
1790
  .pb-pin .ring { position: absolute; left: 0; top: 0; width: 26px; height: 26px; border: 1px solid var(--pb-amber); border-radius: 999px; animation: pb-ring 900ms var(--pb-ease) forwards; pointer-events: none; }
@@ -1573,6 +1820,12 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
1573
1820
  .pb-msg-w { animation: pb-in 260ms var(--pb-ease) both; }
1574
1821
  .pb-msg { padding: 13px 14px; display: flex; gap: 10px; }
1575
1822
  .pb-msg.you { border-bottom: 1px solid var(--pb-line); }
1823
+ .pb-typing { padding: 13px 14px; display: flex; gap: 10px; align-items: center; }
1824
+ .pb-typing .dots { display: flex; gap: 4px; }
1825
+ .pb-typing .dots i { width: 4px; height: 4px; border-radius: 999px; background: var(--pb-amber); animation: pb-pulse 1.1s var(--pb-ease) infinite; }
1826
+ .pb-typing .dots i:nth-child(2) { animation-delay: .18s; }
1827
+ .pb-typing .dots i:nth-child(3) { animation-delay: .36s; }
1828
+ .pb-typing .lbl { font-family: var(--pb-font-mono); font-size: 9px; letter-spacing: .14em; color: var(--pb-fg3); }
1576
1829
  .pb-msg .steps { display: flex; flex-direction: column; gap: 7px; }
1577
1830
  .pb-av { flex: none; width: 22px; height: 22px; border-radius: 999px; display: flex; align-items: center; justify-content: center; font-family: var(--pb-font-mono); font-size: 9px; letter-spacing: .04em; background: var(--pb-invert-bg); color: var(--pb-invert-fg); border: 1px solid var(--pb-invert-bg); }
1578
1831
  .pb-av.via { background: transparent; color: var(--pb-info); border-color: var(--pb-info); }
@@ -1670,17 +1923,31 @@ function isTextEntry(target) {
1670
1923
  }
1671
1924
  var PinboxToolbarElement = class extends BaseElement {
1672
1925
  static tagName = "pinbox-toolbar";
1926
+ /** Watched so a config that arrives after insertion can still start the transport. */
1927
+ static observedAttributes = ["hub", "token"];
1673
1928
  store = createStore();
1674
1929
  /** Card → transport seam (wired by #startTransport once a config exists). */
1675
1930
  actions = {};
1676
1931
  #config = null;
1677
1932
  #transport = null;
1933
+ /**
1934
+ * Connected-lifetime counter, bumped on disconnect. #startTransport can park at
1935
+ * an await (getToken, or `await undefined` on the token-less path); a
1936
+ * continuation that crossed a disconnect must not install a transport into a
1937
+ * later lifetime, where it would shadow or duplicate that lifetime's own start.
1938
+ */
1939
+ #lifetime = 0;
1940
+ /** One deferred start per tick, however many attribute callbacks land in it. */
1941
+ #startQueued = false;
1678
1942
  #token = "";
1679
1943
  #built = false;
1680
1944
  #bar = null;
1681
1945
  #reticle = null;
1682
1946
  #pinsLayer = null;
1683
1947
  #drawer = null;
1948
+ #aim = null;
1949
+ /** Pending viewport-refresh frame, 0 when none is queued. */
1950
+ #viewportFrame = 0;
1684
1951
  #modal = null;
1685
1952
  #helpOpen = false;
1686
1953
  #pageStyle = null;
@@ -1696,6 +1963,7 @@ var PinboxToolbarElement = class extends BaseElement {
1696
1963
  /** Programmatic path (Pinbox.init). The snippet path reads hub/token attributes. */
1697
1964
  configure(config) {
1698
1965
  this.#config = config;
1966
+ if (this.isConnected) this.#queueStart();
1699
1967
  }
1700
1968
  get config() {
1701
1969
  if (this.#config) return this.#config;
@@ -1720,25 +1988,77 @@ var PinboxToolbarElement = class extends BaseElement {
1720
1988
  style.textContent = PAGE_CSS;
1721
1989
  document.head.appendChild(style);
1722
1990
  this.#pageStyle = style;
1991
+ if (this.#aim === null) this.#mountAim();
1723
1992
  document.addEventListener("mousemove", this.#onMouseMove);
1993
+ window.addEventListener("scroll", this.#onViewportChange, { passive: true });
1994
+ window.addEventListener("resize", this.#onViewportChange);
1724
1995
  document.addEventListener("click", this.#onClickCapture, true);
1725
1996
  document.addEventListener("keydown", this.#onKeyDown);
1726
1997
  this.#unsubscribe = this.store.subscribe((s) => this.#render(s));
1727
1998
  this.#render(this.store.get());
1728
- this.#startTransport();
1999
+ this.#queueStart();
2000
+ }
2001
+ /**
2002
+ * Late-config rescue: an element inserted BEFORE its hub/token attributes were
2003
+ * set connected configless and #startTransport bailed. Starting here the moment
2004
+ * a config first exists keeps such an element from staying silently dead.
2005
+ * Config is still read once — a running transport is never reconfigured.
2006
+ */
2007
+ attributeChangedCallback() {
2008
+ if (this.isConnected && this.config !== null) this.#queueStart();
2009
+ }
2010
+ /**
2011
+ * Start at the END of the current tick, not synchronously: a config assembled
2012
+ * attribute-by-attribute on a connected element (append → set hub → set token)
2013
+ * must be read whole. A synchronous start at the first fragment would connect
2014
+ * token-less and, per the read-once rule, drop the token forever.
2015
+ */
2016
+ #queueStart() {
2017
+ if (this.#startQueued) return;
2018
+ this.#startQueued = true;
2019
+ queueMicrotask(() => {
2020
+ this.#startQueued = false;
2021
+ this.#startTransport();
2022
+ });
1729
2023
  }
1730
2024
  disconnectedCallback() {
2025
+ this.#lifetime += 1;
1731
2026
  this.#transport?.close();
1732
2027
  this.#transport = null;
1733
2028
  document.removeEventListener("mousemove", this.#onMouseMove);
2029
+ window.removeEventListener("scroll", this.#onViewportChange);
2030
+ window.removeEventListener("resize", this.#onViewportChange);
1734
2031
  document.removeEventListener("click", this.#onClickCapture, true);
1735
2032
  document.removeEventListener("keydown", this.#onKeyDown);
2033
+ if (this.#viewportFrame !== 0) cancelAnimationFrame(this.#viewportFrame);
2034
+ this.#viewportFrame = 0;
2035
+ this.#aim?.destroy();
2036
+ this.#aim = null;
1736
2037
  this.#unsubscribe?.();
1737
2038
  this.#unsubscribe = null;
1738
2039
  this.#pageStyle?.remove();
1739
2040
  this.#pageStyle = null;
1740
2041
  document.body.classList.remove(PAGE_PLACING_CLASS);
1741
2042
  }
2043
+ /**
2044
+ * Create the aim controller and put its layer in the shadow root.
2045
+ *
2046
+ * Separate from `#build` because the two have different lifetimes: `#build` runs once, but
2047
+ * `disconnectedCallback` tears this controller's window listeners down. A re-parented element
2048
+ * would otherwise come back with no controller and its markup still in place — a grip that
2049
+ * renders and does nothing, with no error to explain it.
2050
+ */
2051
+ #mountAim() {
2052
+ const shadow = this.shadowRoot;
2053
+ if (!shadow) return;
2054
+ shadow.querySelector(".pb-aim")?.remove();
2055
+ this.#aim = createAim(document, {
2056
+ onAim: (x, y) => this.#probe(x, y),
2057
+ onConfirm: () => this.#confirmAim(),
2058
+ onCancel: () => this.#dismiss()
2059
+ });
2060
+ shadow.appendChild(this.#aim.root);
2061
+ }
1742
2062
  #build() {
1743
2063
  const shadow = this.attachShadow({ mode: "open" });
1744
2064
  try {
@@ -1775,6 +2095,7 @@ var PinboxToolbarElement = class extends BaseElement {
1775
2095
  onClose: () => this.store.update({ inboxOpen: false })
1776
2096
  });
1777
2097
  shadow.appendChild(this.#drawer.root);
2098
+ this.#mountAim();
1778
2099
  this.#modal = createShortcutsModal(document, () => this.#setHelp(false));
1779
2100
  shadow.appendChild(this.#modal.root);
1780
2101
  this.#pinsLayer.addEventListener("click", (e) => this.#onChipClick(e));
@@ -1785,10 +2106,13 @@ var PinboxToolbarElement = class extends BaseElement {
1785
2106
  * still renders read-only threads and queued drafts.
1786
2107
  */
1787
2108
  async #startTransport() {
1788
- if (this.#transport !== null) return;
2109
+ if (this.#transport !== null || !this.isConnected) return;
1789
2110
  const cfg = this.config;
1790
2111
  if (cfg === null) return;
1791
- this.#token = cfg.token ?? await cfg.getToken?.().catch(() => void 0) ?? "";
2112
+ const lifetime = this.#lifetime;
2113
+ const token = cfg.token ?? await cfg.getToken?.().catch(() => void 0) ?? "";
2114
+ if (this.#lifetime !== lifetime || this.#transport !== null || !this.isConnected) return;
2115
+ this.#token = token;
1792
2116
  const transport = new HubTransport({
1793
2117
  endpoint: cfg.endpoint,
1794
2118
  token: this.#token,
@@ -1804,7 +2128,10 @@ var PinboxToolbarElement = class extends BaseElement {
1804
2128
  if (queued.length > 0) this.store.update({ queuedIds: new Set(queued.map((p) => p.id)) });
1805
2129
  this.actions.send = (pinId, text) => void this.#send(transport, pinId, text);
1806
2130
  this.actions.resolve = (pinId) => void transport.resolve(pinId).then((pin) => upsertPin(this.store, pin)).catch(() => {});
1807
- this.actions.verify = (pinId, outcome) => void transport.verify(pinId, outcome).then((pin) => upsertPin(this.store, pin)).catch(() => {});
2131
+ this.actions.verify = (pinId, outcome) => void transport.verify(pinId, outcome).then((pin) => {
2132
+ upsertPin(this.store, pin);
2133
+ if (outcome === "accepted") this.#dismiss();
2134
+ }).catch(() => {});
1808
2135
  transport.connect();
1809
2136
  }
1810
2137
  /** draft ⇒ compose PinInput (+ best-effort screenshot) and createPin; else thread reply. */
@@ -1830,6 +2157,7 @@ var PinboxToolbarElement = class extends BaseElement {
1830
2157
  async #screenshot(selector) {
1831
2158
  const cfg = this.config;
1832
2159
  if (cfg === null) return null;
2160
+ if (cfg.screenshots === false) return null;
1833
2161
  try {
1834
2162
  const el = document.querySelector(selector);
1835
2163
  if (el === null) return null;
@@ -1913,28 +2241,71 @@ var PinboxToolbarElement = class extends BaseElement {
1913
2241
  });
1914
2242
  if (this.store.get().draft) this.store.discardDraft();
1915
2243
  }
2244
+ /**
2245
+ * Work out what sits under a viewport point and highlight it.
2246
+ *
2247
+ * Shared by both ways of aiming — following a mouse, and dragging the reticle — so the two can
2248
+ * never disagree about what is under the crosshair.
2249
+ */
2250
+ #probe(clientX, clientY) {
2251
+ const el = hitTest(document, clientX, clientY, (hit) => hit === this);
2252
+ this.#hover = el;
2253
+ if (el) this.#reticle?.snap(el.getBoundingClientRect(), targetLabel(el), {
2254
+ x: window.scrollX,
2255
+ y: window.scrollY
2256
+ });
2257
+ else this.#reticle?.release();
2258
+ this.#aim?.setLabel(el ? targetLabel(el) : "NOTHING UNDER THE PIN");
2259
+ }
2260
+ /**
2261
+ * Keep the drag-aim reticle honest while the viewport moves under it.
2262
+ *
2263
+ * Scrolling changes what is beneath a fixed reticle, and resizing (a phone rotating, a window
2264
+ * dragged narrow) can both strand it off-screen and flip which way of aiming applies.
2265
+ */
2266
+ #onViewportChange = () => {
2267
+ if (this.store.get().mode !== "placing" || this.#viewportFrame !== 0) return;
2268
+ this.#viewportFrame = requestAnimationFrame(() => {
2269
+ this.#viewportFrame = 0;
2270
+ if (this.store.get().mode !== "placing") return;
2271
+ this.#syncAim(true);
2272
+ const aim = this.#aim;
2273
+ if (aim?.root.classList.contains("on") === true) this.#probe(aim.point.x, aim.point.y);
2274
+ });
2275
+ };
1916
2276
  #onMouseMove = (e) => {
1917
2277
  if (this.store.get().mode !== "placing" || !this.#reticle) return;
1918
2278
  this.#reticle.move(e);
1919
- const el = hitTest(document, e.clientX, e.clientY, (hit) => hit === this);
1920
- if (el) {
1921
- this.#hover = el;
1922
- this.#reticle.snap(el.getBoundingClientRect(), targetLabel(el), {
1923
- x: window.scrollX,
1924
- y: window.scrollY
1925
- });
1926
- } else {
1927
- this.#hover = null;
1928
- this.#reticle.release();
1929
- }
2279
+ this.#probe(e.clientX, e.clientY);
1930
2280
  };
2281
+ /** Commit the pin the drag-aim reticle is sitting on. */
2282
+ #confirmAim() {
2283
+ const aim = this.#aim;
2284
+ if (!aim) return;
2285
+ this.#probe(aim.point.x, aim.point.y);
2286
+ const el = this.#hover ?? document.body;
2287
+ this.store.place({
2288
+ target: captureTarget(el, { at: {
2289
+ x: aim.point.x + window.scrollX,
2290
+ y: aim.point.y + window.scrollY
2291
+ } }),
2292
+ placedAt: {
2293
+ x: aim.point.x + window.scrollX,
2294
+ y: aim.point.y + window.scrollY
2295
+ }
2296
+ });
2297
+ this.#reticle?.release();
2298
+ }
1931
2299
  /** Placement click: capture the hovered target (or body) into a client-only draft. */
1932
2300
  #placeDraft(e) {
1933
2301
  e.preventDefault();
1934
2302
  e.stopPropagation();
1935
2303
  const el = this.#hover ?? document.body;
1936
2304
  this.store.place({
1937
- target: captureTarget(el),
2305
+ target: captureTarget(el, { at: {
2306
+ x: e.pageX,
2307
+ y: e.pageY
2308
+ } }),
1938
2309
  placedAt: {
1939
2310
  x: e.pageX,
1940
2311
  y: e.pageY
@@ -1945,8 +2316,12 @@ var PinboxToolbarElement = class extends BaseElement {
1945
2316
  #onClickCapture = (e) => {
1946
2317
  if (e.composedPath().includes(this)) return;
1947
2318
  const state = this.store.get();
1948
- if (state.mode === "placing") this.#placeDraft(e);
1949
- else if (state.activePinId || state.draft) this.#dismiss();
2319
+ if (state.mode === "placing") {
2320
+ if (!needsDragAim(window)) this.#placeDraft(e);
2321
+ return;
2322
+ }
2323
+ if (state.inboxOpen) this.store.update({ inboxOpen: false });
2324
+ if (state.activePinId || state.draft) this.#dismiss();
1950
2325
  };
1951
2326
  /** Prototype keyboard map (v2-command-bar.html lines 701–712). */
1952
2327
  #shortcuts = {
@@ -1962,11 +2337,33 @@ var PinboxToolbarElement = class extends BaseElement {
1962
2337
  if (isTextEntry(e.composedPath()[0])) return;
1963
2338
  this.#shortcuts[e.key === "?" ? "?" : e.key.toLowerCase()]?.();
1964
2339
  };
2340
+ /**
2341
+ * Bring the drag-aim reticle up with placing mode, seeded mid-screen and already showing what it
2342
+ * is over — so the first thing you see is a live target, not an empty crosshair waiting for a
2343
+ * mouse that is never coming.
2344
+ */
2345
+ #syncAim(placing) {
2346
+ const aim = this.#aim;
2347
+ if (!aim) return;
2348
+ if (!placing || !needsDragAim(window)) {
2349
+ aim.hide();
2350
+ return;
2351
+ }
2352
+ if (aim.root.classList.contains("on")) {
2353
+ if (aim.point.x <= window.innerWidth && aim.point.y <= window.innerHeight) return;
2354
+ aim.show(Math.min(aim.point.x, window.innerWidth), Math.min(aim.point.y, window.innerHeight));
2355
+ return;
2356
+ }
2357
+ const { x, y } = startPoint(window);
2358
+ aim.show(x, y);
2359
+ this.#probe(x, y);
2360
+ }
1965
2361
  #render(state) {
1966
2362
  const placing = state.mode === "placing";
1967
2363
  this.toggleAttribute("data-placing", placing);
1968
2364
  document.body.classList.toggle(PAGE_PLACING_CLASS, placing);
1969
2365
  if (!placing) this.#reticle?.release();
2366
+ this.#syncAim(placing);
1970
2367
  if (this.#pinsLayer) renderPins(this.#pinsLayer, state);
1971
2368
  if (this.shadowRoot) renderCard(this.shadowRoot, state, this.#cardActions);
1972
2369
  this.#drawer?.update(state);
package/dist/svelte.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { _ as applyHubEvent, a as HubEvent, b as upsertPin, c as WebSocketLike, d as PinboxToolbarElement, f as Draft, g as appendThreadMessage, h as UiStatus, i as ConnectionState, l as HubError, m as ToolbarState, n as PinboxConfig, o as HubTransport, p as Store, r as defineToolbarElement, s as TransportOptions, t as Pinbox, u as StorageLike, v as createStore, x as CaptureResult, y as deriveUiStatus } from "./index-ZR3JBQu-.js";
1
+ import { _ as applyHubEvent, a as HubEvent, b as upsertPin, c as WebSocketLike, d as PinboxToolbarElement, f as Draft, g as appendThreadMessage, h as UiStatus, i as ConnectionState, l as HubError, m as ToolbarState, n as PinboxConfig, o as HubTransport, p as Store, r as defineToolbarElement, s as TransportOptions, t as Pinbox, u as StorageLike, v as createStore, x as CaptureResult, y as deriveUiStatus } from "./index-BAoi0bRT.js";
2
2
  import { Action } from "svelte/action";
3
3
  //#region src/svelte.d.ts
4
4
  /**