@autono/pinbox-toolbar 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,254 +24,170 @@ var Pinbox = (function(exports) {
24
24
  } };
25
25
  }
26
26
  //#endregion
27
- //#region src/targeting/dom.ts
28
- /**
29
- * Deepest element under (clientX, clientY) that the caller does not ignore, or null when there is
30
- * nothing there but page chrome (html/body).
31
- *
32
- * Looks THROUGH our own overlay rather than giving up at it. The single-element form could not:
33
- * the drag-aim grip sits exactly on the point being aimed at, so it is always the topmost thing
34
- * under the crosshair, and every probe came back "nothing" the moment touch aiming existed.
35
- */
36
- function hitTest(doc, x, y, ignore) {
37
- const stack = doc.elementsFromPoint?.(x, y) ?? [doc.elementFromPoint(x, y)];
38
- for (const el of stack) {
39
- if (!el || el === doc.body || el === doc.documentElement) return null;
40
- if (!ignore(el)) return el;
41
- }
42
- return null;
27
+ //#region src/capture-mode.ts
28
+ function captureKey(prefix) {
29
+ return `${prefix}:capture`;
43
30
  }
44
- /** CLASS-or-TAG display name with a sibling index when needed (prototype nodeName). */
45
- function nodeName(el) {
46
- const key = el.classList[0];
47
- let name = (key ?? el.tagName).toUpperCase();
48
- const parent = el.parentElement;
49
- if (parent) {
50
- const sibs = [...parent.children].filter((c) => c.classList[0] === key && c.tagName === el.tagName);
51
- if (sibs.length > 1) name += ` ${sibs.indexOf(el) + 1}`;
52
- }
53
- return name;
54
- }
55
- /**
56
- * Human label for a target: an explicit data-pb-el annotation wins; otherwise a
57
- * CLASS/TAG ancestry chain of at most 3 parts joined with ›, terminating early
58
- * at the first annotated ancestor.
59
- */
60
- function targetLabel(el) {
61
- const own = el.getAttribute("data-pb-el");
62
- if (own) return own;
63
- const parts = [nodeName(el)];
64
- const body = el.ownerDocument.body;
65
- let node = el.parentElement;
66
- while (node && node !== body && parts.length < 3) {
67
- const anchor = node.getAttribute("data-pb-el");
68
- if (anchor) {
69
- parts.unshift(anchor);
70
- break;
71
- }
72
- if (node.classList[0]) parts.unshift(nodeName(node));
73
- node = node.parentElement;
74
- }
75
- return parts.join(" › ");
31
+ function loadCaptureMode(storage, key, fallback) {
32
+ try {
33
+ const raw = storage?.getItem(key);
34
+ if (raw === "dom" || raw === "tab") return raw;
35
+ } catch {}
36
+ return fallback;
76
37
  }
77
- const SAFE_ID = /^[A-Za-z][\w-]*$/;
78
- /** Data attributes trusted as stable hooks, in priority order. */
79
- const STABLE_DATA_ATTRS = [
80
- "data-pb-anchor",
81
- "data-pb-el",
82
- "data-testid"
83
- ];
84
- function attrSegment(el, doc) {
85
- for (const attr of STABLE_DATA_ATTRS) {
86
- const value = el.getAttribute(attr);
87
- if (value === null || value.includes("\"") || value.includes("\\")) continue;
88
- const selector = `${el.tagName.toLowerCase()}[${attr}="${value}"]`;
89
- if (doc.querySelectorAll(selector).length === 1) return selector;
90
- }
91
- return null;
38
+ function saveCaptureMode(storage, key, mode) {
39
+ try {
40
+ storage?.setItem(key, mode);
41
+ } catch {}
92
42
  }
93
- function nthSegment(el) {
94
- const tag = el.tagName.toLowerCase();
95
- const parent = el.parentElement;
96
- if (!parent) return tag;
97
- const sameTag = [...parent.children].filter((c) => c.tagName === el.tagName);
98
- return sameTag.length > 1 ? `${tag}:nth-of-type(${sameTag.indexOf(el) + 1})` : tag;
43
+ //#endregion
44
+ //#region src/ui/actions.ts
45
+ const PIN_GLYPH = "<rect x=\"3\" y=\"1.5\" width=\"10\" height=\"6.5\" rx=\"1\"/><path d=\"M8 8v6.5\"/>";
46
+ const INBOX_GLYPH = "<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\"/>";
47
+ const THEME_GLYPH = "<path d=\"M8 2a4 4 0 0 0 6 6 6 6 0 1 1-6-6z\"/>";
48
+ const COPY_GLYPH = "<rect x=\"5.5\" y=\"5.5\" width=\"8\" height=\"8\" rx=\"1\"/><path d=\"M10.5 3.5v-1a1 1 0 00-1-1h-6a1 1 0 00-1 1v6a1 1 0 001 1h1\"/>";
49
+ const EYE_GLYPH = "<path d=\"M1.6 8S4 3.8 8 3.8 14.4 8 14.4 8 12 12.2 8 12.2 1.6 8 1.6 8z\"/><circle cx=\"8\" cy=\"8\" r=\"1.8\"/>";
50
+ const EYE_OFF_GLYPH = "<path d=\"M2.3 2.3l11.4 11.4\"/><path d=\"M4.9 4.9C2.7 6.2 1.6 8 1.6 8s2.4 4.2 6.4 4.2c1.2 0 2.3-.3 3.1-.8M6.7 4c.4-.1.9-.2 1.3-.2 4 0 6.4 4.2 6.4 4.2s-.8 1.4-2.2 2.5\"/>";
51
+ const MIN_GLYPH = "<path d=\"M6.5 2.5v4h-4\"/><path d=\"M9.5 13.5v-4h4\"/>";
52
+ const CAMERA_GLYPH = "<path d=\"M2 5.5h2.6l1.2-1.8h4.4L11.4 5.5H14v7.5H2z\"/><circle cx=\"8\" cy=\"9\" r=\"2.3\"/>";
53
+ const EXPAND_GLYPH = "<path d=\"M9.5 6.5v-4h4\"/><path d=\"M6.5 9.5v4h-4\"/>";
54
+ /** Frame a glyph as an inline SVG at `size` px. */
55
+ function icon(glyph, size) {
56
+ return `<svg width="${size}" height="${size}" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">${glyph}</svg>`;
99
57
  }
100
- /**
101
- * Stable CSS path for an element: ids > stable data attributes > an
102
- * nth-of-type chain. Guaranteed round-trip: querySelector(buildSelector(el)) === el.
103
- */
104
- function buildSelector(el) {
105
- const doc = el.ownerDocument;
106
- const segments = [];
107
- let node = el;
108
- while (node && node !== doc.documentElement) {
109
- const id = node.getAttribute("id");
110
- if (id && SAFE_ID.test(id) && doc.querySelectorAll(`#${id}`).length === 1) {
111
- segments.unshift(`#${id}`);
112
- return segments.join(" > ");
113
- }
114
- const byAttr = attrSegment(node, doc);
115
- if (byAttr) {
116
- segments.unshift(byAttr);
117
- return segments.join(" > ");
58
+ /** Ordered as the bar and fan lay them out. */
59
+ const ACTIONS = [
60
+ {
61
+ id: "pin",
62
+ label: "Drop a pin",
63
+ key: "p",
64
+ glyph: PIN_GLYPH,
65
+ bar: { text: "PIN" },
66
+ fan: {}
67
+ },
68
+ {
69
+ id: "inbox",
70
+ label: "Open inbox",
71
+ key: "i",
72
+ glyph: INBOX_GLYPH,
73
+ bar: { count: true },
74
+ fan: {}
75
+ },
76
+ {
77
+ id: "copy",
78
+ label: "Copy open pins",
79
+ key: "c",
80
+ glyph: COPY_GLYPH,
81
+ bar: {},
82
+ fan: {}
83
+ },
84
+ {
85
+ id: "theme",
86
+ label: "Toggle theme",
87
+ key: "d",
88
+ glyph: THEME_GLYPH,
89
+ bar: {},
90
+ fan: {}
91
+ },
92
+ {
93
+ id: "hide",
94
+ label: "Hide / show pins",
95
+ key: "h",
96
+ glyph: EYE_OFF_GLYPH,
97
+ bar: {},
98
+ fan: { label: "Hide pins" }
99
+ },
100
+ {
101
+ id: "capture",
102
+ label: "Tab capture for screenshots (Chrome asks once)",
103
+ key: "s",
104
+ glyph: CAMERA_GLYPH,
105
+ bar: {}
106
+ },
107
+ {
108
+ id: "help",
109
+ label: "Shortcuts",
110
+ key: "?",
111
+ bar: {},
112
+ help: false
113
+ },
114
+ {
115
+ id: "minimize",
116
+ label: "Minimize toolbar",
117
+ key: "m",
118
+ glyph: MIN_GLYPH,
119
+ bar: { ariaLabel: "Minimize toolbar" },
120
+ fan: {
121
+ act: "expand",
122
+ label: "Expand"
118
123
  }
119
- segments.unshift(nthSegment(node));
120
- node = node.parentElement;
124
+ },
125
+ {
126
+ id: "resolve",
127
+ label: "Mark pin resolved",
128
+ key: "r"
129
+ },
130
+ {
131
+ id: "escape",
132
+ label: "Cancel",
133
+ key: "escape",
134
+ keyLabel: "ESC"
121
135
  }
122
- return segments.join(" > ");
123
- }
124
- //#endregion
125
- //#region src/capture.ts
126
- /** Curated computed-style subset — enough to reconstruct layout intent, tiny on the wire. */
127
- const STYLE_KEYS = [
128
- "display",
129
- "position",
130
- "font-size",
131
- "color",
132
- "background-color",
133
- "margin",
134
- "padding",
135
- "overflow"
136
136
  ];
137
- const NEARBY_TEXT_MAX = 160;
138
- function styleSubset(win, el) {
139
- let cs;
140
- try {
141
- cs = win.getComputedStyle(el);
142
- } catch {
143
- return;
144
- }
145
- const out = {};
146
- for (const key of STYLE_KEYS) {
147
- const value = cs.getPropertyValue(key);
148
- if (value !== "") out[key] = value;
149
- }
150
- return Object.keys(out).length > 0 ? out : void 0;
151
- }
152
- function ariaMap(el) {
153
- const out = {};
154
- for (const name of el.getAttributeNames()) if (name.startsWith("aria-")) out[name] = el.getAttribute(name) ?? "";
155
- return Object.keys(out).length > 0 ? out : void 0;
156
- }
157
- function nearbyText(el) {
158
- const text = (el.textContent ?? "").replace(/\s+/g, " ").trim();
159
- return text === "" ? void 0 : text.slice(0, NEARBY_TEXT_MAX);
160
- }
161
- /** The user's selection, only when it intersects the captured element. */
162
- function selectedText(win, el) {
163
- try {
164
- const sel = win.getSelection?.();
165
- if (!sel || sel.isCollapsed || sel.rangeCount === 0) return void 0;
166
- if (!sel.getRangeAt(0).intersectsNode(el)) return void 0;
167
- const text = sel.toString().trim();
168
- return text === "" ? void 0 : text;
169
- } catch {
170
- return;
171
- }
137
+ function keyLabelOf(a) {
138
+ return a.keyLabel ?? (a.key ?? "").toUpperCase();
172
139
  }
173
- /** `fixed` detected via ancestry: any ancestor with computed position: fixed. */
174
- function isFixed(win, el) {
175
- for (let node = el; node !== null; node = node.parentElement) try {
176
- if (win.getComputedStyle(node).position === "fixed") return true;
177
- } catch {
178
- return false;
179
- }
180
- return false;
140
+ /** "Drop a pin (P)" the bar's hover title. */
141
+ function titleOf(a) {
142
+ return a.key === void 0 ? a.label : `${a.label} (${keyLabelOf(a)})`;
181
143
  }
182
- /** Beyond this an element is not a thing you pinned, it is a region. Too many to rewrite as a set. */
183
- const MAX_RUNS = 40;
184
- const MAX_RUN_LENGTH = 200;
185
- /** Text that is not content: a script body or a stylesheet is not something to rewrite. */
186
- const NON_CONTENT = /* @__PURE__ */ new Set([
187
- "SCRIPT",
188
- "STYLE",
189
- "NOSCRIPT",
190
- "TEMPLATE"
144
+ /** Lower-cased `KeyboardEvent.key` action. `?` keeps its case: it only exists shifted. */
145
+ const ACTION_BY_KEY = new Map(ACTIONS.filter((a) => a.key !== void 0).map((a) => [a.key, a.id]));
146
+ //#endregion
147
+ //#region src/keys.ts
148
+ /** Hosts mark a subtree the toolbar must ignore keys from (custom editors, games). */
149
+ const IGNORE_KEYS_ATTR = "data-pinbox-ignore-keys";
150
+ const TEXT_TAGS = /* @__PURE__ */ new Set([
151
+ "TEXTAREA",
152
+ "INPUT",
153
+ "SELECT"
191
154
  ]);
155
+ const TEXT_ROLE_SELECTOR = [
156
+ "textbox",
157
+ "combobox",
158
+ "searchbox",
159
+ "spinbutton"
160
+ ].map((r) => `[role="${r}"]`).join(",");
192
161
  /**
193
- * The element's text, split the way the browser stores it: one entry per run of characters.
194
- *
195
- * This walks TEXT NODES, not elements, and that distinction is the whole point — it makes no
196
- * assumption about how a site is built. A heading is one run. A nav bar is one per link. A
197
- * paragraph with a bold word in the middle is three, in reading order, including the halves either
198
- * side of the bold. An earlier version keyed off "elements with no element children", which
199
- * quietly lost the "Hello " in `<p>Hello <b>world</b></p>` — text a person can obviously see and
200
- * would obviously expect to be able to change.
201
- *
202
- * `nearbyText` runs them all together, which is fine to read and useless to edit: it cannot tell
203
- * an agent that "work approach people contact" is four separate places. This can.
162
+ * Is the key going into a text control? `INPUT`/`TEXTAREA`/contentEditable, plus
163
+ * the things the old check missed: `<select>`, ARIA text roles on custom editors,
164
+ * and any ancestor flagged with `data-pinbox-ignore-keys`.
204
165
  */
205
- function textRuns(el) {
206
- const runs = [];
207
- const walk = (node) => {
208
- if (node.nodeType === 3) {
209
- const text = (node.nodeValue ?? "").trim();
210
- if (text.length > 0) runs.push(text.slice(0, MAX_RUN_LENGTH));
211
- return runs.length <= MAX_RUNS;
212
- }
213
- if (node.nodeType !== 1 || NON_CONTENT.has(node.tagName)) return true;
214
- for (const child of node.childNodes) if (!walk(child)) return false;
215
- return true;
216
- };
217
- if (!walk(el) || runs.length === 0) return void 0;
218
- return runs;
219
- }
220
- function buildContext(win, el) {
221
- const context = {};
222
- if (el.classList.length > 0) context.classes = [...el.classList];
223
- const styles = styleSubset(win, el);
224
- if (styles !== void 0) context.styles = styles;
225
- const aria = ariaMap(el);
226
- if (aria !== void 0) context.aria = aria;
227
- const nearby = nearbyText(el);
228
- if (nearby !== void 0) context.nearbyText = nearby;
229
- const selected = selectedText(win, el);
230
- if (selected !== void 0) context.selectedText = selected;
231
- const runs = textRuns(el);
232
- if (runs !== void 0) context.textRuns = runs;
233
- return Object.keys(context).length > 0 ? context : void 0;
166
+ function isTextEntry(target) {
167
+ const el = target;
168
+ if (!el || typeof el.tagName !== "string") return false;
169
+ if (TEXT_TAGS.has(el.tagName)) return true;
170
+ if (el.isContentEditable === true) return true;
171
+ if (el.closest?.(TEXT_ROLE_SELECTOR) != null) return true;
172
+ return el.closest?.(`[${IGNORE_KEYS_ATTR}]`) != null;
234
173
  }
235
- /** Fills PinInput.target/env from a chosen element (shapes come from the pin schema). */
236
- function captureTarget(el, opts) {
237
- const win = el.ownerDocument.defaultView;
238
- const r = el.getBoundingClientRect();
239
- const target = {
240
- url: win.location.href,
241
- selector: buildSelector(el),
242
- tag: el.tagName.toLowerCase(),
243
- rect: {
244
- x: r.left + win.scrollX,
245
- y: r.top + win.scrollY,
246
- width: r.width,
247
- height: r.height
248
- },
249
- fixed: isFixed(win, el)
250
- };
251
- if (opts?.anchor !== void 0) target.anchor = opts.anchor;
252
- if (opts?.at !== void 0 && r.width > 0 && r.height > 0) {
253
- const fx = (opts.at.x - (r.left + win.scrollX)) / r.width;
254
- const fy = (opts.at.y - (r.top + win.scrollY)) / r.height;
255
- if (fx >= 0 && fx <= 1 && fy >= 0 && fy <= 1) target.spot = {
256
- x: fx,
257
- y: fy
258
- };
259
- }
260
- const context = buildContext(win, el);
261
- if (context !== void 0) target.context = context;
262
- return {
263
- target,
264
- env: {
265
- viewport: {
266
- w: win.innerWidth,
267
- h: win.innerHeight,
268
- dpr: win.devicePixelRatio
269
- },
270
- browser: win.navigator.userAgent,
271
- os: win.navigator.platform || "unknown",
272
- colorScheme: win.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
273
- }
274
- };
174
+ /**
175
+ * Which action, if any, a keydown asks for.
176
+ *
177
+ * Modifier chords are never ours (⌘P is print, ⌃I is italics, ⌥ types
178
+ * characters); Shift is allowed because `?` needs it. Repeats and IME
179
+ * composition are ignored so a held key acts once and a composing keyboard is
180
+ * left alone.
181
+ */
182
+ function shortcutFor(e, mode = "all") {
183
+ if (mode === "off") return null;
184
+ if (e.metaKey || e.ctrlKey || e.altKey || e.repeat || e.isComposing) return null;
185
+ if (isTextEntry(e.composedPath()[0] ?? e.target)) return null;
186
+ const key = e.key === "?" ? "?" : e.key.toLowerCase();
187
+ const id = ACTION_BY_KEY.get(key);
188
+ if (id === void 0) return null;
189
+ if (mode === "escape-only" && id !== "escape") return null;
190
+ return id;
275
191
  }
276
192
  //#endregion
277
193
  //#region src/markdown.ts
@@ -319,18 +235,134 @@ var Pinbox = (function(exports) {
319
235
  return `${block(pin, thread)}\n`;
320
236
  }
321
237
  //#endregion
322
- //#region src/motion/spring.ts
323
- /** Bar puck morphs and the post-drag settle. */
324
- const MORPH_SPRING = {
325
- k: 300,
326
- c: 30
327
- };
328
- /** The morph surface chasing the pointer mid-drag. */
329
- const FOLLOW_SPRING = {
330
- k: 600,
331
- c: 38
332
- };
333
- /** Integration substep — small enough that MORPH/FOLLOW stay stable at any dt. */
238
+ //#region src/motion/drag.ts
239
+ /** Movement that begins a drag */
240
+ const DRAG_START = 8;
241
+ /** …but a release under this much TOTAL travel is still a tap. */
242
+ const TAP_MAX = 12;
243
+ /**
244
+ * Attach the drag rules to `el`. The press is read on `el`; move/up/cancel are read on its
245
+ * document while a hold is live, so the release cannot be lost to a failed capture. Pointer
246
+ * capture is still requested (it keeps moves flowing when the pointer leaves the window);
247
+ * `touch-action: none` on the element is the caller's job.
248
+ */
249
+ function attachDrag(el, on) {
250
+ const doc = el.ownerDocument;
251
+ const win = doc.defaultView;
252
+ let hold = null;
253
+ function listen(active) {
254
+ const method = active ? "addEventListener" : "removeEventListener";
255
+ doc[method]("pointermove", onPointerMove, true);
256
+ doc[method]("pointerup", onPointerUp, true);
257
+ doc[method]("pointercancel", onPointerUp, true);
258
+ win?.[method]("blur", onBlur);
259
+ }
260
+ function onPointerDown(e) {
261
+ if (e.button !== 0 || hold !== null) return;
262
+ const origin = on.origin();
263
+ if (origin === null) return;
264
+ try {
265
+ el.setPointerCapture(e.pointerId);
266
+ } catch {}
267
+ hold = {
268
+ pointerId: e.pointerId,
269
+ px: e.clientX,
270
+ py: e.clientY,
271
+ origin,
272
+ lx: e.clientX,
273
+ ly: e.clientY,
274
+ started: false
275
+ };
276
+ listen(true);
277
+ }
278
+ function onPointerMove(e) {
279
+ if (hold === null || e.pointerId !== hold.pointerId) return;
280
+ hold.lx = e.clientX;
281
+ hold.ly = e.clientY;
282
+ const dx = e.clientX - hold.px;
283
+ const dy = e.clientY - hold.py;
284
+ if (!hold.started) {
285
+ if (!on.canStart()) {
286
+ release();
287
+ return;
288
+ }
289
+ if (Math.hypot(dx, dy) < DRAG_START) return;
290
+ hold.started = true;
291
+ on.onStart(hold.origin);
292
+ }
293
+ on.onMove({
294
+ x: hold.origin.x + dx,
295
+ y: hold.origin.y + dy
296
+ });
297
+ }
298
+ function release() {
299
+ hold = null;
300
+ listen(false);
301
+ }
302
+ /** The window lost focus mid-hold: whatever the pointer does next, we will not see it. */
303
+ function onBlur() {
304
+ if (hold !== null) onPointerUp();
305
+ }
306
+ function onPointerUp(e) {
307
+ if (hold === null || e !== void 0 && e.pointerId !== hold.pointerId) return;
308
+ const h = hold;
309
+ release();
310
+ const total = Math.hypot(h.lx - h.px, h.ly - h.py);
311
+ on.onEnd({
312
+ dragged: h.started && total >= TAP_MAX,
313
+ started: h.started,
314
+ origin: h.origin,
315
+ target: {
316
+ x: h.origin.x + (h.lx - h.px),
317
+ y: h.origin.y + (h.ly - h.py)
318
+ }
319
+ });
320
+ }
321
+ el.addEventListener("pointerdown", onPointerDown);
322
+ return {
323
+ cancel: release,
324
+ destroy() {
325
+ release();
326
+ el.removeEventListener("pointerdown", onPointerDown);
327
+ }
328
+ };
329
+ }
330
+ /** Keep a top-left corner of a `size`-square (or w×h box) `margin` px inside the viewport. */
331
+ function clampToViewport(p, win, box, margin) {
332
+ const clamp = (v, lo, hi) => Math.min(Math.max(v, lo), hi);
333
+ return {
334
+ x: clamp(p.x, margin, Math.max(margin, win.innerWidth - margin - box.w)),
335
+ y: clamp(p.y, margin, Math.max(margin, win.innerHeight - margin - box.h))
336
+ };
337
+ }
338
+ /** A persisted `{x,y}` (mirror convention: reads never throw upward); null when absent or malformed. */
339
+ function readPoint(storage, key) {
340
+ try {
341
+ const raw = storage?.getItem(key);
342
+ if (raw == null) return null;
343
+ const parsed = JSON.parse(raw);
344
+ if (typeof parsed.x !== "number" || typeof parsed.y !== "number") return null;
345
+ return {
346
+ x: parsed.x,
347
+ y: parsed.y
348
+ };
349
+ } catch {
350
+ return null;
351
+ }
352
+ }
353
+ //#endregion
354
+ //#region src/motion/spring.ts
355
+ /** Bar ⇄ puck morphs and the post-drag settle. */
356
+ const MORPH_SPRING = {
357
+ k: 300,
358
+ c: 30
359
+ };
360
+ /** The morph surface chasing the pointer mid-drag. */
361
+ const FOLLOW_SPRING = {
362
+ k: 600,
363
+ c: 38
364
+ };
365
+ /** Integration substep — small enough that MORPH/FOLLOW stay stable at any dt. */
334
366
  const SUBSTEP = 1 / 240;
335
367
  /** A property this close to target, moving this slowly, counts as settled. */
336
368
  const SETTLE = .5;
@@ -384,11 +416,7 @@ var Pinbox = (function(exports) {
384
416
  //#endregion
385
417
  //#region src/minimize.ts
386
418
  const PUCK = 48;
387
- const MARGIN = 16;
388
- /** Movement that begins a drag… */
389
- const DRAG_START = 8;
390
- /** …but a release under this much TOTAL travel is still a tap. */
391
- const TAP_MAX = 12;
419
+ const MARGIN$1 = 16;
392
420
  /** Spring hold while the surface swap crossfades (ms). */
393
421
  const HOLD_MINIMIZE = 90;
394
422
  const HOLD_RESTORE = 70;
@@ -413,7 +441,6 @@ var Pinbox = (function(exports) {
413
441
  let mode = "bar";
414
442
  let puckPos = null;
415
443
  let dock = loadDock();
416
- let pdown = null;
417
444
  let keyboardToggle = false;
418
445
  let raf = 0;
419
446
  let last = 0;
@@ -423,18 +450,7 @@ var Pinbox = (function(exports) {
423
450
  let fanOpen = false;
424
451
  let fanTimer = 0;
425
452
  function loadDock() {
426
- try {
427
- const raw = host.storage?.getItem(`${host.storagePrefix}:dock`);
428
- if (raw == null) return null;
429
- const parsed = JSON.parse(raw);
430
- if (typeof parsed.x !== "number" || typeof parsed.y !== "number") return null;
431
- return {
432
- x: parsed.x,
433
- y: parsed.y
434
- };
435
- } catch {
436
- return null;
437
- }
453
+ return readPoint(host.storage, `${host.storagePrefix}:dock`);
438
454
  }
439
455
  function persist() {
440
456
  try {
@@ -450,12 +466,11 @@ var Pinbox = (function(exports) {
450
466
  return host.initialMinimized;
451
467
  }
452
468
  }
453
- const clamp = (v, lo, hi) => Math.min(Math.max(v, lo), hi);
454
469
  function clampPos(p) {
455
- return {
456
- x: clamp(p.x, MARGIN, win.innerWidth - MARGIN - PUCK),
457
- y: clamp(p.y, MARGIN, win.innerHeight - MARGIN - PUCK)
458
- };
470
+ return clampToViewport(p, win, {
471
+ w: PUCK,
472
+ h: PUCK
473
+ }, MARGIN$1);
459
474
  }
460
475
  function defaultDock() {
461
476
  const r = bar.getBoundingClientRect();
@@ -478,6 +493,7 @@ var Pinbox = (function(exports) {
478
493
  ui.surface.style.borderRadius = `${m.r}px`;
479
494
  ui.surface.style.transform = `translate(${m.x}px, ${m.y}px)`;
480
495
  ui.carrier.style.transform = `translate(${m.x + m.w / 2 - PUCK / 2}px, ${m.y + m.h / 2 - PUCK / 2}px)`;
496
+ if (mode === "settle") placePuck(m.x, m.y);
481
497
  const iconOn = mode === "drag" || mode === "settle" || (mode === "toPuck" || mode === "toBar") && m.w < CARRIER_MAX_W;
482
498
  ui.carrier.classList.toggle("show", iconOn);
483
499
  }
@@ -532,7 +548,7 @@ var Pinbox = (function(exports) {
532
548
  }
533
549
  function minimize(keyboard = false) {
534
550
  if (mode !== "bar") return;
535
- pdown = null;
551
+ drag.cancel();
536
552
  keyboardToggle = keyboard;
537
553
  const r = bar.getBoundingClientRect();
538
554
  const d = clampPos(dock ?? defaultDock());
@@ -570,7 +586,7 @@ var Pinbox = (function(exports) {
570
586
  function restore(keyboard = false) {
571
587
  if (mode !== "puck" || puckPos === null) return;
572
588
  closeFan();
573
- pdown = null;
589
+ drag.cancel();
574
590
  keyboardToggle = keyboard;
575
591
  dock = { ...puckPos };
576
592
  ui.puck.classList.add("pb-ghost");
@@ -652,42 +668,25 @@ var Pinbox = (function(exports) {
652
668
  if (path.includes(ui.fan) || path.includes(ui.puck)) return;
653
669
  closeFan();
654
670
  }
655
- function onPointerDown(e) {
656
- if (mode !== "puck" || e.button !== 0) return;
657
- try {
658
- ui.puck.setPointerCapture(e.pointerId);
659
- } catch {}
660
- if (puckPos === null) return;
661
- pdown = {
662
- px: e.clientX,
663
- py: e.clientY,
664
- ox: puckPos.x,
665
- oy: puckPos.y,
666
- lx: e.clientX,
667
- ly: e.clientY,
668
- moved: false
669
- };
670
- }
671
- function onPointerMove(e) {
672
- if (pdown === null) return;
673
- pdown.lx = e.clientX;
674
- pdown.ly = e.clientY;
675
- const dx = e.clientX - pdown.px;
676
- const dy = e.clientY - pdown.py;
677
- if (!pdown.moved) {
678
- if (mode !== "puck") {
679
- pdown = null;
680
- return;
681
- }
682
- if (Math.hypot(dx, dy) < DRAG_START) return;
683
- pdown.moved = true;
671
+ /** Grabbable at rest and while settling — mid-flight, the spring's current spot is the origin. */
672
+ const grabbable = () => mode === "puck" || mode === "settle";
673
+ const drag = attachDrag(ui.puck, {
674
+ origin: () => {
675
+ if (mode === "settle") return {
676
+ x: main.cur.x,
677
+ y: main.cur.y
678
+ };
679
+ return mode === "puck" ? puckPos : null;
680
+ },
681
+ canStart: grabbable,
682
+ onStart(origin) {
684
683
  closeFan();
685
684
  mode = "drag";
686
685
  if (!host.reduced) {
687
686
  ui.puck.classList.add("pb-ghost");
688
687
  main.snap({
689
- x: pdown.ox,
690
- y: pdown.oy,
688
+ x: origin.x,
689
+ y: origin.y,
691
690
  w: PUCK,
692
691
  h: PUCK,
693
692
  r: PUCK / 2
@@ -695,140 +694,709 @@ var Pinbox = (function(exports) {
695
694
  render();
696
695
  showMorph();
697
696
  }
698
- }
699
- const next = {
700
- x: pdown.ox + dx,
701
- y: pdown.oy + dy
702
- };
703
- if (host.reduced) {
704
- const p = clampPos(next);
705
- placePuck(p.x, p.y);
706
- } else {
697
+ },
698
+ onMove(next) {
699
+ if (host.reduced) {
700
+ const p = clampPos(next);
701
+ placePuck(p.x, p.y);
702
+ } else {
703
+ main.to({
704
+ ...next,
705
+ w: PUCK,
706
+ h: PUCK,
707
+ r: PUCK / 2
708
+ }, FOLLOW_SPRING);
709
+ ensureLoop();
710
+ }
711
+ },
712
+ onEnd({ dragged, started, origin }) {
713
+ if (!dragged) {
714
+ if (started && mode === "drag") {
715
+ main.snap({
716
+ x: origin.x,
717
+ y: origin.y,
718
+ w: PUCK,
719
+ h: PUCK,
720
+ r: PUCK / 2
721
+ });
722
+ placePuck(origin.x, origin.y);
723
+ ui.puck.classList.remove("pb-ghost");
724
+ hideMorph();
725
+ mode = "puck";
726
+ }
727
+ if (!closeFan()) openFan();
728
+ return;
729
+ }
730
+ if (host.reduced) {
731
+ if (puckPos) {
732
+ const p = clampPos(puckPos);
733
+ placePuck(p.x, p.y);
734
+ dock = { ...p };
735
+ }
736
+ mode = "puck";
737
+ persist();
738
+ return;
739
+ }
740
+ const p = clampPos({
741
+ x: main.tgt.x,
742
+ y: main.tgt.y
743
+ });
744
+ placePuck(main.cur.x, main.cur.y);
745
+ ui.puck.classList.remove("pb-ghost");
746
+ hideMorph();
707
747
  main.to({
708
- ...next,
748
+ ...p,
709
749
  w: PUCK,
710
750
  h: PUCK,
711
751
  r: PUCK / 2
712
- }, FOLLOW_SPRING);
752
+ }, MORPH_SPRING);
753
+ mode = "settle";
713
754
  ensureLoop();
714
755
  }
756
+ });
757
+ /** Keyboard/AT activation is a synthesized click (detail 0) with no pointer events. */
758
+ function onClick(e) {
759
+ if (e.detail !== 0) return;
760
+ if (!closeFan()) openFan();
715
761
  }
716
- function onPointerUp() {
717
- if (pdown === null) return;
718
- const total = Math.hypot(pdown.lx - pdown.px, pdown.ly - pdown.py);
719
- const wasDrag = pdown.moved && total >= TAP_MAX;
720
- const origin = {
721
- x: pdown.ox,
722
- y: pdown.oy
723
- };
724
- const startedDrag = pdown.moved;
725
- pdown = null;
726
- if (!wasDrag) {
727
- if (startedDrag && mode === "drag") {
728
- main.snap({
729
- x: origin.x,
730
- y: origin.y,
731
- w: PUCK,
732
- h: PUCK,
733
- r: PUCK / 2
734
- });
735
- placePuck(origin.x, origin.y);
762
+ function onResize() {
763
+ closeFan();
764
+ if (mode === "puck" && puckPos !== null) {
765
+ const p = clampPos(puckPos);
766
+ placePuck(p.x, p.y);
767
+ }
768
+ if (dock !== null) dock = clampPos(dock);
769
+ }
770
+ ui.puck.addEventListener("click", onClick);
771
+ ui.fan.addEventListener("click", onFanClick);
772
+ win.document.addEventListener("pointerdown", onDocPointerDown);
773
+ win.addEventListener("resize", onResize);
774
+ return {
775
+ minimized: () => mode !== "bar",
776
+ mode: () => mode,
777
+ minimize,
778
+ restore,
779
+ closeFan,
780
+ applyInitial() {
781
+ if (!loadMinimized()) return;
782
+ const apply = () => {
783
+ if (mode !== "bar") return;
784
+ const d = clampPos(dock ?? defaultDock());
785
+ bar.classList.add("pb-ghost");
786
+ placePuck(d.x, d.y);
736
787
  ui.puck.classList.remove("pb-ghost");
737
- hideMorph();
738
788
  mode = "puck";
739
- }
740
- if (!closeFan()) openFan();
741
- return;
789
+ host.onSettled(true, false);
790
+ };
791
+ if (host.reduced) apply();
792
+ else win.requestAnimationFrame(apply);
793
+ },
794
+ destroy() {
795
+ drag.destroy();
796
+ ui.puck.removeEventListener("click", onClick);
797
+ ui.fan.removeEventListener("click", onFanClick);
798
+ win.document.removeEventListener("pointerdown", onDocPointerDown);
799
+ win.removeEventListener("resize", onResize);
800
+ if (raf !== 0) win.cancelAnimationFrame(raf);
801
+ raf = 0;
802
+ win.clearTimeout(holdTimer);
803
+ win.clearTimeout(fadeTimer);
804
+ win.clearTimeout(hideTimer);
805
+ win.clearTimeout(fanTimer);
806
+ ui.fan.hidden = true;
807
+ ui.fan.classList.remove("on");
808
+ fanOpen = false;
742
809
  }
743
- if (host.reduced) {
744
- if (puckPos) {
745
- const p = clampPos(puckPos);
746
- placePuck(p.x, p.y);
747
- dock = { ...p };
748
- }
749
- mode = "puck";
750
- persist();
751
- return;
810
+ };
811
+ }
812
+ //#endregion
813
+ //#region src/targeting/dom.ts
814
+ /**
815
+ * Deepest element under (clientX, clientY) that the caller does not ignore, or null when there is
816
+ * nothing there but page chrome (html/body).
817
+ *
818
+ * Looks THROUGH our own overlay rather than giving up at it. The single-element form could not:
819
+ * the drag-aim grip sits exactly on the point being aimed at, so it is always the topmost thing
820
+ * under the crosshair, and every probe came back "nothing" the moment touch aiming existed.
821
+ */
822
+ function hitTest(doc, x, y, ignore) {
823
+ const stack = doc.elementsFromPoint?.(x, y) ?? [doc.elementFromPoint(x, y)];
824
+ for (const el of stack) {
825
+ if (!el || el === doc.body || el === doc.documentElement) return null;
826
+ if (!ignore(el)) return el;
827
+ }
828
+ return null;
829
+ }
830
+ /** CLASS-or-TAG display name with a sibling index when needed (prototype nodeName). */
831
+ function nodeName(el) {
832
+ const key = el.classList[0];
833
+ let name = (key ?? el.tagName).toUpperCase();
834
+ const parent = el.parentElement;
835
+ if (parent) {
836
+ const sibs = [...parent.children].filter((c) => c.classList[0] === key && c.tagName === el.tagName);
837
+ if (sibs.length > 1) name += ` ${sibs.indexOf(el) + 1}`;
838
+ }
839
+ return name;
840
+ }
841
+ /**
842
+ * Human label for a target: an explicit data-pb-el annotation wins; otherwise a
843
+ * CLASS/TAG ancestry chain of at most 3 parts joined with ›, terminating early
844
+ * at the first annotated ancestor.
845
+ */
846
+ function targetLabel(el) {
847
+ const own = el.getAttribute("data-pb-el");
848
+ if (own) return own;
849
+ const parts = [nodeName(el)];
850
+ const body = el.ownerDocument.body;
851
+ let node = el.parentElement;
852
+ while (node && node !== body && parts.length < 3) {
853
+ const anchor = node.getAttribute("data-pb-el");
854
+ if (anchor) {
855
+ parts.unshift(anchor);
856
+ break;
752
857
  }
753
- const p = clampPos({
754
- x: main.tgt.x,
755
- y: main.tgt.y
756
- });
757
- main.to({
758
- ...p,
759
- w: PUCK,
760
- h: PUCK,
761
- r: PUCK / 2
762
- }, MORPH_SPRING);
763
- mode = "settle";
764
- ensureLoop();
858
+ if (node.classList[0]) parts.unshift(nodeName(node));
859
+ node = node.parentElement;
765
860
  }
766
- /** Keyboard/AT activation is a synthesized click (detail 0) with no pointer events. */
767
- function onClick(e) {
768
- if (e.detail !== 0) return;
769
- if (!closeFan()) openFan();
861
+ return parts.join(" ");
862
+ }
863
+ const SAFE_ID = /^[A-Za-z][\w-]*$/;
864
+ /** Data attributes trusted as stable hooks, in priority order. */
865
+ const STABLE_DATA_ATTRS = [
866
+ "data-pb-anchor",
867
+ "data-pb-el",
868
+ "data-testid"
869
+ ];
870
+ function attrSegment(el, doc) {
871
+ for (const attr of STABLE_DATA_ATTRS) {
872
+ const value = el.getAttribute(attr);
873
+ if (value === null || value.includes("\"") || value.includes("\\")) continue;
874
+ const selector = `${el.tagName.toLowerCase()}[${attr}="${value}"]`;
875
+ if (doc.querySelectorAll(selector).length === 1) return selector;
876
+ }
877
+ return null;
878
+ }
879
+ function nthSegment(el) {
880
+ const tag = el.tagName.toLowerCase();
881
+ const parent = el.parentElement;
882
+ if (!parent) return tag;
883
+ const sameTag = [...parent.children].filter((c) => c.tagName === el.tagName);
884
+ return sameTag.length > 1 ? `${tag}:nth-of-type(${sameTag.indexOf(el) + 1})` : tag;
885
+ }
886
+ /**
887
+ * Stable CSS path for an element: ids > stable data attributes > an
888
+ * nth-of-type chain. Guaranteed round-trip: querySelector(buildSelector(el)) === el.
889
+ */
890
+ function buildSelector(el) {
891
+ const doc = el.ownerDocument;
892
+ const segments = [];
893
+ let node = el;
894
+ while (node && node !== doc.documentElement) {
895
+ const id = node.getAttribute("id");
896
+ if (id && SAFE_ID.test(id) && doc.querySelectorAll(`#${id}`).length === 1) {
897
+ segments.unshift(`#${id}`);
898
+ return segments.join(" > ");
899
+ }
900
+ const byAttr = attrSegment(node, doc);
901
+ if (byAttr) {
902
+ segments.unshift(byAttr);
903
+ return segments.join(" > ");
904
+ }
905
+ segments.unshift(nthSegment(node));
906
+ node = node.parentElement;
907
+ }
908
+ return segments.join(" > ");
909
+ }
910
+ //#endregion
911
+ //#region src/capture.ts
912
+ /** Curated computed-style subset — enough to reconstruct layout intent, tiny on the wire. */
913
+ const STYLE_KEYS = [
914
+ "display",
915
+ "position",
916
+ "font-size",
917
+ "color",
918
+ "background-color",
919
+ "margin",
920
+ "padding",
921
+ "overflow"
922
+ ];
923
+ const NEARBY_TEXT_MAX = 160;
924
+ function styleSubset(win, el) {
925
+ let cs;
926
+ try {
927
+ cs = win.getComputedStyle(el);
928
+ } catch {
929
+ return;
930
+ }
931
+ const out = {};
932
+ for (const key of STYLE_KEYS) {
933
+ const value = cs.getPropertyValue(key);
934
+ if (value !== "") out[key] = value;
935
+ }
936
+ return Object.keys(out).length > 0 ? out : void 0;
937
+ }
938
+ function ariaMap(el) {
939
+ const out = {};
940
+ for (const name of el.getAttributeNames()) if (name.startsWith("aria-")) out[name] = el.getAttribute(name) ?? "";
941
+ return Object.keys(out).length > 0 ? out : void 0;
942
+ }
943
+ function nearbyText(el) {
944
+ const text = (el.textContent ?? "").replace(/\s+/g, " ").trim();
945
+ return text === "" ? void 0 : text.slice(0, NEARBY_TEXT_MAX);
946
+ }
947
+ /** The user's selection, only when it intersects the captured element. */
948
+ function selectedText(win, el) {
949
+ try {
950
+ const sel = win.getSelection?.();
951
+ if (!sel || sel.isCollapsed || sel.rangeCount === 0) return void 0;
952
+ if (!sel.getRangeAt(0).intersectsNode(el)) return void 0;
953
+ const text = sel.toString().trim();
954
+ return text === "" ? void 0 : text;
955
+ } catch {
956
+ return;
957
+ }
958
+ }
959
+ /** `fixed` detected via ancestry: any ancestor with computed position: fixed. */
960
+ function isFixed(win, el) {
961
+ for (let node = el; node !== null; node = node.parentElement) try {
962
+ if (win.getComputedStyle(node).position === "fixed") return true;
963
+ } catch {
964
+ return false;
965
+ }
966
+ return false;
967
+ }
968
+ /** Beyond this an element is not a thing you pinned, it is a region. Too many to rewrite as a set. */
969
+ const MAX_RUNS = 40;
970
+ const MAX_RUN_LENGTH = 200;
971
+ /** Text that is not content: a script body or a stylesheet is not something to rewrite. */
972
+ const NON_CONTENT = /* @__PURE__ */ new Set([
973
+ "SCRIPT",
974
+ "STYLE",
975
+ "NOSCRIPT",
976
+ "TEMPLATE"
977
+ ]);
978
+ /**
979
+ * The element's text, split the way the browser stores it: one entry per run of characters.
980
+ *
981
+ * This walks TEXT NODES, not elements, and that distinction is the whole point — it makes no
982
+ * assumption about how a site is built. A heading is one run. A nav bar is one per link. A
983
+ * paragraph with a bold word in the middle is three, in reading order, including the halves either
984
+ * side of the bold. An earlier version keyed off "elements with no element children", which
985
+ * quietly lost the "Hello " in `<p>Hello <b>world</b></p>` — text a person can obviously see and
986
+ * would obviously expect to be able to change.
987
+ *
988
+ * `nearbyText` runs them all together, which is fine to read and useless to edit: it cannot tell
989
+ * an agent that "work approach people contact" is four separate places. This can.
990
+ */
991
+ function textRuns(el) {
992
+ const runs = [];
993
+ const walk = (node) => {
994
+ if (node.nodeType === 3) {
995
+ const text = (node.nodeValue ?? "").trim();
996
+ if (text.length > 0) runs.push(text.slice(0, MAX_RUN_LENGTH));
997
+ return runs.length <= MAX_RUNS;
998
+ }
999
+ if (node.nodeType !== 1 || NON_CONTENT.has(node.tagName)) return true;
1000
+ for (const child of node.childNodes) if (!walk(child)) return false;
1001
+ return true;
1002
+ };
1003
+ if (!walk(el) || runs.length === 0) return void 0;
1004
+ return runs;
1005
+ }
1006
+ function buildContext(win, el) {
1007
+ const context = {};
1008
+ if (el.classList.length > 0) context.classes = [...el.classList];
1009
+ const styles = styleSubset(win, el);
1010
+ if (styles !== void 0) context.styles = styles;
1011
+ const aria = ariaMap(el);
1012
+ if (aria !== void 0) context.aria = aria;
1013
+ const nearby = nearbyText(el);
1014
+ if (nearby !== void 0) context.nearbyText = nearby;
1015
+ const selected = selectedText(win, el);
1016
+ if (selected !== void 0) context.selectedText = selected;
1017
+ const runs = textRuns(el);
1018
+ if (runs !== void 0) context.textRuns = runs;
1019
+ return Object.keys(context).length > 0 ? context : void 0;
1020
+ }
1021
+ /** Fills PinInput.target/env from a chosen element (shapes come from the pin schema). */
1022
+ function captureTarget(el, opts) {
1023
+ const win = el.ownerDocument.defaultView;
1024
+ const r = el.getBoundingClientRect();
1025
+ const target = {
1026
+ url: win.location.href,
1027
+ selector: buildSelector(el),
1028
+ tag: el.tagName.toLowerCase(),
1029
+ rect: {
1030
+ x: r.left + win.scrollX,
1031
+ y: r.top + win.scrollY,
1032
+ width: r.width,
1033
+ height: r.height
1034
+ },
1035
+ fixed: isFixed(win, el)
1036
+ };
1037
+ if (opts?.anchor !== void 0) target.anchor = opts.anchor;
1038
+ if (opts?.at !== void 0 && r.width > 0 && r.height > 0) {
1039
+ const fx = (opts.at.x - (r.left + win.scrollX)) / r.width;
1040
+ const fy = (opts.at.y - (r.top + win.scrollY)) / r.height;
1041
+ if (fx >= 0 && fx <= 1 && fy >= 0 && fy <= 1) target.spot = {
1042
+ x: fx,
1043
+ y: fy
1044
+ };
1045
+ }
1046
+ const context = buildContext(win, el);
1047
+ if (context !== void 0) target.context = context;
1048
+ return {
1049
+ target,
1050
+ env: {
1051
+ viewport: {
1052
+ w: win.innerWidth,
1053
+ h: win.innerHeight,
1054
+ dpr: win.devicePixelRatio
1055
+ },
1056
+ browser: win.navigator.userAgent,
1057
+ os: win.navigator.platform || "unknown",
1058
+ colorScheme: win.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
1059
+ }
1060
+ };
1061
+ }
1062
+ //#endregion
1063
+ //#region src/ui/aim.ts
1064
+ /**
1065
+ * True when aiming has to be done by dragging rather than by pointing.
1066
+ *
1067
+ * Two independent reasons, either of which is sufficient. A coarse pointer means there is no
1068
+ * hover to follow at all — the mouse crosshair cannot work, whatever the screen size. The width
1069
+ * check is the design's own rule (720px) and catches the case a media query cannot: a device that
1070
+ * reports a fine pointer but is being used at phone width.
1071
+ */
1072
+ function needsDragAim(win) {
1073
+ return win.matchMedia?.("(pointer: coarse)").matches === true || win.innerWidth < 720;
1074
+ }
1075
+ /**
1076
+ * Where the reticle starts.
1077
+ *
1078
+ * Slightly above centre: the confirm bar owns the bottom of the screen, and a reticle that opens
1079
+ * underneath your own thumb is one you have to move before you can even see it.
1080
+ */
1081
+ function startPoint(win) {
1082
+ return {
1083
+ x: win.innerWidth / 2,
1084
+ y: win.innerHeight * .42
1085
+ };
1086
+ }
1087
+ 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>";
1088
+ function createAim(doc, handlers) {
1089
+ const win = doc.defaultView;
1090
+ const root = doc.createElement("div");
1091
+ root.className = "pb-aim";
1092
+ root.innerHTML = MARKUP;
1093
+ const h = root.querySelector(".h");
1094
+ const v = root.querySelector(".v");
1095
+ const grip = root.querySelector(".grip");
1096
+ const label = root.querySelector(".lab");
1097
+ const point = {
1098
+ x: 0,
1099
+ y: 0
1100
+ };
1101
+ /** Grab offset, so the reticle does not jump to your fingertip when you take hold of it. */
1102
+ let grab = null;
1103
+ function put(x, y) {
1104
+ point.x = Math.max(0, Math.min(win.innerWidth, x));
1105
+ point.y = Math.max(0, Math.min(win.innerHeight, y));
1106
+ h.style.top = `${point.y}px`;
1107
+ v.style.left = `${point.x}px`;
1108
+ grip.style.left = `${point.x}px`;
1109
+ grip.style.top = `${point.y}px`;
1110
+ }
1111
+ const onPointerMove = (e) => {
1112
+ if (grab === null) return;
1113
+ e.preventDefault();
1114
+ put(e.clientX + grab.dx, e.clientY + grab.dy);
1115
+ handlers.onAim(point.x, point.y);
1116
+ };
1117
+ const onPointerUp = () => {
1118
+ grab = null;
1119
+ };
1120
+ grip.addEventListener("pointerdown", (e) => {
1121
+ e.preventDefault();
1122
+ e.stopPropagation();
1123
+ grab = {
1124
+ dx: point.x - e.clientX,
1125
+ dy: point.y - e.clientY
1126
+ };
1127
+ grip.setPointerCapture?.(e.pointerId);
1128
+ });
1129
+ grip.addEventListener("keydown", (e) => {
1130
+ const step = e.shiftKey ? 20 : 2;
1131
+ const delta = {
1132
+ ArrowLeft: [-step, 0],
1133
+ ArrowRight: [step, 0],
1134
+ ArrowUp: [0, -step],
1135
+ ArrowDown: [0, step]
1136
+ }[e.key];
1137
+ if (!delta) return;
1138
+ e.preventDefault();
1139
+ put(point.x + delta[0], point.y + delta[1]);
1140
+ handlers.onAim(point.x, point.y);
1141
+ });
1142
+ root.addEventListener("click", (e) => {
1143
+ const action = e.target.closest?.("[data-aim]")?.getAttribute("data-aim");
1144
+ if (!action) return;
1145
+ e.preventDefault();
1146
+ e.stopPropagation();
1147
+ if (action === "confirm") handlers.onConfirm();
1148
+ else handlers.onCancel();
1149
+ });
1150
+ win.addEventListener("pointermove", onPointerMove, { passive: false });
1151
+ win.addEventListener("pointerup", onPointerUp);
1152
+ win.addEventListener("pointercancel", onPointerUp);
1153
+ return {
1154
+ root,
1155
+ point,
1156
+ show(x, y) {
1157
+ put(x, y);
1158
+ root.classList.add("on");
1159
+ },
1160
+ hide() {
1161
+ grab = null;
1162
+ root.classList.remove("on");
1163
+ },
1164
+ setLabel(text) {
1165
+ label.textContent = text;
1166
+ },
1167
+ destroy() {
1168
+ win.removeEventListener("pointermove", onPointerMove);
1169
+ win.removeEventListener("pointerup", onPointerUp);
1170
+ win.removeEventListener("pointercancel", onPointerUp);
1171
+ }
1172
+ };
1173
+ }
1174
+ //#endregion
1175
+ //#region src/ui/multimarks.ts
1176
+ const MARK_CLASS = "pb-multi-mark";
1177
+ /** Replace the mark set to mirror `targets`; entries with no rect draw nothing. */
1178
+ function renderMultiMarks(layer, targets, scroll = {
1179
+ x: 0,
1180
+ y: 0
1181
+ }) {
1182
+ for (const node of [...layer.querySelectorAll(`.${MARK_CLASS}`)]) node.remove();
1183
+ targets.forEach((target, i) => {
1184
+ const rect = target.rect;
1185
+ if (rect === void 0) return;
1186
+ const mark = layer.ownerDocument.createElement("div");
1187
+ mark.className = MARK_CLASS;
1188
+ mark.style.left = `${rect.x - scroll.x}px`;
1189
+ mark.style.top = `${rect.y - scroll.y}px`;
1190
+ mark.style.width = `${rect.width}px`;
1191
+ mark.style.height = `${rect.height}px`;
1192
+ mark.innerHTML = `<span>${i + 1}</span>`;
1193
+ layer.appendChild(mark);
1194
+ });
1195
+ }
1196
+ //#endregion
1197
+ //#region src/ui/reticle.ts
1198
+ function createReticle(doc) {
1199
+ const crosshair = doc.createElement("div");
1200
+ crosshair.className = "pb-reticle";
1201
+ crosshair.innerHTML = "<div class=\"h\"></div><div class=\"v\"></div><div class=\"box\"></div><div class=\"ro\"></div>";
1202
+ const h = crosshair.querySelector(".h");
1203
+ const v = crosshair.querySelector(".v");
1204
+ const box = crosshair.querySelector(".box");
1205
+ const readout = crosshair.querySelector(".ro");
1206
+ const outline = doc.createElement("div");
1207
+ outline.className = "pb-outline";
1208
+ outline.innerHTML = "<span class=\"lab\"></span>";
1209
+ const lab = outline.querySelector(".lab");
1210
+ function setOutlineRect(rect) {
1211
+ outline.style.left = `${rect.left - 5}px`;
1212
+ outline.style.top = `${rect.top - 5}px`;
1213
+ outline.style.width = `${rect.width + 10}px`;
1214
+ outline.style.height = `${rect.height + 10}px`;
1215
+ }
1216
+ return {
1217
+ crosshair,
1218
+ outline,
1219
+ move(pos) {
1220
+ h.style.top = `${pos.clientY}px`;
1221
+ v.style.left = `${pos.clientX}px`;
1222
+ box.style.left = `${pos.clientX}px`;
1223
+ box.style.top = `${pos.clientY}px`;
1224
+ readout.style.left = `${pos.clientX}px`;
1225
+ readout.style.top = `${pos.clientY}px`;
1226
+ readout.textContent = `${Math.round(pos.pageX)} × ${Math.round(pos.pageY)}`;
1227
+ },
1228
+ snap(rect, label) {
1229
+ if (!outline.classList.contains("on")) {
1230
+ outline.style.transition = "none";
1231
+ setOutlineRect(rect);
1232
+ outline.offsetWidth;
1233
+ outline.style.transition = "";
1234
+ } else setOutlineRect(rect);
1235
+ lab.textContent = label;
1236
+ outline.classList.add("on");
1237
+ },
1238
+ release() {
1239
+ outline.classList.remove("on");
1240
+ }
1241
+ };
1242
+ }
1243
+ //#endregion
1244
+ //#region src/placement.ts
1245
+ function createPlacement(deps) {
1246
+ const { win, host, store, pinsLayer } = deps;
1247
+ const doc = host.ownerDocument;
1248
+ const reticle = createReticle(doc);
1249
+ let aim = null;
1250
+ let hover = null;
1251
+ /** Shift+click accumulation while placing — extra loci for ONE pending pin. */
1252
+ let extraTargets = [];
1253
+ /** Pending viewport-refresh frame, 0 when none is queued. */
1254
+ let viewportFrame = 0;
1255
+ const placing = () => store.get().mode === "placing";
1256
+ /**
1257
+ * Work out what sits under a viewport point and highlight it. Shared by both ways of aiming —
1258
+ * following a mouse, and dragging the reticle — so the two can never disagree.
1259
+ */
1260
+ function probe(clientX, clientY) {
1261
+ const el = hitTest(doc, clientX, clientY, (hit) => hit === host);
1262
+ hover = el;
1263
+ if (el) reticle.snap(el.getBoundingClientRect(), targetLabel(el));
1264
+ else reticle.release();
1265
+ aim?.setLabel(el ? targetLabel(el) : "NOTHING UNDER THE PIN");
1266
+ }
1267
+ /**
1268
+ * Bring the drag-aim reticle up with placing mode, seeded mid-screen and already showing what
1269
+ * it is over — the first thing you see is a live target, not an empty crosshair.
1270
+ */
1271
+ function syncAim(on) {
1272
+ if (!aim) return;
1273
+ if (!on || !needsDragAim(win)) {
1274
+ aim.hide();
1275
+ return;
1276
+ }
1277
+ if (aim.root.classList.contains("on")) {
1278
+ if (aim.point.x <= win.innerWidth && aim.point.y <= win.innerHeight) return;
1279
+ aim.show(Math.min(aim.point.x, win.innerWidth), Math.min(aim.point.y, win.innerHeight));
1280
+ return;
1281
+ }
1282
+ const { x, y } = startPoint(win);
1283
+ aim.show(x, y);
1284
+ probe(x, y);
1285
+ }
1286
+ /** Commit the pin the drag-aim reticle is sitting on. */
1287
+ function confirmAim() {
1288
+ if (!aim) return;
1289
+ probe(aim.point.x, aim.point.y);
1290
+ const el = hover ?? doc.body;
1291
+ const at = {
1292
+ x: aim.point.x + win.scrollX,
1293
+ y: aim.point.y + win.scrollY
1294
+ };
1295
+ store.place({
1296
+ target: captureTarget(el, { at }),
1297
+ placedAt: at
1298
+ });
1299
+ reticle.release();
1300
+ }
1301
+ function drawMarks() {
1302
+ renderMultiMarks(pinsLayer, extraTargets, {
1303
+ x: win.scrollX,
1304
+ y: win.scrollY
1305
+ });
1306
+ }
1307
+ function clearExtraTargets() {
1308
+ if (extraTargets.length === 0) return;
1309
+ extraTargets = [];
1310
+ drawMarks();
1311
+ }
1312
+ /** Placement click: capture the hovered target (or body) into a client-only draft. */
1313
+ function placeDraft(e) {
1314
+ e.preventDefault();
1315
+ e.stopPropagation();
1316
+ const capture = captureTarget(hover ?? doc.body, { at: {
1317
+ x: e.pageX,
1318
+ y: e.pageY
1319
+ } });
1320
+ if (extraTargets.length > 0) capture.target.targets = extraTargets;
1321
+ clearExtraTargets();
1322
+ store.place({
1323
+ target: capture,
1324
+ placedAt: {
1325
+ x: e.pageX,
1326
+ y: e.pageY
1327
+ }
1328
+ });
1329
+ reticle.release();
770
1330
  }
771
- function onResize() {
772
- closeFan();
773
- if (mode === "puck" && puckPos !== null) {
774
- const p = clampPos(puckPos);
775
- placePuck(p.x, p.y);
776
- }
777
- if (dock !== null) dock = clampPos(dock);
1331
+ /** Shift+click while placing: capture WITHOUT committing; a numbered dashed outline is the receipt. */
1332
+ function accumulateTarget(e) {
1333
+ e.preventDefault();
1334
+ e.stopPropagation();
1335
+ extraTargets = [...extraTargets, captureTarget(hover ?? doc.body).target];
1336
+ drawMarks();
1337
+ }
1338
+ /**
1339
+ * Keep the drag-aim reticle honest while the viewport moves under it — one probe per frame,
1340
+ * not per event: momentum scrolling on a phone dispatches faster than frames.
1341
+ */
1342
+ const onViewportChange = () => {
1343
+ if (!placing() || viewportFrame !== 0) return;
1344
+ viewportFrame = win.requestAnimationFrame(() => {
1345
+ viewportFrame = 0;
1346
+ if (!placing()) return;
1347
+ syncAim(true);
1348
+ if (aim?.root.classList.contains("on") === true) probe(aim.point.x, aim.point.y);
1349
+ });
1350
+ };
1351
+ const onMouseMove = (e) => {
1352
+ if (!placing()) return;
1353
+ reticle.move(e);
1354
+ probe(e.clientX, e.clientY);
1355
+ };
1356
+ function mountAim(shadow) {
1357
+ shadow.querySelector(".pb-aim")?.remove();
1358
+ aim = createAim(doc, {
1359
+ onAim: probe,
1360
+ onConfirm: confirmAim,
1361
+ onCancel: deps.onCancel
1362
+ });
1363
+ shadow.appendChild(aim.root);
778
1364
  }
779
- ui.puck.addEventListener("pointerdown", onPointerDown);
780
- ui.puck.addEventListener("pointermove", onPointerMove);
781
- ui.puck.addEventListener("pointerup", onPointerUp);
782
- ui.puck.addEventListener("pointercancel", onPointerUp);
783
- ui.puck.addEventListener("click", onClick);
784
- ui.fan.addEventListener("click", onFanClick);
785
- win.document.addEventListener("pointerdown", onDocPointerDown);
786
- win.addEventListener("resize", onResize);
787
1365
  return {
788
- minimized: () => mode !== "bar",
789
- mode: () => mode,
790
- minimize,
791
- restore,
792
- closeFan,
793
- applyInitial() {
794
- if (!loadMinimized()) return;
795
- const apply = () => {
796
- if (mode !== "bar") return;
797
- const d = clampPos(dock ?? defaultDock());
798
- bar.classList.add("pb-ghost");
799
- placePuck(d.x, d.y);
800
- ui.puck.classList.remove("pb-ghost");
801
- mode = "puck";
802
- host.onSettled(true, false);
803
- };
804
- if (host.reduced) apply();
805
- else win.requestAnimationFrame(apply);
1366
+ outline: reticle.outline,
1367
+ crosshair: reticle.crosshair,
1368
+ connect(shadow) {
1369
+ if (aim === null) mountAim(shadow);
1370
+ doc.addEventListener("mousemove", onMouseMove);
1371
+ win.addEventListener("scroll", onViewportChange, { passive: true });
1372
+ win.addEventListener("resize", onViewportChange);
806
1373
  },
807
- destroy() {
808
- ui.puck.removeEventListener("pointerdown", onPointerDown);
809
- ui.puck.removeEventListener("pointermove", onPointerMove);
810
- ui.puck.removeEventListener("pointerup", onPointerUp);
811
- ui.puck.removeEventListener("pointercancel", onPointerUp);
812
- ui.puck.removeEventListener("click", onClick);
813
- ui.fan.removeEventListener("click", onFanClick);
814
- win.document.removeEventListener("pointerdown", onDocPointerDown);
815
- win.removeEventListener("resize", onResize);
816
- if (raf !== 0) win.cancelAnimationFrame(raf);
817
- raf = 0;
818
- win.clearTimeout(holdTimer);
819
- win.clearTimeout(fadeTimer);
820
- win.clearTimeout(hideTimer);
821
- win.clearTimeout(fanTimer);
822
- ui.fan.hidden = true;
823
- ui.fan.classList.remove("on");
824
- fanOpen = false;
1374
+ disconnect() {
1375
+ doc.removeEventListener("mousemove", onMouseMove);
1376
+ win.removeEventListener("scroll", onViewportChange);
1377
+ win.removeEventListener("resize", onViewportChange);
1378
+ if (viewportFrame !== 0) win.cancelAnimationFrame(viewportFrame);
1379
+ viewportFrame = 0;
1380
+ aim?.destroy();
1381
+ aim = null;
1382
+ },
1383
+ render(on) {
1384
+ if (!on) clearExtraTargets();
1385
+ if (!on) reticle.release();
1386
+ if (on && extraTargets.length > 0) drawMarks();
1387
+ syncAim(on);
1388
+ },
1389
+ handleClick(e) {
1390
+ if (needsDragAim(win)) return;
1391
+ if (e.shiftKey) accumulateTarget(e);
1392
+ else placeDraft(e);
825
1393
  }
826
1394
  };
827
1395
  }
828
1396
  //#endregion
829
1397
  //#region src/screenshot.ts
830
- const WEBP_QUALITY = .7;
831
- const PLACEHOLDER_MAX = 32;
1398
+ const WEBP_QUALITY$1 = .7;
1399
+ const PLACEHOLDER_MAX$1 = 32;
832
1400
  /** Element rect clamped to the viewport (CSS px); null when nothing is visible. */
833
1401
  function visibleCropRect(el) {
834
1402
  const win = el.ownerDocument.defaultView;
@@ -909,12 +1477,12 @@ var Pinbox = (function(exports) {
909
1477
  const image = {
910
1478
  blob: await canvas.convertToBlob({
911
1479
  type: "image/webp",
912
- quality: WEBP_QUALITY
1480
+ quality: WEBP_QUALITY$1
913
1481
  }),
914
1482
  width: bmp.width,
915
1483
  height: bmp.height
916
1484
  };
917
- const scale = PLACEHOLDER_MAX / Math.max(bmp.width, bmp.height);
1485
+ const scale = PLACEHOLDER_MAX$1 / Math.max(bmp.width, bmp.height);
918
1486
  const tw = Math.max(1, Math.round(bmp.width * Math.min(scale, 1)));
919
1487
  const th = Math.max(1, Math.round(bmp.height * Math.min(scale, 1)));
920
1488
  const thumb = new OffscreenCanvas(tw, th);
@@ -973,7 +1541,137 @@ var Pinbox = (function(exports) {
973
1541
  return body.data.attachment;
974
1542
  }
975
1543
  //#endregion
976
- //#region src/state.ts
1544
+ //#region src/screenshot-dom.ts
1545
+ const MAX_NODES = 1500;
1546
+ const MAX_EDGE = 1600;
1547
+ const WEBP_QUALITY = .7;
1548
+ const PLACEHOLDER_MAX = 32;
1549
+ /** Elements SVG-as-image cannot render (external resources) — replaced by a same-size box. */
1550
+ const REPLACED = /* @__PURE__ */ new Set([
1551
+ "IMG",
1552
+ "VIDEO",
1553
+ "CANVAS",
1554
+ "IFRAME",
1555
+ "OBJECT",
1556
+ "EMBED",
1557
+ "PICTURE"
1558
+ ]);
1559
+ function realmOf(el) {
1560
+ return el.ownerDocument.defaultView;
1561
+ }
1562
+ /** Copy every computed property onto the clone so it renders without the page's stylesheets. */
1563
+ function inlineStyles(win, source, clone) {
1564
+ const cs = win.getComputedStyle(source);
1565
+ const style = clone.style;
1566
+ if (style === void 0) return;
1567
+ let css = "";
1568
+ for (let i = 0; i < cs.length; i += 1) {
1569
+ const prop = cs[i];
1570
+ if (prop === void 0) continue;
1571
+ css += `${prop}:${cs.getPropertyValue(prop)};`;
1572
+ }
1573
+ style.cssText = css;
1574
+ }
1575
+ /**
1576
+ * A same-size neutral box in place of an element the snapshot cannot draw. Keeps the layout
1577
+ * honest (the agent sees where the image sits) without a tainted or empty canvas.
1578
+ */
1579
+ function placeholderFor(doc, source) {
1580
+ const r = source.getBoundingClientRect();
1581
+ const box = doc.createElement("span");
1582
+ box.style.cssText = `display:inline-block;width:${r.width}px;height:${r.height}px;background:rgba(127,127,127,.25);border-radius:4px;vertical-align:top;`;
1583
+ return box;
1584
+ }
1585
+ /**
1586
+ * Deep-clone `root` with computed styles inlined and un-renderable elements replaced.
1587
+ * Returns null when the subtree is too large to snapshot responsively.
1588
+ */
1589
+ function cloneForSnapshot(root) {
1590
+ const doc = root.ownerDocument;
1591
+ const win = doc.defaultView;
1592
+ if (win === null) return null;
1593
+ const sources = [root, ...root.querySelectorAll("*")];
1594
+ if (sources.length > MAX_NODES) return null;
1595
+ const clone = root.cloneNode(true);
1596
+ const clones = [clone, ...clone.querySelectorAll("*")];
1597
+ for (let i = 0; i < sources.length; i += 1) {
1598
+ const source = sources[i];
1599
+ const target = clones[i];
1600
+ if (target === void 0) break;
1601
+ const keepDataImage = source.tagName === "IMG" && (source.getAttribute("src") ?? "").startsWith("data:");
1602
+ if (REPLACED.has(source.tagName) && !keepDataImage) {
1603
+ target.replaceWith(placeholderFor(doc, source));
1604
+ continue;
1605
+ }
1606
+ inlineStyles(win, source, target);
1607
+ }
1608
+ for (const script of clone.querySelectorAll("script")) script.remove();
1609
+ return clone;
1610
+ }
1611
+ /** The SVG document that renders `clone` at the element's size. */
1612
+ function svgFor(clone, width, height) {
1613
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><foreignObject width="100%" height="100%"><div xmlns="http://www.w3.org/1999/xhtml">${new ((realmOf(clone)?.XMLSerializer) ?? XMLSerializer)().serializeToString(clone)}</div></foreignObject></svg>`;
1614
+ }
1615
+ function loadImage(ImageCtor, url) {
1616
+ return new Promise((resolve, reject) => {
1617
+ const img = new ImageCtor();
1618
+ img.onload = () => resolve(img);
1619
+ img.onerror = () => reject(/* @__PURE__ */ new Error("snapshot image failed to load"));
1620
+ img.src = url;
1621
+ });
1622
+ }
1623
+ function toBlob(canvas, type, quality) {
1624
+ return new Promise((resolve, reject) => {
1625
+ canvas.toBlob((b) => b === null ? reject(/* @__PURE__ */ new Error("toBlob")) : resolve(b), type, quality);
1626
+ });
1627
+ }
1628
+ async function rasterize(win, img, width, height) {
1629
+ const scale = Math.min(win.devicePixelRatio || 1, 2, MAX_EDGE / Math.max(width, height));
1630
+ const canvas = win.document.createElement("canvas");
1631
+ canvas.width = Math.max(1, Math.round(width * scale));
1632
+ canvas.height = Math.max(1, Math.round(height * scale));
1633
+ const ctx = canvas.getContext("2d");
1634
+ if (ctx === null) throw new Error("no 2d context");
1635
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
1636
+ const image = {
1637
+ blob: await toBlob(canvas, "image/webp", WEBP_QUALITY),
1638
+ width: canvas.width,
1639
+ height: canvas.height
1640
+ };
1641
+ const t = PLACEHOLDER_MAX / Math.max(canvas.width, canvas.height);
1642
+ const thumb = win.document.createElement("canvas");
1643
+ thumb.width = Math.max(1, Math.round(canvas.width * Math.min(t, 1)));
1644
+ thumb.height = Math.max(1, Math.round(canvas.height * Math.min(t, 1)));
1645
+ thumb.getContext("2d")?.drawImage(canvas, 0, 0, thumb.width, thumb.height);
1646
+ image.placeholder = `data:image/webp;base64,${toBase64(await (await toBlob(thumb, "image/webp", .5)).arrayBuffer())}`;
1647
+ return image;
1648
+ }
1649
+ /**
1650
+ * Snapshot `el` without any permission prompt. Resolves null whenever the environment cannot
1651
+ * (no canvas/Image, zero-size element, subtree too large, a tainted canvas) — never throws.
1652
+ */
1653
+ async function captureElementDom(el) {
1654
+ const win = realmOf(el);
1655
+ if (win === null || typeof win.Image !== "function" || typeof win.XMLSerializer !== "function") return null;
1656
+ const ImageCtor = win.Image;
1657
+ const r = el.getBoundingClientRect();
1658
+ const width = Math.ceil(r.width);
1659
+ const height = Math.ceil(r.height);
1660
+ if (width < 1 || height < 1) return null;
1661
+ const clone = cloneForSnapshot(el);
1662
+ if (clone === null) return null;
1663
+ const svg = svgFor(clone, width, height);
1664
+ const url = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml;charset=utf-8" }));
1665
+ try {
1666
+ return await rasterize(win, await loadImage(ImageCtor, url), width, height);
1667
+ } catch {
1668
+ return null;
1669
+ } finally {
1670
+ URL.revokeObjectURL(url);
1671
+ }
1672
+ }
1673
+ /** A session seen more recently than this counts as an agent that is listening. */
1674
+ const AGENT_LIVE_MS = 9e5;
977
1675
  function initialState() {
978
1676
  return {
979
1677
  pins: [],
@@ -985,7 +1683,10 @@ var Pinbox = (function(exports) {
985
1683
  connection: "connecting",
986
1684
  queuedIds: /* @__PURE__ */ new Set(),
987
1685
  minimized: false,
988
- pinsHidden: false
1686
+ pinsHidden: false,
1687
+ clock: 0,
1688
+ agentLive: null,
1689
+ captureMode: "dom"
989
1690
  };
990
1691
  }
991
1692
  function createStore() {
@@ -1061,20 +1762,60 @@ var Pinbox = (function(exports) {
1061
1762
  if (typeof pin?.id !== "string") return;
1062
1763
  upsertPin(store, pin);
1063
1764
  }
1765
+ /** Unknown clock, unknown agent — the pre-clock derivation. */
1766
+ const NO_VIEW = {
1767
+ clock: 0,
1768
+ agentLive: null
1769
+ };
1770
+ /** When the human last spoke on this pin: the last message if it is theirs, else the pin itself. */
1771
+ function lastHumanAt(pin, thread) {
1772
+ const last = thread[thread.length - 1];
1773
+ return Date.parse(last === void 0 ? pin.createdAt : last.at);
1774
+ }
1775
+ /**
1776
+ * How long the agent has owed a reply, as a state the card can draw.
1777
+ *
1778
+ * Dogfood: a THINKING row spun for two weeks on a pin nobody ever answered, because the row
1779
+ * meant only "the last word is yours". Now that means thinking for 90 s, then a quiet WAITING
1780
+ * FOR AGENT, then NO RESPONSE after ten minutes — or after those 90 s when the hub reports no
1781
+ * agent listening at all. "none" for anything not owed a reply. A 0 clock never goes stale.
1782
+ */
1783
+ function pendingKind(pin, thread, view = NO_VIEW) {
1784
+ if (pin.status !== "open" || pin.kind === "comment") return "none";
1785
+ const last = thread[thread.length - 1];
1786
+ if (last !== void 0 && last.role !== "human") return "none";
1787
+ if (view.clock === 0) return "thinking";
1788
+ const age = view.clock - lastHumanAt(pin, thread);
1789
+ if (age < 9e4) return "thinking";
1790
+ if (view.agentLive === false || age >= 6e5) return "stale";
1791
+ return "waiting";
1792
+ }
1064
1793
  /**
1065
1794
  * Wire-status → UI-status mapping:
1066
1795
  * resolved + no verification ⇒ "verify" (accept/reopen prompt);
1067
1796
  * resolved + verification ⇒ "resolved";
1797
+ * open comment pin ⇒ "note" (nobody owes a reply — never "waiting");
1798
+ * open + reply owed too long (pendingKind "stale") ⇒ "stale";
1068
1799
  * open + empty thread or last message human ⇒ "waiting";
1069
1800
  * open + last message agent|mirror ⇒ "replied".
1070
1801
  * The prototype's WORKING/APPLIED chips need an event vocabulary the hub does not emit — excluded here.
1071
1802
  */
1072
- function deriveUiStatus(pin, thread) {
1803
+ function deriveUiStatus(pin, thread, view = NO_VIEW) {
1073
1804
  if (pin.status === "resolved") return pin.verification ? "resolved" : "verify";
1074
- const last = thread[thread.length - 1];
1075
- if (!last || last.role === "human") return "waiting";
1805
+ if (pin.kind === "comment") return "note";
1806
+ const pending = pendingKind(pin, thread, view);
1807
+ if (pending === "stale") return "stale";
1808
+ if (pending !== "none") return "waiting";
1076
1809
  return "replied";
1077
1810
  }
1811
+ /** Is anyone listening? A session not ended and seen within AGENT_LIVE_MS. */
1812
+ function agentIsLive(sessions, now) {
1813
+ return sessions.some((s) => s.endedAt === void 0 && now - Date.parse(s.lastSeenAt) < AGENT_LIVE_MS);
1814
+ }
1815
+ /** Open pins that ask something of someone — comment pins are remarks, so the badges skip them. */
1816
+ function openTaskCount(pins) {
1817
+ return pins.filter((p) => p.status !== "resolved" && p.kind !== "comment").length;
1818
+ }
1078
1819
  //#endregion
1079
1820
  //#region src/transport/mirror.ts
1080
1821
  function randomBase36(length) {
@@ -1232,6 +1973,10 @@ var Pinbox = (function(exports) {
1232
1973
  listPins() {
1233
1974
  return this.#request("GET", "/pins");
1234
1975
  }
1976
+ /** Agent sessions the hub knows, most recently seen first (routes-sessions.ts). */
1977
+ listSessions() {
1978
+ return this.#request("GET", "/sessions");
1979
+ }
1235
1980
  createPin(input) {
1236
1981
  return this.#request("POST", "/pins", input);
1237
1982
  }
@@ -1310,6 +2055,8 @@ var Pinbox = (function(exports) {
1310
2055
  #timer = null;
1311
2056
  /** One flush at a time — reconnect and the live write path both drain the outbox. */
1312
2057
  #flushing = false;
2058
+ /** Bumped per `GET /sessions`; only the newest request's snapshot reaches `onSessions`. */
2059
+ #sessionsGen = 0;
1313
2060
  constructor(opts) {
1314
2061
  this.#opts = opts;
1315
2062
  const storage = opts.storage ?? globalThis.localStorage ?? memoryStorage();
@@ -1515,6 +2262,7 @@ var Pinbox = (function(exports) {
1515
2262
  }
1516
2263
  /** Refresh listPins (hub wins on status) → flush the outbox (client wins on new pins) → persist the fresh mirror. */
1517
2264
  async #reconcile() {
2265
+ this.#refreshSessions();
1518
2266
  try {
1519
2267
  const pins = await this.#snapshotPins();
1520
2268
  if (pins !== null) this.#opts.onPins?.(pins);
@@ -1541,6 +2289,18 @@ var Pinbox = (function(exports) {
1541
2289
  }
1542
2290
  return null;
1543
2291
  }
2292
+ /** Best-effort: a hub without the route (or unreachable) simply leaves liveness unknown.
2293
+ * Reconnects can overlap; a slow older GET must not overwrite a newer snapshot, so only
2294
+ * the latest request applies. */
2295
+ async #refreshSessions() {
2296
+ if (this.#opts.onSessions === void 0) return;
2297
+ this.#sessionsGen += 1;
2298
+ const gen = this.#sessionsGen;
2299
+ try {
2300
+ const sessions = await this.#rest.listSessions();
2301
+ if (gen === this.#sessionsGen) this.#opts.onSessions(sessions);
2302
+ } catch {}
2303
+ }
1544
2304
  async #flushOutbox() {
1545
2305
  if (this.#flushing) return [];
1546
2306
  this.#flushing = true;
@@ -1563,155 +2323,132 @@ var Pinbox = (function(exports) {
1563
2323
  }
1564
2324
  };
1565
2325
  //#endregion
1566
- //#region src/ui/aim.ts
1567
- /**
1568
- * True when aiming has to be done by dragging rather than by pointing.
1569
- *
1570
- * Two independent reasons, either of which is sufficient. A coarse pointer means there is no
1571
- * hover to follow at all — the mouse crosshair cannot work, whatever the screen size. The width
1572
- * check is the design's own rule (720px) and catches the case a media query cannot: a device that
1573
- * reports a fine pointer but is being used at phone width.
1574
- */
1575
- function needsDragAim(win) {
1576
- return win.matchMedia?.("(pointer: coarse)").matches === true || win.innerWidth < 720;
1577
- }
1578
- /**
1579
- * Where the reticle starts.
1580
- *
1581
- * Slightly above centre: the confirm bar owns the bottom of the screen, and a reticle that opens
1582
- * underneath your own thumb is one you have to move before you can even see it.
1583
- */
1584
- function startPoint(win) {
1585
- return {
1586
- x: win.innerWidth / 2,
1587
- y: win.innerHeight * .42
1588
- };
1589
- }
1590
- 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>";
1591
- function createAim(doc, handlers) {
1592
- const win = doc.defaultView;
1593
- const root = doc.createElement("div");
1594
- root.className = "pb-aim";
1595
- root.innerHTML = MARKUP;
1596
- const h = root.querySelector(".h");
1597
- const v = root.querySelector(".v");
1598
- const grip = root.querySelector(".grip");
1599
- const label = root.querySelector(".lab");
1600
- const point = {
1601
- x: 0,
1602
- y: 0
1603
- };
1604
- /** Grab offset, so the reticle does not jump to your fingertip when you take hold of it. */
1605
- let grab = null;
1606
- function put(x, y) {
1607
- point.x = Math.max(0, Math.min(win.innerWidth, x));
1608
- point.y = Math.max(0, Math.min(win.innerHeight, y));
1609
- h.style.top = `${point.y}px`;
1610
- v.style.left = `${point.x}px`;
1611
- grip.style.left = `${point.x}px`;
1612
- grip.style.top = `${point.y}px`;
1613
- }
1614
- const onPointerMove = (e) => {
1615
- if (grab === null) return;
1616
- e.preventDefault();
1617
- put(e.clientX + grab.dx, e.clientY + grab.dy);
1618
- handlers.onAim(point.x, point.y);
1619
- };
1620
- const onPointerUp = () => {
1621
- grab = null;
1622
- };
1623
- grip.addEventListener("pointerdown", (e) => {
1624
- e.preventDefault();
1625
- e.stopPropagation();
1626
- grab = {
1627
- dx: point.x - e.clientX,
1628
- dy: point.y - e.clientY
1629
- };
1630
- grip.setPointerCapture?.(e.pointerId);
1631
- });
1632
- grip.addEventListener("keydown", (e) => {
1633
- const step = e.shiftKey ? 20 : 2;
1634
- const delta = {
1635
- ArrowLeft: [-step, 0],
1636
- ArrowRight: [step, 0],
1637
- ArrowUp: [0, -step],
1638
- ArrowDown: [0, step]
1639
- }[e.key];
1640
- if (!delta) return;
1641
- e.preventDefault();
1642
- put(point.x + delta[0], point.y + delta[1]);
1643
- handlers.onAim(point.x, point.y);
1644
- });
1645
- root.addEventListener("click", (e) => {
1646
- const action = e.target.closest?.("[data-aim]")?.getAttribute("data-aim");
1647
- if (!action) return;
1648
- e.preventDefault();
1649
- e.stopPropagation();
1650
- if (action === "confirm") handlers.onConfirm();
1651
- else handlers.onCancel();
1652
- });
1653
- win.addEventListener("pointermove", onPointerMove, { passive: false });
1654
- win.addEventListener("pointerup", onPointerUp);
1655
- win.addEventListener("pointercancel", onPointerUp);
1656
- return {
1657
- root,
1658
- point,
1659
- show(x, y) {
1660
- put(x, y);
1661
- root.classList.add("on");
1662
- },
1663
- hide() {
1664
- grab = null;
1665
- root.classList.remove("on");
1666
- },
1667
- setLabel(text) {
1668
- label.textContent = text;
1669
- },
1670
- destroy() {
1671
- win.removeEventListener("pointermove", onPointerMove);
1672
- win.removeEventListener("pointerup", onPointerUp);
1673
- win.removeEventListener("pointercancel", onPointerUp);
1674
- }
1675
- };
1676
- }
1677
- //#endregion
1678
2326
  //#region src/ui/bar.ts
1679
- 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>";
1680
- 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>";
1681
- const THEME_ICON = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\"><path d=\"M8 2a4 4 0 0 0 6 6 6 6 0 1 1-6-6z\"/></svg>";
1682
- const COPY_ICON$1 = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\"><rect x=\"5.5\" y=\"5.5\" width=\"8\" height=\"8\" rx=\"1\"/><path d=\"M10.5 3.5v-1a1 1 0 00-1-1h-6a1 1 0 00-1 1v6a1 1 0 001 1h1\"/></svg>";
1683
2327
  const IDENT_ICON = "<svg width=\"15\" height=\"15\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"var(--pb-amber)\" stroke-width=\"1.4\"><rect x=\"2.5\" y=\"1.5\" width=\"11\" height=\"7\" rx=\"1\"/><path d=\"M8 8.5v6\"/><circle cx=\"8\" cy=\"14.6\" r=\".9\" fill=\"var(--pb-amber)\" stroke=\"none\"/></svg>";
1684
- const MIN_ICON = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\"><path d=\"M6.5 2.5v4h-4\"/><path d=\"M9.5 13.5v-4h4\"/></svg>";
2328
+ const GRIP_ICON = "<svg width=\"6\" height=\"14\" viewBox=\"0 0 6 14\" fill=\"currentColor\"><circle cx=\"1.5\" cy=\"2\" r=\"1.1\"/><circle cx=\"4.5\" cy=\"2\" r=\"1.1\"/><circle cx=\"1.5\" cy=\"7\" r=\"1.1\"/><circle cx=\"4.5\" cy=\"7\" r=\"1.1\"/><circle cx=\"1.5\" cy=\"12\" r=\"1.1\"/><circle cx=\"4.5\" cy=\"12\" r=\"1.1\"/></svg>";
1685
2329
  const CONNECTION_LABEL = {
1686
2330
  connecting: "PINBOX",
1687
2331
  live: "PINBOX",
1688
2332
  offline: "PINBOX · OFFLINE",
1689
2333
  incompatible: "PINBOX · UPDATE NEEDED"
1690
2334
  };
2335
+ /** `data-ref` per action: the element focuses "min" after a keyboard restore. */
2336
+ function refOf(a) {
2337
+ return a.id === "minimize" ? "min" : a.id;
2338
+ }
2339
+ function buttonHtml(a) {
2340
+ const bar = a.bar;
2341
+ const glyph = a.glyph === void 0 ? "" : icon(a.glyph, 14);
2342
+ const body = bar.count ? `${glyph}<span data-ref="count">0</span>` : `${glyph}${bar.text ?? ""}`;
2343
+ const square = bar.text === void 0 && !bar.count;
2344
+ const aria = bar.ariaLabel === void 0 ? "" : ` aria-label="${bar.ariaLabel}"`;
2345
+ return `<button type="button" class="pb-tb${square ? " sq" : ""}" data-ref="${refOf(a)}" title="${titleOf(a)}"${aria}>${a.glyph === void 0 ? a.key ?? "" : body}</button>`;
2346
+ }
1691
2347
  function createBar(doc, on) {
1692
2348
  const root = doc.createElement("div");
1693
2349
  root.className = "pb-bar";
1694
- root.innerHTML = `<div class="armed-ring"></div><div class="ident">${IDENT_ICON}<span class="bl" data-ref="label">PINBOX</span></div><div class="div"></div><button type="button" class="pb-tb" data-ref="pin" title="Pin (P)">${PIN_ICON}PIN</button><button type="button" class="pb-tb" data-ref="inbox" title="Inbox (I)">${INBOX_ICON}<span data-ref="count">0</span></button><div class="div" style="margin:0 3px"></div><button type="button" class="pb-tb sq" data-ref="copy" title="Copy open pins (C)">${COPY_ICON$1}</button><button type="button" class="pb-tb sq" data-ref="theme" title="Theme (D)">${THEME_ICON}</button><button type="button" class="pb-tb sq" data-ref="help" title="Shortcuts (?)">?</button><button type="button" class="pb-tb sq" data-ref="min" title="Minimize (M)" aria-label="Minimize toolbar">${MIN_ICON}</button>`;
2350
+ const wide = ACTIONS.filter((a) => a.bar !== void 0 && (a.bar.text !== void 0 || a.bar.count));
2351
+ const squares = ACTIONS.filter((a) => a.bar !== void 0 && !wide.includes(a));
2352
+ root.innerHTML = `<div class="armed-ring"></div><div class="grip" data-ref="grip" title="Drag to move · double-click to reset">${GRIP_ICON}</div><div class="ident">${IDENT_ICON}<span class="bl" data-ref="label">PINBOX</span></div><div class="div"></div>` + wide.map(buttonHtml).join("") + "<div class=\"div\" style=\"margin:0 3px\"></div>" + squares.map(buttonHtml).join("");
1695
2353
  const ref = (name) => root.querySelector(`[data-ref="${name}"]`);
1696
2354
  const label = ref("label");
1697
2355
  const pinBtn = ref("pin");
1698
2356
  const inboxBtn = ref("inbox");
2357
+ const hideBtn = ref("hide");
2358
+ const captureBtn = ref("capture");
1699
2359
  const count = ref("count");
1700
- pinBtn.addEventListener("click", on.onPin);
1701
- inboxBtn.addEventListener("click", on.onInbox);
1702
- ref("copy").addEventListener("click", on.onCopy);
1703
- ref("theme").addEventListener("click", on.onTheme);
1704
- ref("help").addEventListener("click", on.onHelp);
1705
- ref("min").addEventListener("click", (e) => on.onMinimize(e.detail === 0));
2360
+ for (const a of [...wide, ...squares]) ref(refOf(a)).addEventListener("click", (e) => on.onAction(a.id, e.detail === 0));
2361
+ /** Last-rendered hide state; the glyph is swapped only on change. */
2362
+ let hideShown = null;
2363
+ return {
2364
+ root,
2365
+ grip: ref("grip"),
2366
+ update(state) {
2367
+ const text = state.mode === "placing" ? "CLICK TO PIN" : CONNECTION_LABEL[state.connection];
2368
+ if (label.textContent !== text) label.textContent = text;
2369
+ pinBtn.classList.toggle("hot", state.mode === "placing");
2370
+ inboxBtn.classList.toggle("lit", state.inboxOpen);
2371
+ const open = String(openTaskCount(state.pins));
2372
+ if (count.textContent !== open) count.textContent = open;
2373
+ captureBtn.classList.toggle("lit", state.captureMode === "tab");
2374
+ captureBtn.title = state.captureMode === "tab" ? "Tab capture on — real pixels, Chrome asks once per page load (S)" : "Screenshots: DOM snapshot, no prompt — press for tab capture (S)";
2375
+ if (hideShown !== state.pinsHidden) {
2376
+ hideShown = state.pinsHidden;
2377
+ hideBtn.innerHTML = icon(state.pinsHidden ? EYE_GLYPH : EYE_OFF_GLYPH, 14);
2378
+ hideBtn.classList.toggle("lit", state.pinsHidden);
2379
+ hideBtn.title = state.pinsHidden ? "Show pins (H)" : "Hide pins (H)";
2380
+ }
2381
+ }
2382
+ };
2383
+ }
2384
+ //#endregion
2385
+ //#region src/ui/bar-drag.ts
2386
+ const MARGIN = 16;
2387
+ function createBarDrag(host) {
2388
+ const { win, bar, grip } = host;
2389
+ const key = `${host.storagePrefix}:bar`;
2390
+ let pos = readPoint(host.storage, key);
2391
+ /** Where the bar was when a press began, so a tap-sized wobble snaps back. */
2392
+ let before = null;
2393
+ function persist() {
2394
+ try {
2395
+ if (pos === null) host.storage?.removeItem(key);
2396
+ else host.storage?.setItem(key, JSON.stringify(pos));
2397
+ } catch {}
2398
+ }
2399
+ function box() {
2400
+ const r = bar.getBoundingClientRect();
2401
+ return {
2402
+ w: r.width,
2403
+ h: r.height
2404
+ };
2405
+ }
2406
+ function apply(p) {
2407
+ pos = p;
2408
+ bar.classList.toggle("free", p !== null);
2409
+ bar.style.left = p === null ? "" : `${p.x}px`;
2410
+ bar.style.top = p === null ? "" : `${p.y}px`;
2411
+ }
2412
+ const drag = attachDrag(grip, {
2413
+ origin: () => {
2414
+ if (bar.classList.contains("pb-ghost")) return null;
2415
+ const r = bar.getBoundingClientRect();
2416
+ return {
2417
+ x: r.left,
2418
+ y: r.top
2419
+ };
2420
+ },
2421
+ canStart: () => !bar.classList.contains("pb-ghost"),
2422
+ onStart() {
2423
+ before = pos;
2424
+ bar.classList.add("dragging");
2425
+ },
2426
+ onMove(next) {
2427
+ apply(clampToViewport(next, win, box(), MARGIN));
2428
+ },
2429
+ onEnd({ dragged, started }) {
2430
+ bar.classList.remove("dragging");
2431
+ if (dragged) persist();
2432
+ else if (started) apply(before);
2433
+ }
2434
+ });
2435
+ function reset() {
2436
+ apply(null);
2437
+ persist();
2438
+ }
2439
+ function onResize() {
2440
+ if (pos !== null) apply(clampToViewport(pos, win, box(), MARGIN));
2441
+ }
2442
+ grip.addEventListener("dblclick", reset);
2443
+ win.addEventListener("resize", onResize);
2444
+ if (pos !== null) apply(clampToViewport(pos, win, box(), MARGIN));
1706
2445
  return {
1707
- root,
1708
- update(state) {
1709
- const text = state.mode === "placing" ? "CLICK TO PIN" : CONNECTION_LABEL[state.connection];
1710
- if (label.textContent !== text) label.textContent = text;
1711
- pinBtn.classList.toggle("hot", state.mode === "placing");
1712
- inboxBtn.classList.toggle("lit", state.inboxOpen);
1713
- const open = String(state.pins.filter((p) => p.status !== "resolved").length);
1714
- if (count.textContent !== open) count.textContent = open;
2446
+ position: () => pos,
2447
+ reset,
2448
+ destroy() {
2449
+ drag.destroy();
2450
+ grip.removeEventListener("dblclick", reset);
2451
+ win.removeEventListener("resize", onResize);
1715
2452
  }
1716
2453
  };
1717
2454
  }
@@ -1745,6 +2482,168 @@ var Pinbox = (function(exports) {
1745
2482
  return String(n).padStart(2, "0");
1746
2483
  }
1747
2484
  //#endregion
2485
+ //#region src/ui/card-messages.ts
2486
+ /** The prototype's `_h` innerHTML memo, kept off the DOM node. */
2487
+ const nodeMemo = /* @__PURE__ */ new WeakMap();
2488
+ function timeOf(at) {
2489
+ const d = new Date(at);
2490
+ if (Number.isNaN(d.getTime())) return "";
2491
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
2492
+ }
2493
+ function isImage(att) {
2494
+ if (att.contentType?.startsWith("image/")) return true;
2495
+ return /\.(png|webp|jpe?g|gif)$/i.test(att.url ?? att.path ?? "");
2496
+ }
2497
+ function fileName(att) {
2498
+ const source = att.url ?? att.path ?? att.id;
2499
+ return source.split("/").pop() ?? source;
2500
+ }
2501
+ /** Thumbnail when the attachment is an image; the error listener degrades it to a chip. */
2502
+ function attachmentsHtml(m) {
2503
+ if (!m.attachments?.length) return "";
2504
+ return `<div class="atts">${m.attachments.map((att) => isImage(att) ? `<span class="pb-att"><img src="${esc(safeUrl(att.url ?? att.path ?? ""))}" alt="${esc(fileName(att))}" loading="lazy"></span>` : `<span class="pb-att-chip">${esc(fileName(att))}</span>`).join("")}</div>`;
2505
+ }
2506
+ /** "claude:lark-mac-agent" → "Claude · lark-mac-agent"; other shapes verbatim. */
2507
+ function agentName(origin) {
2508
+ const idx = origin.indexOf(":");
2509
+ if (idx <= 0) return origin;
2510
+ const agent = origin.slice(0, idx);
2511
+ return `${agent.charAt(0).toUpperCase()}${agent.slice(1)} · ${origin.slice(idx + 1)}`;
2512
+ }
2513
+ function messageHtml(m) {
2514
+ if (m.role === "agent") return `<div class="pb-msg"><div class="pb-av agent">AI</div><div class="col"><div class="line"><span class="who">${esc(m.origin === void 0 ? "Agent" : agentName(m.origin))}</span><span class="tm">${esc(timeOf(m.at))}</span></div><div class="txt">${esc(m.text)}</div>${attachmentsHtml(m)}</div></div>`;
2515
+ const mirror = m.role === "mirror";
2516
+ const origin = mirror ? m.origin ?? "mirror" : null;
2517
+ const who = origin ? origin.split(":")[1] ?? origin : "You";
2518
+ const initials = who.slice(0, 2).toUpperCase();
2519
+ const via = origin ? `<span class="via-tag"><span>${esc(origin)}</span></span>` : "";
2520
+ 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>`;
2521
+ }
2522
+ /** The agent has the message and has not answered yet. Its own node, so patching never rebuilds. */
2523
+ const PENDING_HTML = {
2524
+ thinking: "<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>",
2525
+ waiting: "<div class=\"pb-typing quiet\"><div class=\"pb-av agent\">AI</div><div class=\"lbl\">WAITING FOR AGENT</div></div>"
2526
+ };
2527
+ /**
2528
+ * Show, restyle or hide the "working on it" row.
2529
+ *
2530
+ * Without it the card sits silent from the moment you comment until the answer lands, which reads
2531
+ * as nothing happening — the single most common report on the demo. With it forever, a pin nobody
2532
+ * answers "thinks" for two weeks (dogfood) — so the row has stages, and "stale"/"none" remove it
2533
+ * (the stale footer takes over, card-parts.ts).
2534
+ */
2535
+ function patchPending(threadEl, kind) {
2536
+ const existing = threadEl.querySelector("[data-iid=\"pb-typing\"]");
2537
+ if (kind === "none" || kind === "stale") {
2538
+ existing?.remove();
2539
+ return;
2540
+ }
2541
+ const html = PENDING_HTML[kind];
2542
+ if (existing) {
2543
+ if (nodeMemo.get(existing) !== html) {
2544
+ existing.innerHTML = html;
2545
+ nodeMemo.set(existing, html);
2546
+ }
2547
+ threadEl.appendChild(existing);
2548
+ return;
2549
+ }
2550
+ const node = threadEl.ownerDocument.createElement("div");
2551
+ node.className = "pb-msg-w";
2552
+ node.setAttribute("data-iid", "pb-typing");
2553
+ node.innerHTML = html;
2554
+ nodeMemo.set(node, html);
2555
+ threadEl.appendChild(node);
2556
+ threadEl.scrollTop = threadEl.scrollHeight;
2557
+ }
2558
+ /** Keyed thread patching: appends/patches [data-iid] nodes only, never rebuilds. */
2559
+ function patchThread(threadEl, messages) {
2560
+ let appended = false;
2561
+ for (const m of messages) {
2562
+ let node = [...threadEl.children].find((c) => c.getAttribute("data-iid") === m.id) ?? null;
2563
+ const html = messageHtml(m);
2564
+ if (!node) {
2565
+ node = threadEl.ownerDocument.createElement("div");
2566
+ node.className = "pb-msg-w";
2567
+ node.setAttribute("data-iid", m.id);
2568
+ node.innerHTML = html;
2569
+ nodeMemo.set(node, html);
2570
+ threadEl.appendChild(node);
2571
+ appended = true;
2572
+ } else if (nodeMemo.get(node) !== html) {
2573
+ node.innerHTML = html;
2574
+ nodeMemo.set(node, html);
2575
+ }
2576
+ }
2577
+ if (appended) threadEl.scrollTop = threadEl.scrollHeight;
2578
+ }
2579
+ //#endregion
2580
+ //#region src/ui/card-parts.ts
2581
+ const STATUS_LABEL = {
2582
+ open: "OPEN",
2583
+ waiting: "OPEN",
2584
+ replied: "REPLIED",
2585
+ resolved: "RESOLVED",
2586
+ verify: "VERIFY",
2587
+ note: "NOTE",
2588
+ stale: "NO RESPONSE"
2589
+ };
2590
+ const CHECK_ICON$1 = "<svg width=\"13\" height=\"13\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M3 8.5l3.2 3.2L13 4.8\"/></svg>";
2591
+ const X_ICON$1 = "<svg width=\"13\" height=\"13\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 4l8 8M12 4l-8 8\"/></svg>";
2592
+ const COPY_ICON = "<svg width=\"13\" height=\"13\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\"><rect x=\"5.5\" y=\"5.5\" width=\"8\" height=\"8\" rx=\"1\"/><path d=\"M10.5 3.5v-1a1 1 0 00-1-1h-6a1 1 0 00-1 1v6a1 1 0 001 1h1\"/></svg>";
2593
+ function hdHtml(n, targetLabel, status, resolvable, copyable) {
2594
+ return `<div class="meta"><span class="num">${pinNumber(n)}</span><span>${esc(targetLabel)}</span><span class="st">${esc(status)}</span></div><div style="display:flex;gap:2px">` + (copyable ? `<button type="button" class="pb-ico" data-action="copy" title="Copy this pin">${COPY_ICON}</button>` : "") + (resolvable ? `<button type="button" class="pb-ico ok" data-action="resolve" title="Resolve (R)">${CHECK_ICON$1}</button>` : "") + `<button type="button" class="pb-ico" data-action="close" title="Close (Esc)">${X_ICON$1}</button></div>`;
2595
+ }
2596
+ /** One name per locus: selector first, else anchor, else tag. */
2597
+ function locusName(t) {
2598
+ return t.selector ?? t.anchor ?? t.tag?.toUpperCase();
2599
+ }
2600
+ /** The extra loci of a multi-target pin — the anchor leads, extras follow. */
2601
+ function lociHtml(pin) {
2602
+ const target = pin?.target;
2603
+ const extras = target?.targets;
2604
+ if (target === void 0 || extras === void 0 || extras.length === 0) return "";
2605
+ const names = [target, ...extras].map((t) => esc(locusName(t) ?? "?"));
2606
+ return `<div class="pb-loci">${names.length} targets: ${names.join(" · ")}</div>`;
2607
+ }
2608
+ /** Link badge: pin.links[0] read-only — no picker, no unlink yet. */
2609
+ function linkHtml(pin) {
2610
+ const link = pin?.links?.[0];
2611
+ if (!link) return "";
2612
+ return `<div class="pb-linkbar"><span class="ch">${esc(link.connector)}</span><span class="mt">${esc(link.ref)}</span><span class="sp"></span><a class="pb-open" href="${esc(safeUrl(link.url))}" target="_blank" rel="noreferrer">OPEN</a></div>`;
2613
+ }
2614
+ /**
2615
+ * Who resolved the pin and what they said. The schema has carried `resolution.note` and
2616
+ * `.commit` since v1 (agents write them); the card never showed either, so a resolved pin
2617
+ * read as bare "RESOLVED" with the why one CLI call away.
2618
+ */
2619
+ function resolutionHtml(pin) {
2620
+ const r = pin?.resolution;
2621
+ if (pin === null || r === void 0 || pin.status !== "resolved") return "";
2622
+ return `<div class="pb-resnote">Resolved by ${r.by === "agent" ? "agent" : "you"}${r.note === void 0 ? "" : ` · ${esc(r.note)}`}${r.commit === void 0 ? "" : ` <span class="hh">${esc(r.commit.slice(0, 7))}</span>`}</div>`;
2623
+ }
2624
+ /**
2625
+ * The pin has waited too long. Nudge re-posts your last message so a watcher re-triggers;
2626
+ * Resolve clears it — the two things you can actually do about a silent agent.
2627
+ */
2628
+ function staleHtml(status) {
2629
+ if (status !== "stale") return "";
2630
+ return "<div class=\"pb-stale\"><span class=\"msg\">No agent has answered. It may not be running.</span><button type=\"button\" class=\"pb-bt-ghost\" data-action=\"nudge\" title=\"Re-send your last message\">Nudge</button><button type=\"button\" class=\"pb-bt-ok\" data-action=\"resolve\">Resolve</button></div>";
2631
+ }
2632
+ function verifyHtml(status) {
2633
+ if (status === "verify") return "<div class=\"pb-verify\"><button type=\"button\" class=\"pb-bt-ok\" data-action=\"verify-accept\">Looks good</button><button type=\"button\" class=\"pb-bt-ghost\" data-action=\"verify-reopen\">Reopen</button></div>";
2634
+ if (status === "resolved") return "<div class=\"pb-verify\"><button type=\"button\" class=\"pb-bt-ghost\" data-action=\"verify-reopen\">Unresolve</button></div>";
2635
+ return "";
2636
+ }
2637
+ /**
2638
+ * The composer row. A draft chooses what it is before it exists: "Ask agent" (the default —
2639
+ * a `note` pin an agent picks up) or "Note" (a `comment` pin, a remark for people that never
2640
+ * wakes an agent). A control on the draft, not a global toggle: a mode you set once and forget
2641
+ * makes the next pin silently the wrong kind.
2642
+ */
2643
+ function rowHtml(hasThread, isDraft, kind) {
2644
+ return `${isDraft ? `<div class="pb-seg" role="radiogroup" aria-label="Pin kind"><button type="button" role="radio" aria-checked="${kind === "note"}" class="${kind === "note" ? "on" : ""}" data-kind="note" title="An agent picks this up">Ask agent</button><button type="button" role="radio" aria-checked="${kind === "comment"}" class="${kind === "comment" ? "on" : ""}" data-kind="comment" title="A remark for people; no agent acts on it">Note</button></div>` : ""}<div class="pb-kbd">⌘ ↵</div><button type="button" class="pb-bt-solid" data-action="send">${hasThread ? "Reply" : kind === "comment" ? "Leave note" : "Comment"}</button>`;
2645
+ }
2646
+ //#endregion
1748
2647
  //#region src/ui/pins.ts
1749
2648
  /** The prototype's `_h` innerHTML memo, kept off the DOM node. */
1750
2649
  const chipMemo = /* @__PURE__ */ new WeakMap();
@@ -1766,39 +2665,89 @@ var Pinbox = (function(exports) {
1766
2665
  return true;
1767
2666
  }
1768
2667
  }
2668
+ /** A stored (document-space) rect or point, in today's viewport. */
2669
+ function toViewport(win, p) {
2670
+ return {
2671
+ ...p,
2672
+ x: p.x - win.scrollX,
2673
+ y: p.y - win.scrollY
2674
+ };
2675
+ }
1769
2676
  /**
1770
- * Where the pin's anchor is NOW (dogfood #26: markers lingered over unrelated
1771
- * views after SPA tab switches, because placement trusted the stored rect
1772
- * forever). Re-resolve the captured selector on every render:
1773
- * - it resolves with layout → snap to the LIVE rect (also fixes drift);
1774
- * - it resolves without layout (test DOMs, display:none) stored rect;
2677
+ * Where the pin's anchor is NOW, in VIEWPORT space (dogfood #26: markers lingered over
2678
+ * unrelated views after SPA tab switches, because placement trusted the stored rect forever;
2679
+ * dogfood v4: pins drifted on scroll, because the layer was document-space and only
2680
+ * re-rendered on DOM mutation). Re-resolve the captured selector on every render:
2681
+ * - it resolves with layout the LIVE client rect, which is right inside inner scroll
2682
+ * containers and on sticky/fixed anchors alike;
2683
+ * - it resolves without layout (test DOMs, display:none) → stored rect minus scroll;
1775
2684
  * - it does not resolve → no marker; the drawer stays the see-everything list.
1776
2685
  * A pin with no selector (terminal-adjacent) keeps its stored rect, as before.
1777
2686
  */
1778
- function anchorRect(layer, pin) {
1779
- const stored = pin.target?.rect;
2687
+ function anchorRect(doc, pin) {
2688
+ return targetRect(doc, pin.target);
2689
+ }
2690
+ /** The same resolution for any captured target — a pin's, or the draft's before it commits. */
2691
+ function targetRect(doc, target) {
2692
+ const stored = target?.rect;
1780
2693
  if (stored === void 0) return null;
1781
- const doc = layer.ownerDocument;
1782
2694
  const win = doc.defaultView;
1783
2695
  if (win === null) return stored;
1784
- if (!sameView(win, pin.target?.url)) return null;
1785
- const selector = pin.target?.selector;
1786
- if (selector === void 0) return stored;
2696
+ if (!sameView(win, target?.url)) return null;
2697
+ const selector = target?.selector;
2698
+ if (selector === void 0) return toViewport(win, stored);
1787
2699
  let el;
1788
2700
  try {
1789
2701
  el = doc.querySelector(selector);
1790
2702
  } catch {
1791
- return stored;
2703
+ return toViewport(win, stored);
1792
2704
  }
1793
2705
  if (el === null) return null;
1794
2706
  const r = el.getBoundingClientRect();
1795
- if (r.width <= 0 && r.height <= 0) return stored;
1796
- return {
1797
- x: r.left + win.scrollX,
1798
- y: r.top + win.scrollY,
2707
+ if (r.width <= 0 && r.height <= 0) return toViewport(win, stored);
2708
+ return clipToScrollAncestors(win, el, {
2709
+ x: r.left,
2710
+ y: r.top,
1799
2711
  width: r.width,
1800
2712
  height: r.height
1801
- };
2713
+ });
2714
+ }
2715
+ const CLIPPING = /* @__PURE__ */ new Set([
2716
+ "auto",
2717
+ "scroll",
2718
+ "hidden",
2719
+ "clip"
2720
+ ]);
2721
+ /**
2722
+ * The part of `rect` an ancestor scroll container actually shows. A row scrolled out of its
2723
+ * pane has a live client rect above or below the pane; drawing a marker there floats it over
2724
+ * unrelated content (dogfood: the draft on Record 12 sat on the heading once the pane scrolled).
2725
+ * Null when nothing of the element is visible; the drawer still lists the pin.
2726
+ */
2727
+ function clipToScrollAncestors(win, el, rect) {
2728
+ let out = rect;
2729
+ for (let p = el.parentElement; p !== null && p !== win.document.body; p = p.parentElement) {
2730
+ const cs = win.getComputedStyle(p);
2731
+ if (![
2732
+ cs.overflow,
2733
+ cs.overflowX,
2734
+ cs.overflowY
2735
+ ].some((v) => CLIPPING.has(v))) continue;
2736
+ const c = p.getBoundingClientRect();
2737
+ if (c.width <= 0 && c.height <= 0) continue;
2738
+ const x1 = Math.max(out.x, c.left);
2739
+ const y1 = Math.max(out.y, c.top);
2740
+ const x2 = Math.min(out.x + out.width, c.right);
2741
+ const y2 = Math.min(out.y + out.height, c.bottom);
2742
+ if (x2 <= x1 || y2 <= y1) return null;
2743
+ out = {
2744
+ x: x1,
2745
+ y: y1,
2746
+ width: x2 - x1,
2747
+ height: y2 - y1
2748
+ };
2749
+ }
2750
+ return out;
1802
2751
  }
1803
2752
  /**
1804
2753
  * Where the needle lands: the point inside the element that was actually clicked, when the pin
@@ -1815,13 +2764,25 @@ var Pinbox = (function(exports) {
1815
2764
  y: r.y + r.height * fy
1816
2765
  };
1817
2766
  }
2767
+ /**
2768
+ * Where the draft marker sits: its captured element's LIVE rect at the clicked spot, exactly as a
2769
+ * committed pin would (a draft on a sticky header must ride the header while you type). Null when
2770
+ * the element is scrolled out of its container or gone — no marker, and the card docks
2771
+ * (card.ts) instead of floating over whatever now occupies the stale point.
2772
+ */
2773
+ function draftPoint(doc, draft) {
2774
+ const target = draft.target.target;
2775
+ const live = targetRect(doc, target);
2776
+ return live === null ? null : pinPoint(live, target.spot);
2777
+ }
1818
2778
  /** Chip contents (prototype chipBtnInner, lines 546–550): number + linked-channel tag,
1819
2779
  * plus the queued badge while the pin waits in the outbox for the reconnect flush. */
1820
- function chipInner(n, pin, queued = false) {
2780
+ function chipInner(n, pin, queued = false, stale = false) {
1821
2781
  const link = pin?.links?.[0];
1822
2782
  const badge = link ? `<span class="lk"><span>${esc(link.connector)}</span></span>` : "";
1823
- const qd = queued ? "<span class=\"qd\">QUEUED</span>" : "";
1824
- return `<span>${pinNumber(n)}</span>${badge}${qd}`;
2783
+ const qd = queued ? "<span class=\"qd\">QUEUED</span>" : stale ? "<span class=\"qd\">NO REPLY</span>" : "";
2784
+ const nt = pin?.kind === "comment" ? "<span class=\"nt\" title=\"Note — no agent acts on this\">N</span>" : "";
2785
+ return `<span>${pinNumber(n)}</span>${nt}${badge}${qd}`;
1825
2786
  }
1826
2787
  function ensureNode(layer, key, fresh) {
1827
2788
  let node = layer.querySelector(`[data-pin="${key}"]`);
@@ -1855,128 +2816,43 @@ var Pinbox = (function(exports) {
1855
2816
  const visible = state.pins.filter((p) => p.status !== "resolved" || p.id === state.activePinId);
1856
2817
  const placed = [];
1857
2818
  visible.forEach((pin, i) => {
1858
- const rect = anchorRect(layer, pin);
2819
+ const rect = anchorRect(layer.ownerDocument, pin);
1859
2820
  if (rect === null) return;
1860
2821
  const spot = pin.target?.spot;
1861
- const n = pin.n ?? i + 1;
1862
- placed.push(spot === void 0 ? {
1863
- pin,
1864
- n,
1865
- rect
1866
- } : {
1867
- pin,
1868
- n,
1869
- rect,
1870
- spot
1871
- });
1872
- });
1873
- const keys = new Set(placed.map((entry) => entry.pin.id));
1874
- if (state.draft) keys.add("draft");
1875
- for (const node of [...layer.children]) if (!keys.has(node.getAttribute("data-pin") ?? "")) node.remove();
1876
- for (const { pin, n, rect, spot } of placed) {
1877
- const node = ensureNode(layer, pin.id, false);
1878
- const hot = pin.id === state.activePinId;
1879
- const queued = state.queuedIds.has(pin.id);
1880
- node.classList.toggle("queued", queued);
1881
- patchNode(node, pinPoint(rect, spot), hot, chipInner(n, pin, queued));
1882
- }
1883
- if (state.draft) patchNode(ensureNode(layer, "draft", true), state.draft.placedAt, true, chipInner(nextOrdinal(state.pins), null));
1884
- }
1885
- //#endregion
1886
- //#region src/ui/card.ts
1887
- const STATUS_LABEL = {
1888
- open: "OPEN",
1889
- waiting: "OPEN",
1890
- replied: "REPLIED",
1891
- resolved: "RESOLVED",
1892
- verify: "VERIFY"
1893
- };
1894
- const CHECK_ICON = "<svg width=\"13\" height=\"13\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M3 8.5l3.2 3.2L13 4.8\"/></svg>";
1895
- const X_ICON$1 = "<svg width=\"13\" height=\"13\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 4l8 8M12 4l-8 8\"/></svg>";
1896
- const COPY_ICON = "<svg width=\"13\" height=\"13\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\"><rect x=\"5.5\" y=\"5.5\" width=\"8\" height=\"8\" rx=\"1\"/><path d=\"M10.5 3.5v-1a1 1 0 00-1-1h-6a1 1 0 00-1 1v6a1 1 0 001 1h1\"/></svg>";
1897
- const ctxByCard = /* @__PURE__ */ new WeakMap();
1898
- /** The prototype's `_h` innerHTML memo, kept off the DOM node. */
1899
- const nodeMemo = /* @__PURE__ */ new WeakMap();
1900
- function timeOf(at) {
1901
- const d = new Date(at);
1902
- if (Number.isNaN(d.getTime())) return "";
1903
- return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
1904
- }
1905
- function isImage(att) {
1906
- if (att.contentType?.startsWith("image/")) return true;
1907
- return /\.(png|webp|jpe?g|gif)$/i.test(att.url ?? att.path ?? "");
1908
- }
1909
- function fileName(att) {
1910
- const source = att.url ?? att.path ?? att.id;
1911
- return source.split("/").pop() ?? source;
1912
- }
1913
- /** Thumbnail when the attachment is an image; the error listener degrades it to a chip. */
1914
- function attachmentsHtml(m) {
1915
- if (!m.attachments?.length) return "";
1916
- return `<div class="atts">${m.attachments.map((att) => isImage(att) ? `<span class="pb-att"><img src="${esc(safeUrl(att.url ?? att.path ?? ""))}" alt="${esc(fileName(att))}" loading="lazy"></span>` : `<span class="pb-att-chip">${esc(fileName(att))}</span>`).join("")}</div>`;
1917
- }
1918
- /** "claude:lark-mac-agent" → "Claude · lark-mac-agent"; other shapes verbatim. */
1919
- function agentName(origin) {
1920
- const idx = origin.indexOf(":");
1921
- if (idx <= 0) return origin;
1922
- const agent = origin.slice(0, idx);
1923
- return `${agent.charAt(0).toUpperCase()}${agent.slice(1)} · ${origin.slice(idx + 1)}`;
1924
- }
1925
- function messageHtml(m) {
1926
- if (m.role === "agent") return `<div class="pb-msg"><div class="pb-av agent">AI</div><div class="col"><div class="line"><span class="who">${esc(m.origin === void 0 ? "Agent" : agentName(m.origin))}</span><span class="tm">${esc(timeOf(m.at))}</span></div><div class="txt">${esc(m.text)}</div>${attachmentsHtml(m)}</div></div>`;
1927
- const mirror = m.role === "mirror";
1928
- const origin = mirror ? m.origin ?? "mirror" : null;
1929
- const who = origin ? origin.split(":")[1] ?? origin : "You";
1930
- const initials = who.slice(0, 2).toUpperCase();
1931
- const via = origin ? `<span class="via-tag"><span>${esc(origin)}</span></span>` : "";
1932
- 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>`;
1933
- }
1934
- /** The agent has the message and has not answered yet. Its own node, so patching never rebuilds. */
1935
- 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>";
1936
- /**
1937
- * Show or hide the "working on it" row.
1938
- *
1939
- * Without it the card sits silent from the moment you comment until the answer lands, which reads
1940
- * as nothing happening — the single most common report on the demo.
1941
- */
1942
- function patchTyping(threadEl, pending) {
1943
- const existing = threadEl.querySelector("[data-iid=\"pb-typing\"]");
1944
- if (!pending) {
1945
- existing?.remove();
1946
- return;
1947
- }
1948
- if (existing) {
1949
- threadEl.appendChild(existing);
1950
- return;
1951
- }
1952
- const node = threadEl.ownerDocument.createElement("div");
1953
- node.className = "pb-msg-w";
1954
- node.setAttribute("data-iid", "pb-typing");
1955
- node.innerHTML = TYPING_HTML;
1956
- threadEl.appendChild(node);
1957
- threadEl.scrollTop = threadEl.scrollHeight;
1958
- }
1959
- /** Keyed thread patching: appends/patches [data-iid] nodes only, never rebuilds. */
1960
- function patchThread(threadEl, messages) {
1961
- let appended = false;
1962
- for (const m of messages) {
1963
- let node = threadEl.querySelector(`[data-iid="${m.id}"]`);
1964
- const html = messageHtml(m);
1965
- if (!node) {
1966
- node = threadEl.ownerDocument.createElement("div");
1967
- node.className = "pb-msg-w";
1968
- node.setAttribute("data-iid", m.id);
1969
- node.innerHTML = html;
1970
- nodeMemo.set(node, html);
1971
- threadEl.appendChild(node);
1972
- appended = true;
1973
- } else if (nodeMemo.get(node) !== html) {
1974
- node.innerHTML = html;
1975
- nodeMemo.set(node, html);
1976
- }
1977
- }
1978
- if (appended) threadEl.scrollTop = threadEl.scrollHeight;
2822
+ const n = pin.n ?? i + 1;
2823
+ placed.push(spot === void 0 ? {
2824
+ pin,
2825
+ n,
2826
+ rect
2827
+ } : {
2828
+ pin,
2829
+ n,
2830
+ rect,
2831
+ spot
2832
+ });
2833
+ });
2834
+ const keys = new Set(placed.map((entry) => entry.pin.id));
2835
+ if (state.draft) keys.add("draft");
2836
+ for (const node of [...layer.children]) if (!keys.has(node.getAttribute("data-pin") ?? "")) node.remove();
2837
+ for (const { pin, n, rect, spot } of placed) {
2838
+ const node = ensureNode(layer, pin.id, false);
2839
+ const hot = pin.id === state.activePinId;
2840
+ const queued = state.queuedIds.has(pin.id);
2841
+ node.classList.toggle("queued", queued);
2842
+ node.classList.toggle("note", pin.kind === "comment");
2843
+ const stale = deriveUiStatus(pin, state.threads.get(pin.id) ?? [], state) === "stale";
2844
+ node.classList.toggle("stale", stale);
2845
+ patchNode(node, pinPoint(rect, spot), hot, chipInner(n, pin, queued, stale));
2846
+ }
2847
+ const draftAt = state.draft ? draftPoint(layer.ownerDocument, state.draft) : null;
2848
+ if (draftAt === null) layer.querySelector("[data-pin=\"draft\"]")?.remove();
2849
+ if (state.draft && draftAt !== null) patchNode(ensureNode(layer, "draft", true), draftAt, true, chipInner(nextOrdinal(state.pins), null));
1979
2850
  }
2851
+ //#endregion
2852
+ //#region src/ui/card.ts
2853
+ const ctxByCard = /* @__PURE__ */ new WeakMap();
2854
+ /** .pb-card width (styles.ts). */
2855
+ const CARD_W = 344;
1980
2856
  function ensureShell(root) {
1981
2857
  let card = root.querySelector(".pb-card");
1982
2858
  if (!card) {
@@ -1989,7 +2865,8 @@ var Pinbox = (function(exports) {
1989
2865
  const ctx = {
1990
2866
  pid: null,
1991
2867
  parts: {},
1992
- actions: null
2868
+ actions: null,
2869
+ draftKind: "note"
1993
2870
  };
1994
2871
  ctxByCard.set(card, ctx);
1995
2872
  card.addEventListener("click", (e) => onCardClick(card, ctx, e));
@@ -2000,29 +2877,55 @@ var Pinbox = (function(exports) {
2000
2877
  const ta = card.querySelector("textarea");
2001
2878
  const text = ta?.value.trim();
2002
2879
  if (!ta || !text || !ctx.pid) return;
2003
- ctx.actions.send(ctx.pid === "draft" ? "draft" : ctx.pid, text);
2880
+ ctx.actions.send(ctx.pid === "draft" ? "draft" : ctx.pid, text, ctx.draftKind);
2004
2881
  ta.value = "";
2005
2882
  }
2883
+ /** The draft's kind control: pick, redraw the row, keep typing. */
2884
+ function onKindPick(card, ctx, kind) {
2885
+ ctx.draftKind = kind;
2886
+ setPart(card, ctx, "row", rowHtml(false, true, kind));
2887
+ card.querySelector("textarea")?.focus();
2888
+ }
2889
+ /** A moment of green on the copy button is the whole receipt — there is no toast layer,
2890
+ * and the header part-memo never resets a class toggle. */
2891
+ function flashCopied(card, from) {
2892
+ const btn = from.closest?.("[data-action=\"copy\"]");
2893
+ if (!btn) return;
2894
+ btn.classList.add("ok");
2895
+ card.ownerDocument.defaultView?.setTimeout(() => btn.classList.remove("ok"), 900);
2896
+ }
2897
+ /** Actions that need a committed pin (a draft has no id to act on). */
2898
+ const PIN_ACTIONS = {
2899
+ resolve: (_card, ctx, pid) => ctx.actions.resolve(pid),
2900
+ nudge: (_card, ctx, pid) => ctx.actions.nudge(pid),
2901
+ copy: (card, ctx, pid, from) => {
2902
+ ctx.actions.copy(pid);
2903
+ flashCopied(card, from);
2904
+ },
2905
+ "verify-accept": (_card, ctx, pid) => ctx.actions.verify(pid, "accepted"),
2906
+ "verify-reopen": (card, ctx, pid) => {
2907
+ ctx.actions.verify(pid, "reopened");
2908
+ card.querySelector("textarea")?.focus();
2909
+ }
2910
+ };
2006
2911
  function onCardClick(card, ctx, e) {
2007
- const action = e.target.closest?.("[data-action]")?.getAttribute("data-action");
2912
+ const from = e.target;
2913
+ const kind = from.closest?.("[data-kind]")?.getAttribute("data-kind");
2914
+ if (kind === "note" || kind === "comment") {
2915
+ onKindPick(card, ctx, kind);
2916
+ return;
2917
+ }
2918
+ const action = from.closest?.("[data-action]")?.getAttribute("data-action");
2008
2919
  if (!action || !ctx.pid) return;
2009
- if (action === "send") submit(card, ctx);
2010
- else if (action === "close") ctx.actions.close();
2011
- else if (ctx.pid !== "draft") {
2012
- if (action === "resolve") ctx.actions.resolve(ctx.pid);
2013
- else if (action === "copy") {
2014
- ctx.actions.copy(ctx.pid);
2015
- const btn = e.target.closest?.("[data-action=\"copy\"]");
2016
- if (btn) {
2017
- btn.classList.add("ok");
2018
- card.ownerDocument.defaultView?.setTimeout(() => btn.classList.remove("ok"), 900);
2019
- }
2020
- } else if (action === "verify-accept") ctx.actions.verify(ctx.pid, "accepted");
2021
- else if (action === "verify-reopen") {
2022
- ctx.actions.verify(ctx.pid, "reopened");
2023
- card.querySelector("textarea")?.focus();
2024
- }
2920
+ if (action === "send") {
2921
+ submit(card, ctx);
2922
+ return;
2025
2923
  }
2924
+ if (action === "close") {
2925
+ ctx.actions.close();
2926
+ return;
2927
+ }
2928
+ if (ctx.pid !== "draft") PIN_ACTIONS[action]?.(card, ctx, ctx.pid, from);
2026
2929
  }
2027
2930
  function buildSkeleton(card, ctx, isDraft, hasThread) {
2028
2931
  card.innerHTML = "<div class=\"in\"><div class=\"pb-hd\" data-ref=\"hd\"></div><div data-ref=\"link\"></div><div data-ref=\"loci\"></div><div class=\"pb-thread\" data-ref=\"thread\"></div><div data-ref=\"verify\"></div><div class=\"pb-composer\"><textarea rows=\"2\"></textarea><div class=\"row\" data-ref=\"row\"></div></div></div>";
@@ -2041,52 +2944,23 @@ var Pinbox = (function(exports) {
2041
2944
  }, true);
2042
2945
  if (isDraft) ta.focus();
2043
2946
  }
2044
- function hdHtml(n, targetLabel, status, resolvable, copyable) {
2045
- return `<div class="meta"><span class="num">${pinNumber(n)}</span><span>${esc(targetLabel)}</span><span class="st">${esc(status)}</span></div><div style="display:flex;gap:2px">` + (copyable ? `<button type="button" class="pb-ico" data-action="copy" title="Copy this pin">${COPY_ICON}</button>` : "") + (resolvable ? `<button type="button" class="pb-ico ok" data-action="resolve" title="Resolve (R)">${CHECK_ICON}</button>` : "") + `<button type="button" class="pb-ico" data-action="close" title="Close (Esc)">${X_ICON$1}</button></div>`;
2046
- }
2047
- /** One name per locus: selector first, else anchor, else tag. */
2048
- function locusName(t) {
2049
- return t.selector ?? t.anchor ?? t.tag?.toUpperCase();
2050
- }
2051
- /** The extra loci of a multi-target pin — the anchor leads, extras follow. */
2052
- function lociHtml(pin) {
2053
- const target = pin?.target;
2054
- const extras = target?.targets;
2055
- if (target === void 0 || extras === void 0 || extras.length === 0) return "";
2056
- const names = [target, ...extras].map((t) => esc(locusName(t) ?? "?"));
2057
- return `<div class="pb-loci">${names.length} targets: ${names.join(" · ")}</div>`;
2058
- }
2059
- /** Link badge: pin.links[0] read-only — no picker, no unlink yet. */
2060
- function linkHtml(pin) {
2061
- const link = pin?.links?.[0];
2062
- if (!link) return "";
2063
- return `<div class="pb-linkbar"><span class="ch">${esc(link.connector)}</span><span class="mt">${esc(link.ref)}</span><span class="sp"></span><a class="pb-open" href="${esc(safeUrl(link.url))}" target="_blank" rel="noreferrer">OPEN</a></div>`;
2064
- }
2065
- function verifyHtml(status) {
2066
- if (status === "verify") return "<div class=\"pb-verify\"><button type=\"button\" class=\"pb-bt-ok\" data-action=\"verify-accept\">Looks good</button><button type=\"button\" class=\"pb-bt-ghost\" data-action=\"verify-reopen\">Reopen</button></div>";
2067
- if (status === "resolved") return "<div class=\"pb-verify\"><button type=\"button\" class=\"pb-bt-ghost\" data-action=\"verify-reopen\">Unresolve</button></div>";
2068
- return "";
2069
- }
2070
- function rowHtml(hasThread) {
2071
- return `<div class="pb-kbd">⌘ ↵</div><button type="button" class="pb-bt-solid" data-action="send">${hasThread ? "Reply" : "Comment"}</button>`;
2072
- }
2073
2947
  /**
2074
- * Viewport-aware placement, ported verbatim (prototype lines 660–668): measure
2075
- * the rendered card, flip left when it would overflow right, clamp between
2076
- * scrollY + margin and the command-bar clearance — never off-screen.
2948
+ * Viewport-aware placement, ported from the prototype (lines 660–668) into viewport space:
2949
+ * measure the rendered card, flip left when it would overflow right, clamp between the margin
2950
+ * and the command-bar clearance — never off-screen.
2077
2951
  */
2078
2952
  function position(card, at) {
2079
2953
  const win = card.ownerDocument.defaultView;
2080
2954
  if (!win) return;
2081
- const W = 344;
2955
+ const W = CARD_W;
2082
2956
  const m = 12;
2083
2957
  const barClear = 84;
2084
2958
  let left = at.x + 22;
2085
- if (left + W > win.scrollX + win.innerWidth - m) left = at.x - W - 22;
2086
- left = Math.max(win.scrollX + m, left);
2959
+ if (left + W > win.innerWidth - m) left = at.x - W - 22;
2960
+ left = Math.max(m, left);
2087
2961
  const h = card.querySelector(".in")?.offsetHeight ?? 0;
2088
- const minTop = win.scrollY + m;
2089
- const maxTop = win.scrollY + win.innerHeight - h - barClear;
2962
+ const minTop = m;
2963
+ const maxTop = win.innerHeight - h - barClear;
2090
2964
  card.style.left = `${left}px`;
2091
2965
  card.style.top = `${Math.max(minTop, Math.min(at.y - 60, maxTop))}px`;
2092
2966
  }
@@ -2108,15 +2982,35 @@ var Pinbox = (function(exports) {
2108
2982
  if (pin.n !== void 0) return pin.n;
2109
2983
  return state.pins.filter((p) => p.status !== "resolved" || p.id === state.activePinId).indexOf(pin) + 1;
2110
2984
  }
2111
- function anchorOf(pin, draft) {
2112
- const r = pin?.target?.rect;
2113
- if (!r) return draft?.placedAt ?? {
2985
+ /**
2986
+ * Where the card tethers, in viewport space. The LIVE anchor when the pin's element is on this
2987
+ * view; otherwise (other URL, selector gone, a terminal `pinbox pin`) the middle of the viewport
2988
+ * — dogfood: the stored rect of a pin whose view had moved on put the card off-screen, so a pin
2989
+ * listed in the drawer could be opened but never seen, let alone resolved.
2990
+ */
2991
+ function anchorOf(root, pin, draft) {
2992
+ const doc = root.ownerDocument;
2993
+ const win = doc.defaultView;
2994
+ let live = null;
2995
+ if (pin !== null) {
2996
+ const r = anchorRect(doc, pin);
2997
+ live = r === null ? null : {
2998
+ x: r.x + r.width / 2,
2999
+ y: r.y + r.height / 2
3000
+ };
3001
+ } else if (draft !== null) live = draftPoint(doc, draft);
3002
+ else return {
3003
+ x: 0,
3004
+ y: 0
3005
+ };
3006
+ if (live !== null) return live;
3007
+ if (win === null) return {
2114
3008
  x: 0,
2115
3009
  y: 0
2116
3010
  };
2117
3011
  return {
2118
- x: r.x + r.width / 2,
2119
- y: r.y + r.height / 2
3012
+ x: win.innerWidth / 2 - CARD_W / 2 - 22,
3013
+ y: win.innerHeight / 3 + 60
2120
3014
  };
2121
3015
  }
2122
3016
  /**
@@ -2126,7 +3020,7 @@ var Pinbox = (function(exports) {
2126
3020
  function labelOf(target) {
2127
3021
  return target?.anchor ?? target?.tag?.toUpperCase() ?? "PIN";
2128
3022
  }
2129
- function viewOf(state) {
3023
+ function viewOf(root, state) {
2130
3024
  const pin = activePin(state);
2131
3025
  const pid = pin?.id ?? (state.draft ? "draft" : null);
2132
3026
  if (!pid) return null;
@@ -2136,9 +3030,9 @@ var Pinbox = (function(exports) {
2136
3030
  pin,
2137
3031
  thread,
2138
3032
  n: ordinalOf(state, pin),
2139
- status: pin ? deriveUiStatus(pin, thread) : null,
3033
+ status: pin ? deriveUiStatus(pin, thread, state) : null,
2140
3034
  label: labelOf(pin?.target ?? state.draft?.target.target),
2141
- at: anchorOf(pin, state.draft)
3035
+ at: anchorOf(root, pin, state.draft)
2142
3036
  };
2143
3037
  }
2144
3038
  /** Render the thread card for a state snapshot: the active pin, or the draft. */
@@ -2162,7 +3056,7 @@ var Pinbox = (function(exports) {
2162
3056
  const card = ensureShell(root);
2163
3057
  const ctx = ctxByCard.get(card);
2164
3058
  ctx.actions = actions;
2165
- const view = viewOf(state);
3059
+ const view = viewOf(root, state);
2166
3060
  if (!view) {
2167
3061
  card.hidden = true;
2168
3062
  ctx.pid = null;
@@ -2171,6 +3065,7 @@ var Pinbox = (function(exports) {
2171
3065
  if (ctx.pid !== view.pid) {
2172
3066
  ctx.pid = view.pid;
2173
3067
  ctx.parts = {};
3068
+ ctx.draftKind = "note";
2174
3069
  buildSkeleton(card, ctx, view.pid === "draft", view.pin !== null || view.thread.length > 0);
2175
3070
  }
2176
3071
  card.hidden = false;
@@ -2180,32 +3075,40 @@ var Pinbox = (function(exports) {
2180
3075
  setPart(card, ctx, "hd", hdHtml(view.n, view.label, statusLabel, resolvable, view.pin !== null));
2181
3076
  setPart(card, ctx, "link", linkHtml(view.pin));
2182
3077
  setPart(card, ctx, "loci", lociHtml(view.pin));
2183
- setPart(card, ctx, "verify", verifyHtml(view.status));
3078
+ setPart(card, ctx, "verify", resolutionHtml(view.pin) + staleHtml(view.status) + verifyHtml(view.status));
2184
3079
  const messages = view.pin === null ? view.thread : [pinAsMessage(view.pin), ...view.thread];
2185
- setPart(card, ctx, "row", rowHtml(messages.length > 0));
3080
+ setPart(card, ctx, "row", rowHtml(messages.length > 0, view.pid === "draft", ctx.draftKind));
2186
3081
  const threadEl = card.querySelector("[data-ref=\"thread\"]");
2187
3082
  if (threadEl) {
2188
3083
  patchThread(threadEl, messages);
2189
- patchTyping(threadEl, !queued && view.pin?.status === "open" && view.status === "waiting");
3084
+ patchPending(threadEl, queued || view.pin === null ? "none" : pendingKind(view.pin, view.thread, state));
2190
3085
  }
2191
3086
  position(card, view.at);
2192
3087
  }
2193
3088
  //#endregion
2194
3089
  //#region src/ui/drawer.ts
2195
3090
  const X_ICON = "<svg width=\"13\" height=\"13\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 4l8 8M12 4l-8 8\"/></svg>";
3091
+ const CHECK_ICON = "<svg width=\"13\" height=\"13\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M3 8.5l3.2 3.2L13 4.8\"/></svg>";
3092
+ const UNDO_ICON = "<svg width=\"13\" height=\"13\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M3.5 7.5h6a3 3 0 010 6H6\"/><path d=\"M6 4.5l-3 3 3 3\"/></svg>";
3093
+ /** How long a first click on Resolve-group waits for its confirming second click. */
3094
+ const CONFIRM_MS = 3e3;
2196
3095
  const STATUS_TEXT = {
2197
3096
  open: "OPEN",
2198
3097
  waiting: "OPEN",
2199
3098
  replied: "REPLIED",
2200
3099
  resolved: "RESOLVED",
2201
- verify: "VERIFY"
3100
+ verify: "VERIFY",
3101
+ note: "NOTE",
3102
+ stale: "NO RESPONSE"
2202
3103
  };
2203
3104
  const STATUS_DOT = {
2204
3105
  open: "var(--pb-fg4)",
2205
3106
  waiting: "var(--pb-fg4)",
2206
3107
  replied: "var(--pb-info)",
2207
3108
  resolved: "var(--pb-ok)",
2208
- verify: "var(--pb-amber)"
3109
+ verify: "var(--pb-amber)",
3110
+ note: "var(--pb-fg3)",
3111
+ stale: "var(--pb-amber)"
2209
3112
  };
2210
3113
  /**
2211
3114
  * The place a row names. A browser pin has a selector; a terminal `pinbox pin` has a
@@ -2214,44 +3117,138 @@ var Pinbox = (function(exports) {
2214
3117
  function locusOf(pin) {
2215
3118
  return pin.target?.selector ?? pin.target?.source?.file ?? "";
2216
3119
  }
2217
- function itemHtml(pin, n, active, thread, queued) {
2218
- const status = deriveUiStatus(pin, thread);
3120
+ /** "github#58" the same label `pinbox link` prints. */
3121
+ function linkKey(pin) {
2219
3122
  const link = pin.links?.[0];
2220
- return `<button type="button" class="pb-item${active ? " on" : ""}" data-item="${esc(pin.id)}"><span class="nn">${pinNumber(n)}</span><span class="cc"><span class="tt">${esc(pin.text)}</span><span class="mm"><span class="sdot" style="background:${queued ? "var(--pb-amber)" : STATUS_DOT[status]}"></span><span>${queued ? "QUEUED" : STATUS_TEXT[status]}</span>` + (link ? `<span class="lk">${esc(link.connector)}</span>` : "") + `<span>${esc(locusOf(pin))}</span></span></span></button>`;
3123
+ return link === void 0 ? null : `${link.connector}#${link.ref}`;
3124
+ }
3125
+ function itemHtml(pin, n, active, thread, queued, view) {
3126
+ const status = deriveUiStatus(pin, thread, view);
3127
+ const link = pin.links?.[0];
3128
+ const act = pin.status === "resolved" ? `<button type="button" class="pb-ico" data-act="unresolve" title="Unresolve">${UNDO_ICON}</button>` : queued ? "" : `<button type="button" class="pb-ico ok" data-act="resolve" title="Resolve">${CHECK_ICON}</button>`;
3129
+ return `<div class="pb-item${active ? " on" : ""}" data-item="${esc(pin.id)}"><button type="button" class="pb-item-main" data-open="${esc(pin.id)}"><span class="nn">${pinNumber(n)}</span><span class="cc"><span class="tt">${esc(pin.text)}</span><span class="mm"><span class="sdot" style="background:${queued ? "var(--pb-amber)" : STATUS_DOT[status]}"></span><span>${queued ? "QUEUED" : STATUS_TEXT[status]}</span>` + (link ? `<span class="lk">${esc(link.connector)}</span>` : "") + `<span>${esc(locusOf(pin))}</span></span></span></button><span class="acts">${act}</span></div>`;
3130
+ }
3131
+ function groupHtml(key, pins, confirming) {
3132
+ const link = pins[0]?.links?.[0];
3133
+ const open = link ? `<a class="pb-open" href="${esc(safeUrl(link.url))}" target="_blank" rel="noreferrer">OPEN</a>` : "";
3134
+ const label = confirming ? "CONFIRM?" : `RESOLVE ${pins.length}`;
3135
+ return `<div class="pb-group"><span class="gk">${esc(link?.connector ?? "")}</span><span class="gr">#${esc(link?.ref ?? "")}</span><span class="sp"></span>${open}<button type="button" class="pb-gres${confirming ? " confirm" : ""}" data-group-resolve="${esc(key)}" title="Resolve every pin linked to ${esc(key)}">${label}</button></div>`;
3136
+ }
3137
+ /** Open pins split into the ungrouped list and one group per tracker item, in first-seen order. */
3138
+ function groupByLink(open) {
3139
+ const loose = [];
3140
+ const groups = /* @__PURE__ */ new Map();
3141
+ for (const pin of open) {
3142
+ const key = linkKey(pin);
3143
+ if (key === null) loose.push(pin);
3144
+ else groups.set(key, [...groups.get(key) ?? [], pin]);
3145
+ }
3146
+ return {
3147
+ loose,
3148
+ groups
3149
+ };
2221
3150
  }
2222
3151
  function createDrawer(doc, on) {
2223
3152
  const root = doc.createElement("div");
2224
3153
  root.className = "pb-drawer";
2225
3154
  root.hidden = true;
2226
- root.innerHTML = `<div class="dh"><span>INBOX</span><button type="button" class="pb-ico" data-ref="close" title="Close">${X_ICON}</button></div><div class="pb-tabs"><button type="button" class="pb-tab on" data-tab="open">OPEN · 0</button><button type="button" class="pb-tab" data-tab="resolved">RESOLVED · 0</button></div><div class="pb-items" data-ref="items"></div>`;
3155
+ root.innerHTML = `<div class="dh"><span>INBOX</span><button type="button" class="pb-ico" data-ref="close" title="Close">${X_ICON}</button></div><div class="pb-tabs"><button type="button" class="pb-tab on" data-tab="open">OPEN · 0</button><button type="button" class="pb-tab" data-tab="notes">NOTES · 0</button><button type="button" class="pb-tab" data-tab="resolved">RESOLVED · 0</button></div><div class="pb-items" data-ref="items"></div><div class="pb-dfoot" data-ref="foot" hidden></div>`;
2227
3156
  let tab = "open";
2228
3157
  let last = null;
2229
3158
  let itemsMemo = "";
3159
+ /** Group key awaiting its confirming click, if any. */
3160
+ let confirming = null;
3161
+ let confirmTimer = 0;
2230
3162
  const items = root.querySelector("[data-ref=\"items\"]");
3163
+ const foot = root.querySelector("[data-ref=\"foot\"]");
3164
+ let footMemo = "";
2231
3165
  const tabButtons = [...root.querySelectorAll("[data-tab]")];
3166
+ const win = doc.defaultView;
2232
3167
  root.querySelector("[data-ref=\"close\"]")?.addEventListener("click", on.onClose);
2233
3168
  for (const btn of tabButtons) btn.addEventListener("click", () => {
2234
3169
  tab = btn.getAttribute("data-tab");
2235
3170
  if (last) render(last);
2236
3171
  });
3172
+ function setConfirming(key) {
3173
+ confirming = key;
3174
+ win?.clearTimeout(confirmTimer);
3175
+ if (key !== null) confirmTimer = win?.setTimeout(() => setConfirming(null), CONFIRM_MS) ?? 0;
3176
+ if (last) render(last);
3177
+ }
3178
+ function resolveGroup(key) {
3179
+ if (confirming !== key) {
3180
+ setConfirming(key);
3181
+ return;
3182
+ }
3183
+ setConfirming(null);
3184
+ const pins = last?.pins.filter((p) => p.status === "open" && linkKey(p) === key) ?? [];
3185
+ for (const pin of pins) on.onResolve(pin.id, `shipped in ${key}`);
3186
+ }
3187
+ /** Every open pin the agent never answered (dogfood #6–10): one click clears the backlog. */
3188
+ function stalePins(state) {
3189
+ return state.pins.filter((p) => p.status === "open" && !state.queuedIds.has(p.id) && deriveUiStatus(p, state.threads.get(p.id) ?? [], state) === "stale");
3190
+ }
3191
+ function resolveStale() {
3192
+ if (confirming !== "stale") {
3193
+ setConfirming("stale");
3194
+ return;
3195
+ }
3196
+ setConfirming(null);
3197
+ for (const pin of last ? stalePins(last) : []) on.onResolve(pin.id, "no agent response");
3198
+ }
3199
+ foot.addEventListener("click", (e) => {
3200
+ if (e.target.closest?.("[data-bulk]")) resolveStale();
3201
+ });
2237
3202
  items.addEventListener("click", (e) => {
2238
- const id = e.target.closest?.("[data-item]")?.getAttribute("data-item");
3203
+ const target = e.target;
3204
+ const act = target.closest?.("[data-act]");
3205
+ if (act) {
3206
+ const id = act.closest("[data-item]")?.getAttribute("data-item");
3207
+ if (!id) return;
3208
+ if (act.getAttribute("data-act") === "resolve") on.onResolve(id);
3209
+ else on.onUnresolve(id);
3210
+ return;
3211
+ }
3212
+ const group = target.closest?.("[data-group-resolve]")?.getAttribute("data-group-resolve");
3213
+ if (group) {
3214
+ resolveGroup(group);
3215
+ return;
3216
+ }
3217
+ const id = target.closest?.("[data-open]")?.getAttribute("data-open");
2239
3218
  if (id) on.onActivate(id);
2240
3219
  });
3220
+ const rowOf = (state) => (p) => itemHtml(p, p.n ?? state.pins.indexOf(p) + 1, p.id === state.activePinId, state.threads.get(p.id) ?? [], state.queuedIds.has(p.id), state);
3221
+ function openHtml(state, open) {
3222
+ const row = rowOf(state);
3223
+ const { loose, groups } = groupByLink(open);
3224
+ let html = loose.map(row).join("");
3225
+ for (const [key, pins] of groups) html += groupHtml(key, pins, confirming === key) + pins.map(row).join("");
3226
+ return html;
3227
+ }
3228
+ /** The bulk footer: present on the OPEN tab exactly while some pin has had no response. */
3229
+ function renderFoot(state) {
3230
+ const stale = tab === "open" ? stalePins(state) : [];
3231
+ const html = stale.length === 0 ? "" : `<button type="button" class="pb-gres${confirming === "stale" ? " confirm" : ""}" data-bulk="stale">${confirming === "stale" ? "CONFIRM?" : `RESOLVE ${stale.length} WITH NO RESPONSE`}</button>`;
3232
+ if (footMemo === html) return;
3233
+ foot.innerHTML = html;
3234
+ foot.hidden = html === "";
3235
+ footMemo = html;
3236
+ }
2241
3237
  function render(state) {
2242
- const open = state.pins.filter((p) => p.status === "open");
2243
- const resolved = state.pins.filter((p) => p.status === "resolved");
2244
- const [openTab, doneTab] = tabButtons;
2245
- if (openTab) {
2246
- openTab.textContent = `OPEN · ${open.length}`;
2247
- openTab.classList.toggle("on", tab === "open");
2248
- }
2249
- if (doneTab) {
2250
- doneTab.textContent = `RESOLVED · ${resolved.length}`;
2251
- doneTab.classList.toggle("on", tab === "resolved");
3238
+ const open = state.pins.filter((p) => p.status === "open" && p.kind !== "comment");
3239
+ const counts = {
3240
+ open,
3241
+ notes: state.pins.filter((p) => p.status === "open" && p.kind === "comment"),
3242
+ resolved: state.pins.filter((p) => p.status === "resolved")
3243
+ };
3244
+ for (const btn of tabButtons) {
3245
+ const key = btn.getAttribute("data-tab");
3246
+ btn.textContent = `${key.toUpperCase()} · ${counts[key].length}`;
3247
+ btn.classList.toggle("on", tab === key);
2252
3248
  }
2253
- const list = tab === "open" ? open : resolved;
2254
- const html = list.length ? list.map((p) => itemHtml(p, p.n ?? state.pins.indexOf(p) + 1, p.id === state.activePinId, state.threads.get(p.id) ?? [], state.queuedIds.has(p.id))).join("") : "<div class=\"pb-empty\">Nothing here yet.</div>";
3249
+ renderFoot(state);
3250
+ const list = counts[tab];
3251
+ const html = list.length ? tab === "open" ? openHtml(state, open) : list.map(rowOf(state)).join("") : "<div class=\"pb-empty\">Nothing here yet.</div>";
2255
3252
  if (itemsMemo !== html) {
2256
3253
  items.innerHTML = html;
2257
3254
  itemsMemo = html;
@@ -2284,36 +3281,17 @@ var Pinbox = (function(exports) {
2284
3281
  };
2285
3282
  }
2286
3283
  //#endregion
2287
- //#region src/ui/multimarks.ts
2288
- const MARK_CLASS = "pb-multi-mark";
2289
- /** Replace the mark set to mirror `targets`; entries with no rect draw nothing. */
2290
- function renderMultiMarks(layer, targets) {
2291
- for (const node of [...layer.querySelectorAll(`.${MARK_CLASS}`)]) node.remove();
2292
- targets.forEach((target, i) => {
2293
- const rect = target.rect;
2294
- if (rect === void 0) return;
2295
- const mark = layer.ownerDocument.createElement("div");
2296
- mark.className = MARK_CLASS;
2297
- mark.style.left = `${rect.x}px`;
2298
- mark.style.top = `${rect.y}px`;
2299
- mark.style.width = `${rect.width}px`;
2300
- mark.style.height = `${rect.height}px`;
2301
- mark.innerHTML = `<span>${i + 1}</span>`;
2302
- layer.appendChild(mark);
2303
- });
2304
- }
2305
- //#endregion
2306
3284
  //#region src/ui/puck.ts
2307
3285
  /** The bar's ident mark, sized up for the 48px puck face. */
2308
3286
  const PUCK_ICON = "<svg width=\"17\" height=\"17\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\"><rect x=\"2.5\" y=\"1.5\" width=\"11\" height=\"7\" rx=\"1\"/><path d=\"M8 8.5v6\"/><circle cx=\"8\" cy=\"14.6\" r=\".9\" fill=\"currentColor\" stroke=\"none\"/></svg>";
2309
- const FAN_PIN = "<svg width=\"15\" height=\"15\" 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>";
2310
- const FAN_INBOX = "<svg width=\"15\" height=\"15\" 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>";
2311
- const FAN_THEME = "<svg width=\"15\" height=\"15\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\"><path d=\"M8 2a4 4 0 0 0 6 6 6 6 0 1 1-6-6z\"/></svg>";
2312
- const FAN_EYE = "<svg width=\"15\" height=\"15\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\"><path d=\"M1.6 8S4 3.8 8 3.8 14.4 8 14.4 8 12 12.2 8 12.2 1.6 8 1.6 8z\"/><circle cx=\"8\" cy=\"8\" r=\"1.8\"/></svg>";
2313
- const FAN_EYE_OFF = "<svg width=\"15\" height=\"15\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\"><path d=\"M2.3 2.3l11.4 11.4\"/><path d=\"M4.9 4.9C2.7 6.2 1.6 8 1.6 8s2.4 4.2 6.4 4.2c1.2 0 2.3-.3 3.1-.8M6.7 4c.4-.1.9-.2 1.3-.2 4 0 6.4 4.2 6.4 4.2s-.8 1.4-2.2 2.5\"/></svg>";
2314
- const FAN_EXPAND = "<svg width=\"15\" height=\"15\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\"><path d=\"M9.5 6.5v-4h4\"/><path d=\"M6.5 9.5v4h-4\"/></svg>";
2315
- function fanItem(act, index, icon, label, key) {
2316
- return `<button type="button" class="pb-fan-item" data-act="${act}" style="--i:${index}" aria-label="${label}">${icon}${act === "inbox" ? "<span class=\"badge\" data-ref=\"count\" hidden>0</span>" : ""}<span class="fl">${label.toUpperCase()}<i>${key}</i></span></button>`;
3287
+ /** Everything in the action table that asked for a fan item, in table order. */
3288
+ const FAN_ACTIONS = ACTIONS.filter((a) => a.fan !== void 0);
3289
+ function fanItem(a, index) {
3290
+ const act = a.fan?.act ?? a.id;
3291
+ const label = a.fan?.label ?? a.label;
3292
+ const glyph = act === "expand" ? EXPAND_GLYPH : a.glyph ?? "";
3293
+ const badge = a.id === "inbox" ? "<span class=\"badge\" data-ref=\"count\" hidden>0</span>" : "";
3294
+ return `<button type="button" class="pb-fan-item" data-act="${act}" style="--i:${index}" aria-label="${label}">${icon(glyph, 15)}${badge}<span class="fl">${label.toUpperCase()}<i>${keyLabelOf(a)}</i></span></button>`;
2317
3295
  }
2318
3296
  function createMinimizeUi(doc) {
2319
3297
  const puck = doc.createElement("button");
@@ -2326,7 +3304,7 @@ var Pinbox = (function(exports) {
2326
3304
  fan.className = "pb-fan";
2327
3305
  fan.hidden = true;
2328
3306
  fan.setAttribute("role", "menu");
2329
- fan.innerHTML = fanItem("pin", 0, FAN_PIN, "Drop a pin", "P") + fanItem("inbox", 1, FAN_INBOX, "Inbox", "I") + fanItem("theme", 2, FAN_THEME, "Theme", "D") + fanItem("hide", 3, FAN_EYE_OFF, "Hide pins", "H") + fanItem("expand", 4, FAN_EXPAND, "Expand", "M");
3307
+ fan.innerHTML = FAN_ACTIONS.map(fanItem).join("");
2330
3308
  const morphWrap = doc.createElement("div");
2331
3309
  morphWrap.className = "pb-morph-wrap";
2332
3310
  morphWrap.hidden = true;
@@ -2352,7 +3330,7 @@ var Pinbox = (function(exports) {
2352
3330
  surface,
2353
3331
  carrier,
2354
3332
  update(state) {
2355
- const open = String(state.pins.filter((p) => p.status !== "resolved").length);
3333
+ const open = String(openTaskCount(state.pins));
2356
3334
  for (const badge of badges) {
2357
3335
  if (badge.textContent !== open) badge.textContent = open;
2358
3336
  badge.hidden = open === "0";
@@ -2363,7 +3341,7 @@ var Pinbox = (function(exports) {
2363
3341
  if (hideShown !== state.pinsHidden) {
2364
3342
  hideShown = state.pinsHidden;
2365
3343
  const label = state.pinsHidden ? "Show pins" : "Hide pins";
2366
- hideItem.innerHTML = `${state.pinsHidden ? FAN_EYE : FAN_EYE_OFF}<span class="fl">${label.toUpperCase()}<i>H</i></span>`;
3344
+ hideItem.innerHTML = icon(state.pinsHidden ? EYE_GLYPH : EYE_OFF_GLYPH, 15) + `<span class="fl">${label.toUpperCase()}<i>H</i></span>`;
2367
3345
  hideItem.setAttribute("aria-label", label);
2368
3346
  hideItem.classList.toggle("lit", state.pinsHidden);
2369
3347
  }
@@ -2371,70 +3349,19 @@ var Pinbox = (function(exports) {
2371
3349
  };
2372
3350
  }
2373
3351
  //#endregion
2374
- //#region src/ui/reticle.ts
2375
- function createReticle(doc) {
2376
- const crosshair = doc.createElement("div");
2377
- crosshair.className = "pb-reticle";
2378
- crosshair.innerHTML = "<div class=\"h\"></div><div class=\"v\"></div><div class=\"box\"></div><div class=\"ro\"></div>";
2379
- const h = crosshair.querySelector(".h");
2380
- const v = crosshair.querySelector(".v");
2381
- const box = crosshair.querySelector(".box");
2382
- const readout = crosshair.querySelector(".ro");
2383
- const outline = doc.createElement("div");
2384
- outline.className = "pb-outline";
2385
- outline.innerHTML = "<span class=\"lab\"></span>";
2386
- const lab = outline.querySelector(".lab");
2387
- function setOutlineRect(rect, scroll) {
2388
- outline.style.left = `${rect.left + scroll.x - 5}px`;
2389
- outline.style.top = `${rect.top + scroll.y - 5}px`;
2390
- outline.style.width = `${rect.width + 10}px`;
2391
- outline.style.height = `${rect.height + 10}px`;
2392
- }
2393
- return {
2394
- crosshair,
2395
- outline,
2396
- move(pos) {
2397
- h.style.top = `${pos.clientY}px`;
2398
- v.style.left = `${pos.clientX}px`;
2399
- box.style.left = `${pos.clientX}px`;
2400
- box.style.top = `${pos.clientY}px`;
2401
- readout.style.left = `${pos.clientX}px`;
2402
- readout.style.top = `${pos.clientY}px`;
2403
- readout.textContent = `${Math.round(pos.pageX)} × ${Math.round(pos.pageY)}`;
2404
- },
2405
- snap(rect, label, scroll) {
2406
- if (!outline.classList.contains("on")) {
2407
- outline.style.transition = "none";
2408
- setOutlineRect(rect, scroll);
2409
- outline.offsetWidth;
2410
- outline.style.transition = "";
2411
- } else setOutlineRect(rect, scroll);
2412
- lab.textContent = label;
2413
- outline.classList.add("on");
2414
- },
2415
- release() {
2416
- outline.classList.remove("on");
2417
- }
2418
- };
2419
- }
2420
- //#endregion
2421
3352
  //#region src/ui/shortcuts.ts
3353
+ /** The action table's keyed entries, plus the composer chord the table cannot know about. */
2422
3354
  const ROWS = [
2423
- ["Drop a pin", "P"],
2424
- ["Open inbox", "I"],
2425
- ["Toggle theme", "D"],
3355
+ ...ACTIONS.filter((a) => a.key !== void 0 && a.help !== false).map((a) => [a.label, keyLabelOf(a)]),
2426
3356
  ["Send comment", "⌘ ↵"],
2427
- ["Mark pin resolved", "R"],
2428
- ["Copy open pins", "C"],
2429
- ["Hide / show pins", "H"],
2430
- ["Minimize toolbar", "M"],
2431
- ["Cancel", "ESC"]
3357
+ ["Move toolbar", "DRAG ⋮"],
3358
+ ["Reset toolbar position", "2× GRIP"]
2432
3359
  ];
2433
3360
  function createShortcutsModal(doc, onClose) {
2434
3361
  const root = doc.createElement("div");
2435
3362
  root.className = "pb-modal";
2436
3363
  root.hidden = true;
2437
- root.innerHTML = `<div class="mx"><div class="mh">SHORTCUTS</div><div style="padding:8px 20px 18px">${ROWS.map(([what, key]) => `<div class="mr"><span class="mw">${esc(what)}</span><span class="mk">${esc(key)}</span></div>`).join("")}</div></div>`;
3364
+ root.innerHTML = `<div class="mx"><div class="mh">SHORTCUTS</div><div style="padding:8px 20px 18px">${ROWS.map(([what, key]) => `<div class="mr"><span class="mw">${esc(what)}</span><span class="mk">${esc(key)}</span></div>`).join("")}</div><div class="mf">Shortcuts need the page focused. Inside an embedded frame, click the page first.</div></div>`;
2438
3365
  root.addEventListener("click", onClose);
2439
3366
  return {
2440
3367
  root,
@@ -2494,8 +3421,10 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
2494
3421
  @keyframes pb-pulse { 0%,100% { opacity:.3 } 50% { opacity:1 } }
2495
3422
  @keyframes pb-resolve { to { opacity:0; transform:translateY(-10px) } }
2496
3423
 
2497
- /* overlay layer at the document origin */
2498
- .pb-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 0; }
3424
+ /* overlay layer in VIEWPORT space: fixed, re-laid out on every scroll/resize frame (element.ts).
3425
+ Document-space it drifted inner scroll containers, sticky anchors and any transformed
3426
+ ancestor of the host all broke the "page scrolls on window from the origin" assumption. */
3427
+ .pb-overlay { position: fixed; inset: 0; pointer-events: none; }
2499
3428
 
2500
3429
  .pb-outline { position: absolute; z-index: 30; pointer-events: none; border: 1px solid var(--pb-amber); border-radius: 2px; background: var(--pb-amber-soft); opacity: 0;
2501
3430
  transition: left 220ms var(--pb-ease), top 220ms var(--pb-ease), width 220ms var(--pb-ease), height 220ms var(--pb-ease), opacity 150ms linear; }
@@ -2529,7 +3458,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
2529
3458
  .pb-aim .bar .cancel { flex: none; padding: 0 18px; border: 1px solid var(--pb-line-2); background: transparent; color: var(--pb-fg2); }
2530
3459
  .pb-aim .bar .ok { flex: none; padding: 0 20px; border: none; background: var(--pb-amber); color: var(--pb-amber-ink); }
2531
3460
 
2532
- .pb-pin { position: absolute; }
3461
+ .pb-pin { position: absolute; pointer-events: auto; }
2533
3462
  .pb-pin.resolving { animation: pb-resolve 380ms var(--pb-ease) forwards; }
2534
3463
  .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; }
2535
3464
  .pb-pin .dot { position: absolute; left: -3px; top: -3px; width: 7px; height: 7px; border-radius: 999px; background: var(--pb-amber); box-shadow: 0 0 0 2px var(--pb-canvas); }
@@ -2541,11 +3470,19 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
2541
3470
  .pb-chipBtn .lk { display: flex; align-items: center; gap: 5px; padding-left: 6px; margin-left: 1px; border-left: 1px solid var(--pb-line-2); font-size: 9.5px; letter-spacing: .02em; opacity: .85; }
2542
3471
  .pb-pin.hot .pb-chipBtn .lk { border-left-color: color-mix(in srgb, var(--pb-amber-ink) 28%, transparent); }
2543
3472
  .pb-pin.queued .pb-chipBtn { border-style: dashed; }
3473
+ .pb-pin.stale:not(.hot) .pb-chipBtn { border-color: var(--pb-amber); }
3474
+ .pb-pin.stale:not(.hot) .pb-chipBtn .qd { color: var(--pb-amber); }
3475
+ /* comment pins: muted, an N glyph; hot state stays amber so the active one is unmistakable */
3476
+ .pb-pin.note .dot { background: var(--pb-fg3); }
3477
+ .pb-pin.note .needle { background: linear-gradient(to top, var(--pb-fg3), color-mix(in srgb, var(--pb-fg3) 35%, transparent)); }
3478
+ .pb-pin.note:not(.hot) .pb-chipBtn { color: var(--pb-fg2); border-color: var(--pb-line); }
3479
+ .pb-chipBtn .nt { display: flex; align-items: center; justify-content: center; width: 14px; height: 14px; border-radius: 2px; background: var(--pb-fg3); color: var(--pb-canvas); font-size: 8.5px; letter-spacing: 0; }
3480
+ .pb-pin.hot .pb-chipBtn .nt { background: var(--pb-amber-ink); color: var(--pb-amber); }
2544
3481
  .pb-chipBtn .qd { padding-left: 6px; margin-left: 1px; border-left: 1px solid var(--pb-line-2); font-size: 9px; letter-spacing: .12em; color: var(--pb-amber); }
2545
3482
  .pb-pin.hot .pb-chipBtn .qd { color: var(--pb-amber-ink); border-left-color: color-mix(in srgb, var(--pb-amber-ink) 28%, transparent); }
2546
3483
 
2547
3484
  /* thread card (ui/card.ts) — prototype lines 121–190 */
2548
- .pb-card { position: absolute; z-index: 80; width: 344px; }
3485
+ .pb-card { position: absolute; z-index: 80; width: 344px; pointer-events: auto; }
2549
3486
  .pb-card .in { animation: pb-in 260ms var(--pb-ease) both; background: var(--pb-elev); border: 1px solid var(--pb-line-2); border-radius: 4px; box-shadow: var(--pb-shadow); overflow: hidden; }
2550
3487
  .pb-hd { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-bottom: 1px solid var(--pb-line); background: var(--pb-surface); }
2551
3488
  .pb-hd .meta { display: flex; align-items: center; gap: 9px; font-family: var(--pb-font-mono); font-size: 10px; letter-spacing: .18em; color: var(--pb-fg3); }
@@ -2594,6 +3531,17 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
2594
3531
  .pb-change .ft { display: flex; align-items: center; gap: 8px; padding: 9px 10px; background: var(--pb-surface); border-top: 1px solid var(--pb-line); }
2595
3532
  .pb-change .applied { font-family: var(--pb-font-mono); font-size: 9.5px; letter-spacing: .16em; color: var(--pb-ok); display: flex; align-items: center; gap: 8px; }
2596
3533
  .pb-change .applied .hh { color: var(--pb-fg4); }
3534
+ .pb-stale { display: flex; align-items: center; gap: 8px; padding: 9px 12px; background: color-mix(in srgb, var(--pb-amber) 8%, var(--pb-surface)); border-top: 1px solid var(--pb-line); font-size: 11.5px; color: var(--pb-fg2); }
3535
+ .pb-stale .msg { flex: 1; min-width: 0; }
3536
+ .pb-typing.quiet .lbl { color: var(--pb-fg3); }
3537
+ .pb-dfoot { padding: 10px 16px; border-top: 1px solid var(--pb-line); display: flex; justify-content: flex-end; }
3538
+ .pb-resnote { padding: 8px 12px; font-size: 11.5px; line-height: 1.45; color: var(--pb-fg2); border-top: 1px solid var(--pb-line); }
3539
+ .pb-resnote .hh { font-family: var(--pb-font-mono); font-size: 10px; color: var(--pb-fg3); }
3540
+ .pb-seg { display: flex; margin-right: auto; border: 1px solid var(--pb-line-2); border-radius: 2px; overflow: hidden; }
3541
+ .pb-seg button { height: 24px; padding: 0 9px; font-size: 10.5px; color: var(--pb-fg3); transition: background 120ms linear, color 120ms linear; }
3542
+ .pb-seg button + button { border-left: 1px solid var(--pb-line-2); }
3543
+ .pb-seg button:hover { color: var(--pb-fg1); }
3544
+ .pb-seg button.on { background: var(--pb-hover); color: var(--pb-fg1); }
2597
3545
  .pb-verify { display: flex; align-items: center; gap: 8px; padding: 9px 12px; background: var(--pb-surface); border-top: 1px solid var(--pb-line); }
2598
3546
  .pb-bt-solid { height: 26px; padding: 0 13px; border-radius: 2px; background: var(--pb-invert-bg); color: var(--pb-invert-fg); font-size: 11.5px; }
2599
3547
  .pb-bt-solid:hover { opacity: .9; }
@@ -2609,6 +3557,11 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
2609
3557
 
2610
3558
  /* command bar */
2611
3559
  .pb-bar { position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%); z-index: 90; display: flex; align-items: center; height: 46px; padding: 0 6px; gap: 3px; background: var(--pb-bar); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); border: 1px solid var(--pb-line-2); border-radius: 4px; box-shadow: var(--pb-shadow); }
3560
+ /* Dragged once: explicit left/top (inline) replace the bottom-centre default. */
3561
+ .pb-bar.free { bottom: auto; transform: none; }
3562
+ .pb-bar .grip { display: flex; align-items: center; justify-content: center; width: 14px; height: 32px; margin-left: 2px; color: var(--pb-fg4); cursor: grab; touch-action: none; border-radius: 2px; }
3563
+ .pb-bar .grip:hover { color: var(--pb-fg2); background: var(--pb-hover); }
3564
+ .pb-bar.dragging .grip { cursor: grabbing; }
2612
3565
  .pb-bar .armed-ring { position: absolute; inset: -1px; border: 1px solid var(--pb-amber); border-radius: 4px; box-shadow: 0 0 32px var(--pb-amber-soft); pointer-events: none; animation: pb-fade 200ms ease-out both; display: none; }
2613
3566
  :host([data-placing]) .pb-bar .armed-ring { display: block; }
2614
3567
  .pb-bar .ident { display: flex; align-items: center; gap: 9px; padding: 0 12px 0 10px; min-width: 150px; }
@@ -2628,8 +3581,18 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
2628
3581
  .pb-tab { padding: 0 0 10px; white-space: nowrap; font-family: var(--pb-font-mono); font-size: 10px; letter-spacing: .18em; color: var(--pb-fg3); border-bottom: 1px solid transparent; }
2629
3582
  .pb-tab.on { color: var(--pb-fg1); border-bottom-color: var(--pb-amber); }
2630
3583
  .pb-items { flex: 1; overflow: auto; }
2631
- .pb-item { width: 100%; text-align: left; display: flex; gap: 11px; padding: 14px 16px; border-bottom: 1px solid var(--pb-line); }
3584
+ .pb-item { display: flex; align-items: flex-start; border-bottom: 1px solid var(--pb-line); }
2632
3585
  .pb-item:hover { background: var(--pb-hover); }
3586
+ .pb-item-main { flex: 1; min-width: 0; text-align: left; display: flex; gap: 11px; padding: 14px 0 14px 16px; }
3587
+ /* Row actions show on hover/focus; always on touch, where there is no hover. */
3588
+ .pb-item .acts { display: flex; flex-direction: column; gap: 2px; padding: 12px 10px 0 6px; opacity: 0; transition: opacity 120ms linear; }
3589
+ .pb-item:hover .acts, .pb-item:focus-within .acts { opacity: 1; }
3590
+ @media (hover: none) { .pb-item .acts { opacity: 1; } }
3591
+ .pb-group { display: flex; align-items: center; gap: 8px; padding: 8px 16px; background: color-mix(in srgb, var(--pb-info) 7%, var(--pb-surface)); border-bottom: 1px solid var(--pb-line); font-family: var(--pb-font-mono); font-size: 9.5px; letter-spacing: .14em; color: var(--pb-fg2); }
3592
+ .pb-group .gr { color: var(--pb-fg1); }
3593
+ .pb-group .sp { flex: 1; }
3594
+ .pb-gres { height: 22px; padding: 0 8px; border: 1px solid var(--pb-line-2); border-radius: 2px; font-family: var(--pb-font-mono); font-size: 9px; letter-spacing: .14em; color: var(--pb-fg1); transition: color 120ms linear, border-color 120ms linear; }
3595
+ .pb-gres:hover, .pb-gres.confirm { border-color: var(--pb-ok); color: var(--pb-ok); }
2633
3596
  .pb-item .nn { flex: none; display: flex; align-items: center; justify-content: center; min-width: 24px; height: 20px; border-radius: 2px; border: 1px solid var(--pb-line-2); color: var(--pb-fg2); font-family: var(--pb-font-mono); font-size: 10px; letter-spacing: .06em; }
2634
3597
  .pb-item.on .nn { background: var(--pb-amber); color: var(--pb-amber-ink); border-color: var(--pb-amber); }
2635
3598
  .pb-item .cc { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
@@ -2687,6 +3650,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
2687
3650
  .pb-modal .mh { padding: 18px 20px 14px; border-bottom: 1px solid var(--pb-line); font-family: var(--pb-font-mono); font-size: 10px; letter-spacing: .24em; }
2688
3651
  .pb-modal .mr { display: flex; align-items: center; justify-content: space-between; padding: 9px 0; border-bottom: 1px solid var(--pb-line); }
2689
3652
  .pb-modal .mw { font-size: 13px; letter-spacing: -.005em; color: var(--pb-fg2); }
3653
+ .pb-modal .mf { padding: 0 20px 16px; font-size: 10.5px; color: var(--pb-fg2); }
2690
3654
  .pb-modal .mk { display: flex; align-items: center; justify-content: center; min-width: 26px; height: 22px; padding: 0 7px; border: 1px solid var(--pb-line-2); border-radius: 2px; background: var(--pb-sunken); font-family: var(--pb-font-mono); font-size: 10.5px; }
2691
3655
 
2692
3656
  [hidden] { display: none !important; }
@@ -2701,13 +3665,9 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
2701
3665
  const PAGE_CSS = `body.${PAGE_PLACING_CLASS}, body.${PAGE_PLACING_CLASS} * { cursor: none !important; }`;
2702
3666
  //#endregion
2703
3667
  //#region src/element.ts
3668
+ /** How long a pin commit waits for its picture before shipping without one. */
3669
+ const CAPTURE_TIMEOUT_MS = 4e3;
2704
3670
  const BaseElement = globalThis.HTMLElement ?? class {};
2705
- /** Keystrokes are ignored while a text control has focus (prototype line 702–703). */
2706
- function isTextEntry(target) {
2707
- const el = target;
2708
- const tag = el?.tagName ?? "";
2709
- return tag === "TEXTAREA" || tag === "INPUT" || el?.isContentEditable === true;
2710
- }
2711
3671
  var PinboxToolbarElement = class extends BaseElement {
2712
3672
  static tagName = "pinbox-toolbar";
2713
3673
  /** Watched so a config that arrives after insertion can still start the transport. */
@@ -2729,28 +3689,31 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
2729
3689
  #token = "";
2730
3690
  #built = false;
2731
3691
  #bar = null;
2732
- #reticle = null;
2733
3692
  #pinsLayer = null;
2734
3693
  #drawer = null;
2735
- #aim = null;
2736
- /** Pending viewport-refresh frame, 0 when none is queued. */
2737
- #viewportFrame = 0;
3694
+ /** Reticle, drag-aim and multi-target capture — see placement.ts. */
3695
+ #placement = null;
2738
3696
  #modal = null;
2739
3697
  #minUi = null;
2740
3698
  #min = null;
3699
+ /** The bar's grip drag; same lifetime as the minimize controller. */
3700
+ #barDrag = null;
2741
3701
  /** SPA view watcher: DOM/history changes re-run the anchor-gated render. */
2742
3702
  #anchors = null;
2743
3703
  #helpOpen = false;
3704
+ /** The 30 s wall-clock tick that ages pending pins into WAITING / NO RESPONSE. 0 when idle. */
3705
+ #clockTimer = 0;
3706
+ /** Pending viewport re-layout frame, 0 when none is queued. */
3707
+ #layoutFrame = 0;
3708
+ #resizeObserver = null;
2744
3709
  #pageStyle = null;
2745
3710
  #unsubscribe = null;
2746
- #hover = null;
2747
- /** Shift+click accumulation while placing — extra loci for ONE pending pin. */
2748
- #extraTargets = [];
2749
3711
  /** Card → element: send/verify/resolve forward to the transport seam; close dismisses. */
2750
3712
  #cardActions = {
2751
- send: (pinId, text) => this.actions.send?.(pinId, text),
3713
+ send: (pinId, text, kind) => this.actions.send?.(pinId, text, kind),
2752
3714
  verify: (pinId, outcome) => this.actions.verify?.(pinId, outcome),
2753
3715
  resolve: (pinId) => this.actions.resolve?.(pinId),
3716
+ nudge: (pinId) => this.#nudge(pinId),
2754
3717
  copy: (pinId) => this.#copyPin(pinId),
2755
3718
  close: () => this.#dismiss()
2756
3719
  };
@@ -2774,6 +3737,32 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
2774
3737
  this.#built = true;
2775
3738
  this.#build();
2776
3739
  }
3740
+ this.#applyPageDefaults();
3741
+ if (this.shadowRoot) this.#placement?.connect(this.shadowRoot);
3742
+ if (this.#min === null) this.#mountMinimize();
3743
+ if (this.#barDrag === null) this.#mountBarDrag();
3744
+ this.#anchors = watchAnchors(window, () => this.#render(this.store.get()));
3745
+ this.#listen();
3746
+ this.#unsubscribe = this.store.subscribe((s) => this.#render(s));
3747
+ this.#render(this.store.get());
3748
+ this.#startClock();
3749
+ this.store.update({ captureMode: this.#loadCaptureMode() });
3750
+ this.#queueStart();
3751
+ }
3752
+ /** Persisted per endpoint beside the puck dock; PinboxConfig.capture is the first-run default. */
3753
+ #loadCaptureMode() {
3754
+ const key = captureKey(`pinbox:${this.config?.endpoint ?? ""}`);
3755
+ return loadCaptureMode(globalThis.localStorage, key, this.config?.capture ?? "dom");
3756
+ }
3757
+ #toggleCapture() {
3758
+ const next = this.store.get().captureMode === "tab" ? "dom" : "tab";
3759
+ this.store.update({ captureMode: next });
3760
+ if (next === "dom") releaseCapture();
3761
+ saveCaptureMode(globalThis.localStorage, captureKey(`pinbox:${this.config?.endpoint ?? ""}`), next);
3762
+ return true;
3763
+ }
3764
+ /** Theme from the OS when the host set none; the page-level CSS (placing cursor) into <head>. */
3765
+ #applyPageDefaults() {
2777
3766
  if (!this.hasAttribute("data-pb")) {
2778
3767
  const dark = window.matchMedia("(prefers-color-scheme: dark)").matches;
2779
3768
  this.setAttribute("data-pb", dark ? "dark" : "light");
@@ -2782,17 +3771,37 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
2782
3771
  style.textContent = PAGE_CSS;
2783
3772
  document.head.appendChild(style);
2784
3773
  this.#pageStyle = style;
2785
- if (this.#aim === null) this.#mountAim();
2786
- if (this.#min === null) this.#mountMinimize();
2787
- this.#anchors = watchAnchors(window, () => this.#render(this.store.get()));
2788
- document.addEventListener("mousemove", this.#onMouseMove);
2789
- window.addEventListener("scroll", this.#onViewportChange, { passive: true });
2790
- window.addEventListener("resize", this.#onViewportChange);
3774
+ }
3775
+ #mountBarDrag() {
3776
+ if (this.#bar === null) return;
3777
+ this.#barDrag = createBarDrag({
3778
+ win: window,
3779
+ bar: this.#bar.root,
3780
+ grip: this.#bar.grip,
3781
+ storage: globalThis.localStorage ?? null,
3782
+ storagePrefix: `pinbox:${this.config?.endpoint ?? ""}`
3783
+ });
3784
+ }
3785
+ /** The page-level listeners of one connected lifetime; disconnectedCallback removes them. */
3786
+ #listen() {
2791
3787
  document.addEventListener("click", this.#onClickCapture, true);
2792
- document.addEventListener("keydown", this.#onKeyDown);
2793
- this.#unsubscribe = this.store.subscribe((s) => this.#render(s));
2794
- this.#render(this.store.get());
2795
- this.#queueStart();
3788
+ window.addEventListener("keydown", this.#onKeyDown, true);
3789
+ document.addEventListener("scroll", this.#onLayoutChange, {
3790
+ capture: true,
3791
+ passive: true
3792
+ });
3793
+ window.addEventListener("resize", this.#onLayoutChange);
3794
+ const RO = globalThis.ResizeObserver;
3795
+ if (typeof RO === "function") {
3796
+ this.#resizeObserver = new RO(this.#onLayoutChange);
3797
+ this.#resizeObserver.observe(document.body);
3798
+ }
3799
+ }
3800
+ /** Age is state (state.ts `clock`), so a tick is a render and stale pins flip without a hub event. */
3801
+ #startClock() {
3802
+ const tick = () => this.store.update({ clock: Date.now() });
3803
+ tick();
3804
+ this.#clockTimer = window.setInterval(tick, 3e4);
2796
3805
  }
2797
3806
  /**
2798
3807
  * Late-config rescue: an element inserted BEFORE its hub/token attributes were
@@ -2821,45 +3830,30 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
2821
3830
  this.#lifetime += 1;
2822
3831
  this.#transport?.close();
2823
3832
  this.#transport = null;
2824
- document.removeEventListener("mousemove", this.#onMouseMove);
2825
- window.removeEventListener("scroll", this.#onViewportChange);
2826
- window.removeEventListener("resize", this.#onViewportChange);
2827
3833
  document.removeEventListener("click", this.#onClickCapture, true);
2828
- document.removeEventListener("keydown", this.#onKeyDown);
2829
- if (this.#viewportFrame !== 0) cancelAnimationFrame(this.#viewportFrame);
2830
- this.#viewportFrame = 0;
2831
- this.#aim?.destroy();
2832
- this.#aim = null;
3834
+ window.removeEventListener("keydown", this.#onKeyDown, true);
3835
+ document.removeEventListener("scroll", this.#onLayoutChange, { capture: true });
3836
+ window.removeEventListener("resize", this.#onLayoutChange);
3837
+ this.#resizeObserver?.disconnect();
3838
+ this.#resizeObserver = null;
3839
+ if (this.#layoutFrame !== 0) cancelAnimationFrame(this.#layoutFrame);
3840
+ this.#layoutFrame = 0;
3841
+ this.#placement?.disconnect();
2833
3842
  this.#min?.destroy();
2834
3843
  this.#min = null;
3844
+ this.#barDrag?.destroy();
3845
+ this.#barDrag = null;
2835
3846
  releaseCapture();
2836
3847
  this.#anchors?.destroy();
2837
3848
  this.#anchors = null;
2838
3849
  this.#unsubscribe?.();
2839
3850
  this.#unsubscribe = null;
3851
+ window.clearInterval(this.#clockTimer);
3852
+ this.#clockTimer = 0;
2840
3853
  this.#pageStyle?.remove();
2841
3854
  this.#pageStyle = null;
2842
3855
  document.body.classList.remove(PAGE_PLACING_CLASS);
2843
3856
  }
2844
- /**
2845
- * Create the aim controller and put its layer in the shadow root.
2846
- *
2847
- * Separate from `#build` because the two have different lifetimes: `#build` runs once, but
2848
- * `disconnectedCallback` tears this controller's window listeners down. A re-parented element
2849
- * would otherwise come back with no controller and its markup still in place — a grip that
2850
- * renders and does nothing, with no error to explain it.
2851
- */
2852
- #mountAim() {
2853
- const shadow = this.shadowRoot;
2854
- if (!shadow) return;
2855
- shadow.querySelector(".pb-aim")?.remove();
2856
- this.#aim = createAim(document, {
2857
- onAim: (x, y) => this.#probe(x, y),
2858
- onConfirm: () => this.#confirmAim(),
2859
- onCancel: () => this.#dismiss()
2860
- });
2861
- shadow.appendChild(this.#aim.root);
2862
- }
2863
3857
  #build() {
2864
3858
  const shadow = this.attachShadow({ mode: "open" });
2865
3859
  try {
@@ -2875,22 +3869,21 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
2875
3869
  overlay.className = "pb-overlay";
2876
3870
  this.#pinsLayer = document.createElement("div");
2877
3871
  overlay.appendChild(this.#pinsLayer);
2878
- this.#reticle = createReticle(document);
2879
- overlay.appendChild(this.#reticle.outline);
3872
+ this.#placement = createPlacement({
3873
+ win: window,
3874
+ host: this,
3875
+ store: this.store,
3876
+ pinsLayer: this.#pinsLayer,
3877
+ onCancel: () => this.#dismiss()
3878
+ });
3879
+ overlay.appendChild(this.#placement.outline);
2880
3880
  const card = document.createElement("div");
2881
3881
  card.className = "pb-card";
2882
3882
  card.hidden = true;
2883
3883
  overlay.appendChild(card);
2884
3884
  shadow.appendChild(overlay);
2885
- shadow.appendChild(this.#reticle.crosshair);
2886
- this.#bar = createBar(document, {
2887
- onPin: () => this.#togglePlacing(),
2888
- onInbox: () => this.store.update({ inboxOpen: !this.store.get().inboxOpen }),
2889
- onTheme: () => this.#toggleTheme(),
2890
- onHelp: () => this.#toggleHelp(),
2891
- onCopy: () => this.#copyOpenPins(),
2892
- onMinimize: (keyboard) => this.minimize(keyboard)
2893
- });
3885
+ shadow.appendChild(this.#placement.crosshair);
3886
+ this.#bar = createBar(document, { onAction: (id, keyboard) => this.#runAction(id, keyboard) });
2894
3887
  shadow.appendChild(this.#bar.root);
2895
3888
  this.#minUi = createMinimizeUi(document);
2896
3889
  shadow.appendChild(this.#minUi.morphWrap);
@@ -2898,10 +3891,11 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
2898
3891
  shadow.appendChild(this.#minUi.fan);
2899
3892
  this.#drawer = createDrawer(document, {
2900
3893
  onActivate: (pinId) => this.#activateFromInbox(pinId),
2901
- onClose: () => this.store.update({ inboxOpen: false })
3894
+ onClose: () => this.store.update({ inboxOpen: false }),
3895
+ onResolve: (pinId, note) => this.actions.resolve?.(pinId, note),
3896
+ onUnresolve: (pinId) => this.actions.verify?.(pinId, "reopened")
2902
3897
  });
2903
3898
  shadow.appendChild(this.#drawer.root);
2904
- this.#mountAim();
2905
3899
  this.#modal = createShortcutsModal(document, () => this.#setHelp(false));
2906
3900
  shadow.appendChild(this.#modal.root);
2907
3901
  this.#pinsLayer.addEventListener("click", (e) => this.#onChipClick(e));
@@ -2921,12 +3915,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
2921
3915
  reduced: window.matchMedia("(prefers-reduced-motion: reduce)").matches,
2922
3916
  initialMinimized: this.config?.minimized === true,
2923
3917
  onSettled: (minimized, keyboard) => this.#onMinimizeSettled(minimized, keyboard),
2924
- onFanAction: (action) => {
2925
- if (action === "pin") this.#togglePlacing();
2926
- else if (action === "inbox") this.#toggleInbox();
2927
- else if (action === "hide") this.#togglePinsHidden();
2928
- else this.#toggleTheme();
2929
- }
3918
+ onFanAction: (action) => this.#runAction(action, false)
2930
3919
  });
2931
3920
  this.#min.applyInitial();
2932
3921
  }
@@ -2972,6 +3961,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
2972
3961
  onEvent: (e) => applyHubEvent(this.store, e),
2973
3962
  onConnection: (connection) => this.store.update({ connection }),
2974
3963
  onPins: (pins) => this.store.update({ pins }),
3964
+ onSessions: (sessions) => this.store.update({ agentLive: agentIsLive(sessions, Date.now()) }),
2975
3965
  onOutbox: (ids) => this.store.update({ queuedIds: new Set(ids) })
2976
3966
  });
2977
3967
  this.#transport = transport;
@@ -2979,8 +3969,8 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
2979
3969
  const seed = [...transport.mirrorPins(), ...queued];
2980
3970
  if (seed.length > 0 && this.store.get().pins.length === 0) this.store.update({ pins: seed });
2981
3971
  if (queued.length > 0) this.store.update({ queuedIds: new Set(queued.map((p) => p.id)) });
2982
- this.actions.send = (pinId, text) => void this.#send(transport, pinId, text);
2983
- this.actions.resolve = (pinId) => void transport.resolve(pinId).then((pin) => upsertPin(this.store, pin)).catch(() => {});
3972
+ this.actions.send = (pinId, text, kind) => void this.#send(transport, pinId, text, kind);
3973
+ this.actions.resolve = (pinId, note) => void transport.resolve(pinId, note).then((pin) => upsertPin(this.store, pin)).catch(() => {});
2984
3974
  this.actions.verify = (pinId, outcome) => void transport.verify(pinId, outcome).then((pin) => {
2985
3975
  upsertPin(this.store, pin);
2986
3976
  if (outcome === "accepted") this.#dismiss();
@@ -2988,14 +3978,14 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
2988
3978
  transport.connect();
2989
3979
  }
2990
3980
  /** draft ⇒ compose PinInput (+ best-effort screenshot) and createPin; else thread reply. */
2991
- async #send(transport, pinId, text) {
3981
+ async #send(transport, pinId, text, kind) {
2992
3982
  try {
2993
3983
  if (pinId === "draft") {
2994
3984
  const draft = this.store.get().draft;
2995
3985
  if (draft === null) return;
2996
3986
  const input = {
2997
3987
  text,
2998
- kind: "note",
3988
+ kind,
2999
3989
  target: draft.target.target,
3000
3990
  env: draft.target.env,
3001
3991
  author: { userId: transport.consumerId }
@@ -3006,7 +3996,11 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
3006
3996
  } else appendThreadMessage(this.store, await transport.reply(pinId, text));
3007
3997
  } catch {}
3008
3998
  }
3009
- /** Best-effort element screenshot: draft submit → captureElement → uploadAttachment. */
3999
+ /**
4000
+ * Best-effort element screenshot: draft submit → capture → uploadAttachment. The capture is
4001
+ * bounded — a slow rasterize or a prompt left hanging must not hold the pin hostage, so past
4002
+ * CAPTURE_TIMEOUT_MS the pin ships without pixels (structured capture still carries it).
4003
+ */
3010
4004
  async #screenshot(selector) {
3011
4005
  const cfg = this.config;
3012
4006
  if (cfg === null) return null;
@@ -3014,7 +4008,9 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
3014
4008
  try {
3015
4009
  const el = document.querySelector(selector);
3016
4010
  if (el === null) return null;
3017
- const img = await captureElement(el);
4011
+ const capture = this.store.get().captureMode === "tab" ? captureElement(el) : captureElementDom(el);
4012
+ const timeout = new Promise((resolve) => window.setTimeout(() => resolve(null), CAPTURE_TIMEOUT_MS));
4013
+ const img = await Promise.race([capture, timeout]);
3018
4014
  if (img === null) return null;
3019
4015
  return await uploadAttachment(cfg.endpoint, this.#token, img);
3020
4016
  } catch {
@@ -3036,21 +4032,33 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
3036
4032
  const pin = this.store.get().pins.find((p) => p.id === pinId);
3037
4033
  this.#ensureThread(pinId);
3038
4034
  this.store.update({ activePinId: pinId });
3039
- const rect = pin?.target?.rect;
4035
+ const rect = pin === void 0 ? null : anchorRect(document, pin);
3040
4036
  if (rect) {
3041
- const y = rect.y + rect.height / 2;
4037
+ const y = window.scrollY + rect.y + rect.height / 2;
3042
4038
  window.scrollTo({
3043
4039
  top: Math.max(0, y - window.innerHeight / 2),
3044
4040
  behavior: "smooth"
3045
4041
  });
3046
4042
  }
3047
4043
  }
4044
+ /**
4045
+ * Nudge a stale pin: re-post the last human message as a new thread message. A watcher that
4046
+ * missed the original (crashed, restarted, registered late) gets a fresh event to wake on.
4047
+ */
4048
+ #nudge(pinId) {
4049
+ const state = this.store.get();
4050
+ const pin = state.pins.find((p) => p.id === pinId);
4051
+ if (pin === void 0) return;
4052
+ const last = [...state.threads.get(pinId) ?? []].reverse().find((m) => m.role === "human");
4053
+ this.actions.send?.(pinId, last?.text ?? pin.text, "note");
4054
+ }
3048
4055
  /** The markdown offline fallback: copy every open pin's block to the clipboard. */
3049
4056
  #copyOpenPins() {
3050
4057
  const state = this.store.get();
3051
4058
  try {
3052
4059
  navigator.clipboard.writeText(pinsToMarkdown(state.pins, state.threads));
3053
4060
  } catch {}
4061
+ return true;
3054
4062
  }
3055
4063
  /** The card's copy: exactly the pin you are looking at, thread included. */
3056
4064
  #copyPin(pinId) {
@@ -3063,6 +4071,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
3063
4071
  }
3064
4072
  #togglePinsHidden() {
3065
4073
  this.store.update({ pinsHidden: !this.store.get().pinsHidden });
4074
+ return true;
3066
4075
  }
3067
4076
  #setHelp(open) {
3068
4077
  this.#helpOpen = open;
@@ -3070,6 +4079,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
3070
4079
  }
3071
4080
  #toggleHelp() {
3072
4081
  this.#setHelp(!this.#helpOpen);
4082
+ return true;
3073
4083
  }
3074
4084
  /** Chip click toggles the pin active (prototype data-open delegation, line 675). */
3075
4085
  #onChipClick(e) {
@@ -3089,175 +4099,99 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
3089
4099
  activePinId: null,
3090
4100
  pinsHidden: false
3091
4101
  });
4102
+ return true;
3092
4103
  }
3093
4104
  #toggleInbox() {
3094
4105
  this.store.update({ inboxOpen: !this.store.get().inboxOpen });
3095
- }
3096
- #resolveActive() {
3097
- const active = this.store.get().activePinId;
3098
- if (active) this.actions.resolve?.(active);
4106
+ return true;
3099
4107
  }
3100
4108
  #toggleTheme() {
3101
4109
  const next = this.getAttribute("data-pb") === "dark" ? "light" : "dark";
3102
4110
  this.setAttribute("data-pb", next);
4111
+ return true;
3103
4112
  }
3104
4113
  /** esc / click-away: leave placing, discard the draft (client-only), deactivate. */
3105
4114
  #dismiss() {
3106
4115
  this.#setHelp(false);
3107
- this.#clearExtraTargets();
3108
4116
  this.store.update({
3109
4117
  mode: "idle",
3110
4118
  activePinId: null
3111
4119
  });
3112
4120
  if (this.store.get().draft) this.store.discardDraft();
3113
4121
  }
3114
- /**
3115
- * Work out what sits under a viewport point and highlight it.
3116
- *
3117
- * Shared by both ways of aiming — following a mouse, and dragging the reticle — so the two can
3118
- * never disagree about what is under the crosshair.
3119
- */
3120
- #probe(clientX, clientY) {
3121
- const el = hitTest(document, clientX, clientY, (hit) => hit === this);
3122
- this.#hover = el;
3123
- if (el) this.#reticle?.snap(el.getBoundingClientRect(), targetLabel(el), {
3124
- x: window.scrollX,
3125
- y: window.scrollY
3126
- });
3127
- else this.#reticle?.release();
3128
- this.#aim?.setLabel(el ? targetLabel(el) : "NOTHING UNDER THE PIN");
3129
- }
3130
- /**
3131
- * Keep the drag-aim reticle honest while the viewport moves under it.
3132
- *
3133
- * Scrolling changes what is beneath a fixed reticle, and resizing (a phone rotating, a window
3134
- * dragged narrow) can both strand it off-screen and flip which way of aiming applies.
3135
- */
3136
- #onViewportChange = () => {
3137
- if (this.store.get().mode !== "placing" || this.#viewportFrame !== 0) return;
3138
- this.#viewportFrame = requestAnimationFrame(() => {
3139
- this.#viewportFrame = 0;
3140
- if (this.store.get().mode !== "placing") return;
3141
- this.#syncAim(true);
3142
- const aim = this.#aim;
3143
- if (aim?.root.classList.contains("on") === true) this.#probe(aim.point.x, aim.point.y);
3144
- });
3145
- };
3146
- #onMouseMove = (e) => {
3147
- if (this.store.get().mode !== "placing" || !this.#reticle) return;
3148
- this.#reticle.move(e);
3149
- this.#probe(e.clientX, e.clientY);
3150
- };
3151
- /** Commit the pin the drag-aim reticle is sitting on. */
3152
- #confirmAim() {
3153
- const aim = this.#aim;
3154
- if (!aim) return;
3155
- this.#probe(aim.point.x, aim.point.y);
3156
- const el = this.#hover ?? document.body;
3157
- this.store.place({
3158
- target: captureTarget(el, { at: {
3159
- x: aim.point.x + window.scrollX,
3160
- y: aim.point.y + window.scrollY
3161
- } }),
3162
- placedAt: {
3163
- x: aim.point.x + window.scrollX,
3164
- y: aim.point.y + window.scrollY
3165
- }
3166
- });
3167
- this.#reticle?.release();
3168
- }
3169
- /** Placement click: capture the hovered target (or body) into a client-only draft. */
3170
- #placeDraft(e) {
3171
- e.preventDefault();
3172
- e.stopPropagation();
3173
- const capture = captureTarget(this.#hover ?? document.body, { at: {
3174
- x: e.pageX,
3175
- y: e.pageY
3176
- } });
3177
- if (this.#extraTargets.length > 0) capture.target.targets = this.#extraTargets;
3178
- this.#clearExtraTargets();
3179
- this.store.place({
3180
- target: capture,
3181
- placedAt: {
3182
- x: e.pageX,
3183
- y: e.pageY
3184
- }
3185
- });
3186
- this.#reticle?.release();
3187
- }
3188
- /** Shift+click while placing: capture WITHOUT committing; a numbered dashed
3189
- * outline is the receipt. Plain click still commits (with these attached). */
3190
- #accumulateTarget(e) {
3191
- e.preventDefault();
3192
- e.stopPropagation();
3193
- const el = this.#hover ?? document.body;
3194
- this.#extraTargets = [...this.#extraTargets, captureTarget(el).target];
3195
- if (this.#pinsLayer) renderMultiMarks(this.#pinsLayer, this.#extraTargets);
3196
- }
3197
- #clearExtraTargets() {
3198
- if (this.#extraTargets.length === 0) return;
3199
- this.#extraTargets = [];
3200
- if (this.#pinsLayer) renderMultiMarks(this.#pinsLayer, []);
3201
- }
3202
4122
  #onClickCapture = (e) => {
3203
4123
  if (e.composedPath().includes(this)) return;
3204
4124
  const state = this.store.get();
3205
4125
  if (state.mode === "placing") {
3206
- if (needsDragAim(window)) return;
3207
- if (e.shiftKey) this.#accumulateTarget(e);
3208
- else this.#placeDraft(e);
4126
+ this.#placement?.handleClick(e);
3209
4127
  return;
3210
4128
  }
3211
4129
  if (state.inboxOpen) this.store.update({ inboxOpen: false });
3212
4130
  if (state.activePinId || state.draft) this.#dismiss();
3213
4131
  };
3214
- /** Prototype keyboard map (v2-command-bar.html lines 701–712) + M (v3 minimize). */
3215
- #shortcuts = {
4132
+ /**
4133
+ * One handler per action, for every surface: bar buttons, fan items and keys all name an
4134
+ * action from the table (ui/actions.ts). A handler returns false when nothing was there to act
4135
+ * on, so the key path can let the host have the keystroke (an Esc with nothing open is the
4136
+ * page's Esc; R with no active pin is the page's R).
4137
+ */
4138
+ #handlers = {
3216
4139
  escape: () => {
3217
- if (this.#min?.closeFan() === true) return;
4140
+ if (this.#min?.closeFan() === true) return true;
4141
+ const s = this.store.get();
4142
+ const open = s.mode === "placing" || s.activePinId !== null || s.draft !== null || this.#helpOpen;
3218
4143
  this.#dismiss();
4144
+ return open;
4145
+ },
4146
+ pin: () => this.#togglePlacing(),
4147
+ inbox: () => this.#toggleInbox(),
4148
+ theme: () => this.#toggleTheme(),
4149
+ copy: () => this.#copyOpenPins(),
4150
+ hide: () => this.#togglePinsHidden(),
4151
+ capture: () => this.#toggleCapture(),
4152
+ help: () => this.#toggleHelp(),
4153
+ resolve: () => {
4154
+ const active = this.store.get().activePinId;
4155
+ if (active === null) return false;
4156
+ this.actions.resolve?.(active);
4157
+ return true;
3219
4158
  },
3220
- p: () => this.#togglePlacing(),
3221
- i: () => this.#toggleInbox(),
3222
- d: () => this.#toggleTheme(),
3223
- r: () => this.#resolveActive(),
3224
- c: () => this.#copyOpenPins(),
3225
- h: () => this.#togglePinsHidden(),
3226
- m: () => this.#min?.minimized() === true ? this.restore(true) : this.minimize(true),
3227
- "?": () => this.#toggleHelp()
4159
+ minimize: (keyboard) => {
4160
+ if (this.#min?.minimized() === true) this.restore(keyboard);
4161
+ else this.minimize(keyboard);
4162
+ return true;
4163
+ }
3228
4164
  };
4165
+ #runAction(id, keyboard) {
4166
+ return this.#handlers[id](keyboard);
4167
+ }
3229
4168
  #onKeyDown = (e) => {
3230
- if (isTextEntry(e.composedPath()[0])) return;
3231
- this.#shortcuts[e.key === "?" ? "?" : e.key.toLowerCase()]?.();
4169
+ const id = shortcutFor(e, this.config?.shortcuts ?? "all");
4170
+ if (id === null) return;
4171
+ if (!this.#runAction(id, true)) return;
4172
+ e.preventDefault();
4173
+ e.stopPropagation();
3232
4174
  };
3233
4175
  /**
3234
- * Bring the drag-aim reticle up with placing mode, seeded mid-screen and already showing what it
3235
- * is over — so the first thing you see is a live target, not an empty crosshair waiting for a
3236
- * mouse that is never coming.
4176
+ * The viewport moved under the overlay (scroll, resize, layout shift): re-place what is
4177
+ * anchored to the page pins, the card, multi-target marks once per frame. Not a store
4178
+ * update: nothing about the pins changed, only where their anchors are on screen.
3237
4179
  */
3238
- #syncAim(placing) {
3239
- const aim = this.#aim;
3240
- if (!aim) return;
3241
- if (!placing || !needsDragAim(window)) {
3242
- aim.hide();
3243
- return;
3244
- }
3245
- if (aim.root.classList.contains("on")) {
3246
- if (aim.point.x <= window.innerWidth && aim.point.y <= window.innerHeight) return;
3247
- aim.show(Math.min(aim.point.x, window.innerWidth), Math.min(aim.point.y, window.innerHeight));
3248
- return;
3249
- }
3250
- const { x, y } = startPoint(window);
3251
- aim.show(x, y);
3252
- this.#probe(x, y);
3253
- }
4180
+ #onLayoutChange = () => {
4181
+ if (this.#layoutFrame !== 0) return;
4182
+ this.#layoutFrame = requestAnimationFrame(() => {
4183
+ this.#layoutFrame = 0;
4184
+ const state = this.store.get();
4185
+ if (this.#pinsLayer) renderPins(this.#pinsLayer, state);
4186
+ if (this.shadowRoot) renderCard(this.shadowRoot, state, this.#cardActions);
4187
+ this.#placement?.render(state.mode === "placing");
4188
+ });
4189
+ };
3254
4190
  #render(state) {
3255
4191
  const placing = state.mode === "placing";
3256
4192
  this.toggleAttribute("data-placing", placing);
3257
4193
  document.body.classList.toggle(PAGE_PLACING_CLASS, placing);
3258
- if (!placing) this.#clearExtraTargets();
3259
- if (!placing) this.#reticle?.release();
3260
- this.#syncAim(placing);
4194
+ this.#placement?.render(placing);
3261
4195
  if (this.#pinsLayer) renderPins(this.#pinsLayer, state);
3262
4196
  if (this.shadowRoot) renderCard(this.shadowRoot, state, this.#cardActions);
3263
4197
  this.#drawer?.update(state);