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