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