@autono/pinbox-toolbar 0.1.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.
@@ -0,0 +1,1992 @@
1
+ //#region src/targeting/dom.ts
2
+ /**
3
+ * Deepest element under (clientX, clientY), or null when the hit is the page
4
+ * chrome itself (html/body) or something the caller ignores (our own overlay).
5
+ */
6
+ function hitTest(doc, x, y, ignore) {
7
+ const el = doc.elementFromPoint(x, y);
8
+ if (!el || el === doc.body || el === doc.documentElement) return null;
9
+ return ignore(el) ? null : el;
10
+ }
11
+ /** CLASS-or-TAG display name with a sibling index when needed (prototype nodeName). */
12
+ function nodeName(el) {
13
+ const key = el.classList[0];
14
+ let name = (key ?? el.tagName).toUpperCase();
15
+ const parent = el.parentElement;
16
+ if (parent) {
17
+ const sibs = [...parent.children].filter((c) => c.classList[0] === key && c.tagName === el.tagName);
18
+ if (sibs.length > 1) name += ` ${sibs.indexOf(el) + 1}`;
19
+ }
20
+ return name;
21
+ }
22
+ /**
23
+ * Human label for a target: an explicit data-pb-el annotation wins; otherwise a
24
+ * CLASS/TAG ancestry chain of at most 3 parts joined with ›, terminating early
25
+ * at the first annotated ancestor.
26
+ */
27
+ function targetLabel(el) {
28
+ const own = el.getAttribute("data-pb-el");
29
+ if (own) return own;
30
+ const parts = [nodeName(el)];
31
+ const body = el.ownerDocument.body;
32
+ let node = el.parentElement;
33
+ while (node && node !== body && parts.length < 3) {
34
+ const anchor = node.getAttribute("data-pb-el");
35
+ if (anchor) {
36
+ parts.unshift(anchor);
37
+ break;
38
+ }
39
+ if (node.classList[0]) parts.unshift(nodeName(node));
40
+ node = node.parentElement;
41
+ }
42
+ return parts.join(" › ");
43
+ }
44
+ const SAFE_ID = /^[A-Za-z][\w-]*$/;
45
+ /** Data attributes trusted as stable hooks, in priority order. */
46
+ const STABLE_DATA_ATTRS = [
47
+ "data-pb-anchor",
48
+ "data-pb-el",
49
+ "data-testid"
50
+ ];
51
+ function attrSegment(el, doc) {
52
+ for (const attr of STABLE_DATA_ATTRS) {
53
+ const value = el.getAttribute(attr);
54
+ if (value === null || value.includes("\"") || value.includes("\\")) continue;
55
+ const selector = `${el.tagName.toLowerCase()}[${attr}="${value}"]`;
56
+ if (doc.querySelectorAll(selector).length === 1) return selector;
57
+ }
58
+ return null;
59
+ }
60
+ function nthSegment(el) {
61
+ const tag = el.tagName.toLowerCase();
62
+ const parent = el.parentElement;
63
+ if (!parent) return tag;
64
+ const sameTag = [...parent.children].filter((c) => c.tagName === el.tagName);
65
+ return sameTag.length > 1 ? `${tag}:nth-of-type(${sameTag.indexOf(el) + 1})` : tag;
66
+ }
67
+ /**
68
+ * Stable CSS path for an element: ids > stable data attributes > an
69
+ * nth-of-type chain. Guaranteed round-trip: querySelector(buildSelector(el)) === el.
70
+ */
71
+ function buildSelector(el) {
72
+ const doc = el.ownerDocument;
73
+ const segments = [];
74
+ let node = el;
75
+ while (node && node !== doc.documentElement) {
76
+ const id = node.getAttribute("id");
77
+ if (id && SAFE_ID.test(id) && doc.querySelectorAll(`#${id}`).length === 1) {
78
+ segments.unshift(`#${id}`);
79
+ return segments.join(" > ");
80
+ }
81
+ const byAttr = attrSegment(node, doc);
82
+ if (byAttr) {
83
+ segments.unshift(byAttr);
84
+ return segments.join(" > ");
85
+ }
86
+ segments.unshift(nthSegment(node));
87
+ node = node.parentElement;
88
+ }
89
+ return segments.join(" > ");
90
+ }
91
+ //#endregion
92
+ //#region src/capture.ts
93
+ /** Curated computed-style subset — enough to reconstruct layout intent, tiny on the wire. */
94
+ const STYLE_KEYS = [
95
+ "display",
96
+ "position",
97
+ "font-size",
98
+ "color",
99
+ "background-color",
100
+ "margin",
101
+ "padding",
102
+ "overflow"
103
+ ];
104
+ const NEARBY_TEXT_MAX = 160;
105
+ function styleSubset(win, el) {
106
+ let cs;
107
+ try {
108
+ cs = win.getComputedStyle(el);
109
+ } catch {
110
+ return;
111
+ }
112
+ const out = {};
113
+ for (const key of STYLE_KEYS) {
114
+ const value = cs.getPropertyValue(key);
115
+ if (value !== "") out[key] = value;
116
+ }
117
+ return Object.keys(out).length > 0 ? out : void 0;
118
+ }
119
+ function ariaMap(el) {
120
+ const out = {};
121
+ for (const name of el.getAttributeNames()) if (name.startsWith("aria-")) out[name] = el.getAttribute(name) ?? "";
122
+ return Object.keys(out).length > 0 ? out : void 0;
123
+ }
124
+ function nearbyText(el) {
125
+ const text = (el.textContent ?? "").replace(/\s+/g, " ").trim();
126
+ return text === "" ? void 0 : text.slice(0, NEARBY_TEXT_MAX);
127
+ }
128
+ /** The user's selection, only when it intersects the captured element. */
129
+ function selectedText(win, el) {
130
+ try {
131
+ const sel = win.getSelection?.();
132
+ if (!sel || sel.isCollapsed || sel.rangeCount === 0) return void 0;
133
+ if (!sel.getRangeAt(0).intersectsNode(el)) return void 0;
134
+ const text = sel.toString().trim();
135
+ return text === "" ? void 0 : text;
136
+ } catch {
137
+ return;
138
+ }
139
+ }
140
+ /** `fixed` detected via ancestry: any ancestor with computed position: fixed. */
141
+ function isFixed(win, el) {
142
+ for (let node = el; node !== null; node = node.parentElement) try {
143
+ if (win.getComputedStyle(node).position === "fixed") return true;
144
+ } catch {
145
+ return false;
146
+ }
147
+ return false;
148
+ }
149
+ function buildContext(win, el) {
150
+ const context = {};
151
+ if (el.classList.length > 0) context.classes = [...el.classList];
152
+ const styles = styleSubset(win, el);
153
+ if (styles !== void 0) context.styles = styles;
154
+ const aria = ariaMap(el);
155
+ if (aria !== void 0) context.aria = aria;
156
+ const nearby = nearbyText(el);
157
+ if (nearby !== void 0) context.nearbyText = nearby;
158
+ const selected = selectedText(win, el);
159
+ if (selected !== void 0) context.selectedText = selected;
160
+ return Object.keys(context).length > 0 ? context : void 0;
161
+ }
162
+ /** Fills PinInput.target/env from a chosen element (shapes come from the pin schema). */
163
+ function captureTarget(el, opts) {
164
+ const win = el.ownerDocument.defaultView;
165
+ const r = el.getBoundingClientRect();
166
+ const target = {
167
+ url: win.location.href,
168
+ selector: buildSelector(el),
169
+ tag: el.tagName.toLowerCase(),
170
+ rect: {
171
+ x: r.left + win.scrollX,
172
+ y: r.top + win.scrollY,
173
+ width: r.width,
174
+ height: r.height
175
+ },
176
+ fixed: isFixed(win, el)
177
+ };
178
+ if (opts?.anchor !== void 0) target.anchor = opts.anchor;
179
+ const context = buildContext(win, el);
180
+ if (context !== void 0) target.context = context;
181
+ return {
182
+ target,
183
+ env: {
184
+ viewport: {
185
+ w: win.innerWidth,
186
+ h: win.innerHeight,
187
+ dpr: win.devicePixelRatio
188
+ },
189
+ browser: win.navigator.userAgent,
190
+ os: win.navigator.platform || "unknown",
191
+ colorScheme: win.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
192
+ }
193
+ };
194
+ }
195
+ //#endregion
196
+ //#region src/markdown.ts
197
+ /** Squash any text to a single markdown-safe line. */
198
+ function line(text) {
199
+ return text.replace(/\s+/g, " ").trim();
200
+ }
201
+ function label(pin) {
202
+ return pin.target?.anchor ?? pin.target?.tag?.toUpperCase() ?? "PIN";
203
+ }
204
+ function threadTail(thread) {
205
+ if (thread.length === 0) return [];
206
+ const tail = thread.slice(-3);
207
+ const skipped = thread.length - tail.length;
208
+ return [
209
+ "",
210
+ "Thread:",
211
+ ...skipped > 0 ? [`- … ${skipped} earlier message${skipped === 1 ? "" : "s"}`] : [],
212
+ ...tail.map((m) => `- ${m.role}: ${line(m.text)}`)
213
+ ];
214
+ }
215
+ function block(pin, thread) {
216
+ const { selector, url, source } = pin.target ?? {};
217
+ return [
218
+ `## Pin ${pin.id} — OPEN`,
219
+ `- label: ${line(label(pin))}`,
220
+ ...selector === void 0 ? [] : [`- selector: \`${line(selector)}\``],
221
+ ...source === void 0 ? [] : [`- source: ${line(source.line === void 0 ? source.file : `${source.file}:${source.line}`)}`],
222
+ ...url === void 0 ? [] : [`- url: ${line(url)}`],
223
+ "",
224
+ `> ${line(pin.text)}`,
225
+ ...threadTail(thread)
226
+ ].join("\n");
227
+ }
228
+ /** Serialize the open pins (resolved excluded) for pasting into any agent chat. */
229
+ function pinsToMarkdown(pins, threads) {
230
+ const open = pins.filter((p) => p.status === "open");
231
+ if (open.length === 0) return "No open pins.\n";
232
+ return `${open.map((p) => block(p, threads.get(p.id) ?? [])).join("\n\n")}\n`;
233
+ }
234
+ //#endregion
235
+ //#region src/screenshot.ts
236
+ const WEBP_QUALITY = .7;
237
+ const PLACEHOLDER_MAX = 32;
238
+ /** Element rect clamped to the viewport (CSS px); null when nothing is visible. */
239
+ function visibleCropRect(el) {
240
+ const win = el.ownerDocument.defaultView;
241
+ if (!win) return null;
242
+ const r = el.getBoundingClientRect();
243
+ const x = Math.max(r.left, 0);
244
+ const y = Math.max(r.top, 0);
245
+ const right = Math.min(r.right, win.innerWidth);
246
+ const bottom = Math.min(r.bottom, win.innerHeight);
247
+ if (right - x < 1 || bottom - y < 1) return null;
248
+ return {
249
+ x,
250
+ y,
251
+ width: right - x,
252
+ height: bottom - y
253
+ };
254
+ }
255
+ function captureSource(el) {
256
+ const win = el.ownerDocument.defaultView;
257
+ if (!win) return null;
258
+ const g = globalThis;
259
+ if (typeof g.OffscreenCanvas !== "function" || typeof g.createImageBitmap !== "function") return null;
260
+ const media = win.navigator?.mediaDevices;
261
+ if (typeof media?.getDisplayMedia !== "function") return null;
262
+ return {
263
+ win,
264
+ media
265
+ };
266
+ }
267
+ /** Play the stream into an off-DOM video element and wait for the first frame. */
268
+ async function firstFrame(win, stream) {
269
+ const video = win.document.createElement("video");
270
+ video.muted = true;
271
+ video.srcObject = stream;
272
+ await video.play();
273
+ if (video.readyState < 2) await new Promise((resolve) => {
274
+ video.addEventListener("loadeddata", () => resolve(), { once: true });
275
+ });
276
+ return video;
277
+ }
278
+ /** webp-encode a bitmap; also emit the ≤32px placeholder data URL. */
279
+ async function encode(bmp) {
280
+ const canvas = new OffscreenCanvas(bmp.width, bmp.height);
281
+ canvas.getContext("2d")?.drawImage(bmp, 0, 0);
282
+ const image = {
283
+ blob: await canvas.convertToBlob({
284
+ type: "image/webp",
285
+ quality: WEBP_QUALITY
286
+ }),
287
+ width: bmp.width,
288
+ height: bmp.height
289
+ };
290
+ const scale = PLACEHOLDER_MAX / Math.max(bmp.width, bmp.height);
291
+ const tw = Math.max(1, Math.round(bmp.width * Math.min(scale, 1)));
292
+ const th = Math.max(1, Math.round(bmp.height * Math.min(scale, 1)));
293
+ const thumb = new OffscreenCanvas(tw, th);
294
+ thumb.getContext("2d")?.drawImage(bmp, 0, 0, tw, th);
295
+ image.placeholder = `data:image/webp;base64,${toBase64(await (await thumb.convertToBlob({
296
+ type: "image/webp",
297
+ quality: .5
298
+ })).arrayBuffer())}`;
299
+ return image;
300
+ }
301
+ function toBase64(buffer) {
302
+ const bytes = new Uint8Array(buffer);
303
+ let bin = "";
304
+ for (const b of bytes) bin += String.fromCharCode(b);
305
+ return btoa(bin);
306
+ }
307
+ /**
308
+ * Best-effort capture of the element's visible viewport region. Resolves null
309
+ * whenever the environment cannot capture (no OffscreenCanvas/createImageBitmap,
310
+ * no getDisplayMedia, element off-screen, user denies the prompt) — never throws.
311
+ */
312
+ async function captureElement(el) {
313
+ const source = captureSource(el);
314
+ const crop = visibleCropRect(el);
315
+ if (source === null || crop === null) return null;
316
+ const { win, media } = source;
317
+ let stream = null;
318
+ try {
319
+ stream = await media.getDisplayMedia({
320
+ video: true,
321
+ audio: false,
322
+ preferCurrentTab: true
323
+ });
324
+ const video = await firstFrame(win, stream);
325
+ const sx = video.videoWidth / win.innerWidth;
326
+ const sy = video.videoHeight / win.innerHeight;
327
+ return await encode(await createImageBitmap(video, Math.round(crop.x * sx), Math.round(crop.y * sy), Math.max(1, Math.round(crop.width * sx)), Math.max(1, Math.round(crop.height * sy))));
328
+ } catch {
329
+ return null;
330
+ } finally {
331
+ if (stream) for (const track of stream.getTracks()) track.stop();
332
+ }
333
+ }
334
+ /**
335
+ * POST /attachments?kind=screenshot — raw webp body, bearer auth; unwraps the
336
+ * hub envelope `{ok:true,data:{attachment}}` and surfaces its error envelope.
337
+ */
338
+ async function uploadAttachment(endpoint, token, img) {
339
+ const base = endpoint.replace(/\/+$/, "");
340
+ const res = await fetch(`${base}/attachments?kind=screenshot`, {
341
+ method: "POST",
342
+ headers: {
343
+ authorization: `Bearer ${token}`,
344
+ "content-type": img.blob.type || "image/webp"
345
+ },
346
+ body: img.blob
347
+ });
348
+ const body = await res.json();
349
+ if (!res.ok || body.ok !== true || body.data === void 0) {
350
+ const e = body.error;
351
+ throw new Error(e?.code !== void 0 ? `${e.code}: ${e.message ?? "attachment upload failed"}` : `attachment upload failed (HTTP ${res.status})`);
352
+ }
353
+ return body.data.attachment;
354
+ }
355
+ //#endregion
356
+ //#region src/state.ts
357
+ function initialState() {
358
+ return {
359
+ pins: [],
360
+ threads: /* @__PURE__ */ new Map(),
361
+ draft: null,
362
+ mode: "idle",
363
+ activePinId: null,
364
+ inboxOpen: false,
365
+ connection: "connecting",
366
+ queuedIds: /* @__PURE__ */ new Set()
367
+ };
368
+ }
369
+ function createStore() {
370
+ let state = initialState();
371
+ const subscribers = /* @__PURE__ */ new Set();
372
+ function commit(next) {
373
+ state = next;
374
+ for (const fn of subscribers) fn(state);
375
+ }
376
+ return {
377
+ get: () => state,
378
+ subscribe(fn) {
379
+ subscribers.add(fn);
380
+ return () => subscribers.delete(fn);
381
+ },
382
+ update(patch) {
383
+ commit({
384
+ ...state,
385
+ ...patch
386
+ });
387
+ },
388
+ place(draft) {
389
+ commit({
390
+ ...state,
391
+ draft,
392
+ mode: "idle",
393
+ activePinId: null
394
+ });
395
+ },
396
+ discardDraft() {
397
+ commit({
398
+ ...state,
399
+ draft: null
400
+ });
401
+ },
402
+ commitDraft(pin) {
403
+ const pins = state.pins.some((p) => p.id === pin.id) ? state.pins.map((p) => p.id === pin.id ? pin : p) : [...state.pins, pin];
404
+ commit({
405
+ ...state,
406
+ pins,
407
+ draft: null,
408
+ activePinId: pin.id
409
+ });
410
+ }
411
+ };
412
+ }
413
+ /** Replace-by-id upsert; new pins append. */
414
+ function upsertPin(store, pin) {
415
+ const pins = store.get().pins;
416
+ store.update({ pins: pins.some((p) => p.id === pin.id) ? pins.map((p) => p.id === pin.id ? pin : p) : [...pins, pin] });
417
+ }
418
+ /** Append to the pin's thread, deduping by message id (REST echo vs WS event). */
419
+ function appendThreadMessage(store, message) {
420
+ const state = store.get();
421
+ const thread = state.threads.get(message.pinId) ?? [];
422
+ if (thread.some((m) => m.id === message.id)) return;
423
+ const threads = new Map(state.threads);
424
+ threads.set(message.pinId, [...thread, message]);
425
+ store.update({ threads });
426
+ }
427
+ /**
428
+ * Wire events mutate the store: pin.created upserts;
429
+ * pin.resolved / pin.verified / pin.linked replace the payload Pin;
430
+ * thread.message appends — payloads are the full post-mutation objects.
431
+ */
432
+ function applyHubEvent(store, event) {
433
+ if (event.eventType === "thread.message") {
434
+ appendThreadMessage(store, event.payload);
435
+ return;
436
+ }
437
+ const pin = event.payload;
438
+ if (typeof pin?.id !== "string") return;
439
+ upsertPin(store, pin);
440
+ }
441
+ /**
442
+ * Wire-status → UI-status mapping:
443
+ * resolved + no verification ⇒ "verify" (accept/reopen prompt);
444
+ * resolved + verification ⇒ "resolved";
445
+ * open + empty thread or last message human ⇒ "waiting";
446
+ * open + last message agent|mirror ⇒ "replied".
447
+ * The prototype's WORKING/APPLIED chips need an event vocabulary the hub does not emit — excluded here.
448
+ */
449
+ function deriveUiStatus(pin, thread) {
450
+ if (pin.status === "resolved") return pin.verification ? "resolved" : "verify";
451
+ const last = thread[thread.length - 1];
452
+ if (!last || last.role === "human") return "waiting";
453
+ return "replied";
454
+ }
455
+ //#endregion
456
+ //#region src/transport/mirror.ts
457
+ function randomBase36(length) {
458
+ let out = "";
459
+ while (out.length < length) out += Math.random().toString(36).slice(2);
460
+ return out.slice(0, length);
461
+ }
462
+ /** In-memory fallback when no Web Storage exists (SSR import, tests). */
463
+ function memoryStorage() {
464
+ const map = /* @__PURE__ */ new Map();
465
+ return {
466
+ getItem: (k) => map.get(k) ?? null,
467
+ setItem: (k, v) => void map.set(k, v),
468
+ removeItem: (k) => void map.delete(k)
469
+ };
470
+ }
471
+ var Mirror = class {
472
+ #storage;
473
+ #prefix;
474
+ constructor(storage, endpoint) {
475
+ this.#storage = storage;
476
+ this.#prefix = `pinbox:${endpoint.replace(/\/+$/, "")}`;
477
+ }
478
+ #key(name) {
479
+ return `${this.#prefix}:${name}`;
480
+ }
481
+ #readRaw(name) {
482
+ try {
483
+ return this.#storage.getItem(this.#key(name));
484
+ } catch {
485
+ return null;
486
+ }
487
+ }
488
+ #read(name, fallback) {
489
+ try {
490
+ const raw = this.#readRaw(name);
491
+ return raw === null ? fallback : JSON.parse(raw);
492
+ } catch {
493
+ return fallback;
494
+ }
495
+ }
496
+ #write(name, value) {
497
+ try {
498
+ this.#storage.setItem(this.#key(name), value);
499
+ } catch {}
500
+ }
501
+ consumerId() {
502
+ const id = this.#readRaw("consumer");
503
+ if (id !== null && id !== "") return id;
504
+ const fresh = randomBase36(10);
505
+ this.#write("consumer", fresh);
506
+ return fresh;
507
+ }
508
+ cursor() {
509
+ const n = Number(this.#readRaw("cursor"));
510
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
511
+ }
512
+ writeCursor(seq) {
513
+ this.#write("cursor", String(seq));
514
+ }
515
+ pins() {
516
+ return this.#read("pins", []);
517
+ }
518
+ writePins(pins) {
519
+ this.#write("pins", JSON.stringify(pins));
520
+ }
521
+ /** Threads keyed by pin id — the offline read-only thread render (plan: "mirror
522
+ * renders read-only threads"). Only pins whose thread was fetched appear. */
523
+ threads() {
524
+ return this.#read("threads", {});
525
+ }
526
+ /** `null` distinguishes "never mirrored" from "mirrored and empty". */
527
+ thread(pinId) {
528
+ return this.threads()[pinId] ?? null;
529
+ }
530
+ writeThread(pinId, messages) {
531
+ this.#write("threads", JSON.stringify({
532
+ ...this.threads(),
533
+ [pinId]: messages
534
+ }));
535
+ }
536
+ /** No-op for an unmirrored pin: a lone reply is not a thread. */
537
+ appendThread(pinId, message) {
538
+ const existing = this.thread(pinId);
539
+ if (existing === null) return;
540
+ this.writeThread(pinId, [...existing, message]);
541
+ }
542
+ outbox() {
543
+ return this.#read("outbox", []);
544
+ }
545
+ writeOutbox(entries) {
546
+ if (entries.length === 0) {
547
+ try {
548
+ this.#storage.removeItem(this.#key("outbox"));
549
+ } catch {}
550
+ return;
551
+ }
552
+ this.#write("outbox", JSON.stringify(entries));
553
+ }
554
+ pushOutbox(entry) {
555
+ this.writeOutbox([...this.outbox(), entry]);
556
+ }
557
+ };
558
+ //#endregion
559
+ //#region src/transport/rest.ts
560
+ var HubError = class extends Error {
561
+ code;
562
+ status;
563
+ hint;
564
+ constructor(code, message, status, hint) {
565
+ super(message);
566
+ this.name = "HubError";
567
+ this.code = code;
568
+ this.status = status;
569
+ this.hint = hint;
570
+ }
571
+ };
572
+ /** Unwrap {ok:true,data} or throw the envelope's error as a HubError. */
573
+ async function decodeEnvelope(res) {
574
+ let envelope;
575
+ try {
576
+ envelope = await res.json();
577
+ } catch {
578
+ throw new HubError("E_HUB_UNREACHABLE", `hub returned non-JSON (HTTP ${res.status})`, res.status);
579
+ }
580
+ if (res.ok && envelope.ok === true) return envelope.data;
581
+ const e = envelope.error;
582
+ throw new HubError(e?.code ?? "E_INTERNAL", e?.message ?? `hub error (HTTP ${res.status})`, res.status, e?.hint);
583
+ }
584
+ var RestClient = class {
585
+ #base;
586
+ #token;
587
+ #fetch;
588
+ constructor(endpoint, token, fetchFn) {
589
+ this.#base = endpoint.replace(/\/+$/, "");
590
+ this.#token = token;
591
+ this.#fetch = fetchFn ?? ((input, init) => fetch(input, init));
592
+ }
593
+ async #request(method, path, body) {
594
+ let res;
595
+ try {
596
+ res = await this.#fetch(`${this.#base}${path}`, {
597
+ method,
598
+ headers: {
599
+ authorization: `Bearer ${this.#token}`,
600
+ ...body === void 0 ? {} : { "content-type": "application/json" }
601
+ },
602
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
603
+ });
604
+ } catch (cause) {
605
+ throw new HubError("E_HUB_UNREACHABLE", `hub unreachable: ${cause instanceof Error ? cause.message : "network failure"}`, 0);
606
+ }
607
+ return decodeEnvelope(res);
608
+ }
609
+ listPins() {
610
+ return this.#request("GET", "/pins");
611
+ }
612
+ createPin(input) {
613
+ return this.#request("POST", "/pins", input);
614
+ }
615
+ getThread(pinId) {
616
+ return this.#request("GET", `/pins/${pinId}/thread`);
617
+ }
618
+ reply(pinId, text, attachments) {
619
+ return this.#request("POST", `/pins/${pinId}/thread`, {
620
+ role: "human",
621
+ text,
622
+ ...attachments === void 0 ? {} : { attachments }
623
+ });
624
+ }
625
+ resolve(pinId, note) {
626
+ return this.#request("POST", `/pins/${pinId}/resolve`, {
627
+ by: "human",
628
+ ...note === void 0 ? {} : { note }
629
+ });
630
+ }
631
+ verify(pinId, outcome) {
632
+ return this.#request("POST", `/pins/${pinId}/verify`, { outcome });
633
+ }
634
+ };
635
+ //#endregion
636
+ //#region src/transport.ts
637
+ const WS_PATH = "/ws";
638
+ const WS_PROTOCOL_VERSION = 1;
639
+ const WS_MIN_PROTOCOL = 1;
640
+ const WS_TOKEN_SUBPROTOCOL_PREFIX = "pinbox.token.";
641
+ const WS_CLOSE_PROTOCOL = 4400;
642
+ const BACKOFF_BASE_MS = 1e3;
643
+ const BACKOFF_MAX_MS = 3e4;
644
+ /** How many times a reconcile re-reads the pin list before conceding to live events. */
645
+ const SNAPSHOT_ATTEMPTS = 3;
646
+ /** `/ws` UNDER the endpoint, not at the origin root: a cloud hub is commonly
647
+ * mounted at a path prefix (`https://hub/tenant/abc`), and resolving "/ws" against
648
+ * it would silently drop the prefix. Query/hash never belong on the socket url. */
649
+ function wsUrl(endpoint) {
650
+ const url = new URL(endpoint);
651
+ url.pathname = `${url.pathname.replace(/\/+$/, "")}${WS_PATH}`;
652
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
653
+ url.search = "";
654
+ url.hash = "";
655
+ return url.toString();
656
+ }
657
+ /** Handshake rule 1: either side of the version window excludes the peer. */
658
+ function incompatibleWith(frame) {
659
+ return (frame.minProtocol ?? 1) > WS_PROTOCOL_VERSION || (frame.protocol ?? 1) < WS_MIN_PROTOCOL;
660
+ }
661
+ /** The optimistic pin a queued outbox entry stands for until the flush replaces it. */
662
+ function outboxPin(entry) {
663
+ return {
664
+ ...entry.input,
665
+ id: entry.localId,
666
+ schemaVersion: 1,
667
+ status: "open",
668
+ createdAt: entry.at ?? (/* @__PURE__ */ new Date()).toISOString()
669
+ };
670
+ }
671
+ var HubTransport = class {
672
+ /** Stable per install, persisted (`pinbox:<endpoint>:consumer`). */
673
+ consumerId;
674
+ #opts;
675
+ #mirror;
676
+ #rest;
677
+ #scheduler;
678
+ #ws = null;
679
+ #cursor;
680
+ /** Live frames arriving in the accept→catch-up window, drained after catch-up. */
681
+ #buffer = [];
682
+ #caughtUp = false;
683
+ #live = false;
684
+ #closed = false;
685
+ #incompatible = false;
686
+ #attempt = 0;
687
+ #timer = null;
688
+ /** One flush at a time — reconnect and the live write path both drain the outbox. */
689
+ #flushing = false;
690
+ constructor(opts) {
691
+ this.#opts = opts;
692
+ const storage = opts.storage ?? globalThis.localStorage ?? memoryStorage();
693
+ this.#mirror = new Mirror(storage, opts.endpoint);
694
+ this.#rest = new RestClient(opts.endpoint, opts.token, opts.fetchFn);
695
+ this.#scheduler = opts.scheduler ?? {
696
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
697
+ clearTimeout: (id) => clearTimeout(id)
698
+ };
699
+ this.consumerId = this.#mirror.consumerId();
700
+ this.#cursor = this.#mirror.cursor();
701
+ }
702
+ /** Last-known pin list — the offline read-only render seed. */
703
+ mirrorPins() {
704
+ return this.#mirror.pins();
705
+ }
706
+ /** Optimistic pins for the queued outbox — offline reloads render + flag them. */
707
+ outboxPins() {
708
+ return this.#mirror.outbox().map(outboxPin);
709
+ }
710
+ /** Last-known thread for a pin — the offline read-only thread render seed.
711
+ * Empty for a pin whose thread was never fetched while connected. */
712
+ mirrorThread(pinId) {
713
+ return this.#mirror.thread(pinId) ?? [];
714
+ }
715
+ /** hello → buffer live frames → apply catch-up → drain buffer. */
716
+ connect() {
717
+ if (this.#closed || this.#incompatible || this.#ws !== null) return;
718
+ this.#opts.onConnection("connecting");
719
+ this.#caughtUp = false;
720
+ this.#buffer = [];
721
+ const ws = (this.#opts.webSocket ?? ((url, protocols) => new WebSocket(url, protocols)))(wsUrl(this.#opts.endpoint), [WS_TOKEN_SUBPROTOCOL_PREFIX + this.#opts.token]);
722
+ this.#ws = ws;
723
+ ws.onopen = () => ws.send(JSON.stringify({
724
+ type: "hello",
725
+ protocol: WS_PROTOCOL_VERSION,
726
+ consumerId: this.consumerId,
727
+ lastSeq: this.#cursor
728
+ }));
729
+ ws.onmessage = (ev) => this.#onFrame(ev.data);
730
+ ws.onclose = (ev) => this.#onDown(ws, ev.code);
731
+ ws.onerror = () => this.#onDown(ws);
732
+ }
733
+ close() {
734
+ this.#closed = true;
735
+ if (this.#timer !== null) {
736
+ this.#scheduler.clearTimeout(this.#timer);
737
+ this.#timer = null;
738
+ }
739
+ const ws = this.#ws;
740
+ this.#ws = null;
741
+ this.#live = false;
742
+ ws?.close(1e3, "client closed");
743
+ }
744
+ listPins() {
745
+ return this.#rest.listPins();
746
+ }
747
+ /** Offline ⇒ queued in the outbox, optimistic local pin (client wins on new pins). */
748
+ async createPin(input) {
749
+ if (this.#live) try {
750
+ return await this.#afterWrite(this.#rest.createPin(input));
751
+ } catch (err) {
752
+ if (!(err instanceof HubError) || err.code !== "E_HUB_UNREACHABLE") throw err;
753
+ }
754
+ const entry = {
755
+ localId: `pin_${randomBase36(10)}`,
756
+ input,
757
+ at: (/* @__PURE__ */ new Date()).toISOString()
758
+ };
759
+ this.#mirror.pushOutbox(entry);
760
+ this.#emitOutbox();
761
+ return outboxPin(entry);
762
+ }
763
+ /** Mirrored on every success, served from the mirror when the hub is unreachable —
764
+ * an offline reload renders read-only threads instead of empty ones. Any other
765
+ * hub error (auth, not-found) surfaces: the mirror is a fallback, not a mask. */
766
+ async getThread(pinId) {
767
+ try {
768
+ const messages = await this.#rest.getThread(pinId);
769
+ this.#mirror.writeThread(pinId, messages);
770
+ return messages;
771
+ } catch (err) {
772
+ if (!(err instanceof HubError) || err.code !== "E_HUB_UNREACHABLE") throw err;
773
+ const mirrored = this.#mirror.thread(pinId);
774
+ if (mirrored === null) throw err;
775
+ return mirrored;
776
+ }
777
+ }
778
+ async reply(pinId, text, attachments) {
779
+ const message = await this.#afterWrite(this.#rest.reply(pinId, text, attachments));
780
+ this.#mirror.appendThread(pinId, message);
781
+ return message;
782
+ }
783
+ resolve(pinId, note) {
784
+ return this.#afterWrite(this.#rest.resolve(pinId, note));
785
+ }
786
+ verify(pinId, outcome) {
787
+ return this.#afterWrite(this.#rest.verify(pinId, outcome));
788
+ }
789
+ /** A write the hub accepted proves it is reachable, so anything the socket-still-up
790
+ * failure path queued can go now — `#reconcile` only runs on reconnect, and while
791
+ * the socket stays healthy that reconnect may never come. */
792
+ async #afterWrite(op) {
793
+ const result = await op;
794
+ await this.#drainOutbox();
795
+ return result;
796
+ }
797
+ async #drainOutbox() {
798
+ if (this.#mirror.outbox().length === 0) return;
799
+ try {
800
+ const flushed = await this.#flushOutbox();
801
+ if (flushed.length === 0) return;
802
+ const all = [...this.#mirror.pins(), ...flushed];
803
+ this.#opts.onPins?.(all);
804
+ this.#mirror.writePins(all);
805
+ } catch {}
806
+ }
807
+ #onFrame(data) {
808
+ let frame;
809
+ try {
810
+ frame = JSON.parse(data);
811
+ } catch {
812
+ return;
813
+ }
814
+ if (frame.type === "event") this.#onEventFrame(frame);
815
+ else if (frame.type === "catch-up") this.#onCatchUp(frame);
816
+ }
817
+ #toEvent(frame) {
818
+ return {
819
+ seq: frame.seq ?? 0,
820
+ eventType: frame.eventType ?? "",
821
+ at: frame.at ?? "",
822
+ payload: frame.payload
823
+ };
824
+ }
825
+ #onEventFrame(frame) {
826
+ const event = this.#toEvent(frame);
827
+ if (!this.#caughtUp) {
828
+ this.#buffer.push(event);
829
+ return;
830
+ }
831
+ this.#apply(event);
832
+ }
833
+ /** Deliver once, monotonically: seq at or below the cursor is already seen. */
834
+ #apply(event) {
835
+ if (event.seq <= this.#cursor) return;
836
+ this.#opts.onEvent(event);
837
+ this.#cursor = event.seq;
838
+ this.#mirror.writeCursor(this.#cursor);
839
+ }
840
+ /** The client symmetrically closes on an excluding protocol window — a clear
841
+ * upgrade message, never silent misbehavior. */
842
+ #failIncompatible() {
843
+ this.#incompatible = true;
844
+ const ws = this.#ws;
845
+ this.#ws = null;
846
+ ws?.close(WS_CLOSE_PROTOCOL, "protocol version incompatible");
847
+ this.#opts.onConnection("incompatible");
848
+ }
849
+ #onCatchUp(frame) {
850
+ if (incompatibleWith(frame)) {
851
+ this.#failIncompatible();
852
+ return;
853
+ }
854
+ for (const e of frame.events ?? []) this.#apply(this.#toEvent(e));
855
+ if ((frame.lastSeq ?? 0) > this.#cursor) {
856
+ this.#cursor = frame.lastSeq ?? 0;
857
+ this.#mirror.writeCursor(this.#cursor);
858
+ }
859
+ this.#caughtUp = true;
860
+ const buffered = this.#buffer;
861
+ this.#buffer = [];
862
+ for (const e of buffered) this.#apply(e);
863
+ this.#live = true;
864
+ this.#attempt = 0;
865
+ this.#opts.onConnection("live");
866
+ this.#reconcile();
867
+ }
868
+ #onDown(ws, code) {
869
+ if (this.#ws !== ws) return;
870
+ this.#ws = null;
871
+ this.#live = false;
872
+ this.#caughtUp = false;
873
+ if (this.#closed || this.#incompatible) return;
874
+ if (code === WS_CLOSE_PROTOCOL) {
875
+ this.#incompatible = true;
876
+ this.#opts.onConnection("incompatible");
877
+ return;
878
+ }
879
+ this.#opts.onConnection("offline");
880
+ this.#scheduleReconnect();
881
+ }
882
+ /** Exponential backoff 1s→30s with jitter, resetting on a healthy connection. */
883
+ #scheduleReconnect() {
884
+ if (this.#timer !== null) return;
885
+ const base = Math.min(BACKOFF_BASE_MS * 2 ** this.#attempt, BACKOFF_MAX_MS);
886
+ const delay = Math.min(Math.round(base * (1 + Math.random() * .25)), BACKOFF_MAX_MS);
887
+ this.#attempt += 1;
888
+ this.#timer = this.#scheduler.setTimeout(() => {
889
+ this.#timer = null;
890
+ this.connect();
891
+ }, delay);
892
+ }
893
+ /** Refresh listPins (hub wins on status) → flush the outbox (client wins on new pins) → persist the fresh mirror. */
894
+ async #reconcile() {
895
+ try {
896
+ const pins = await this.#snapshotPins();
897
+ if (pins !== null) this.#opts.onPins?.(pins);
898
+ const flushed = await this.#flushOutbox();
899
+ if (flushed.length === 0) {
900
+ if (pins !== null) this.#mirror.writePins(pins);
901
+ return;
902
+ }
903
+ const all = [...pins ?? this.#mirror.pins(), ...flushed];
904
+ this.#opts.onPins?.(all);
905
+ this.#mirror.writePins(all);
906
+ } catch {}
907
+ }
908
+ /** `onPins` is a wholesale replacement, so a snapshot must not be older than the
909
+ * events already applied: a `pin.resolved` landing mid-GET would be overwritten by
910
+ * the staler list, and the advanced cursor would stop it ever reapplying. Re-read
911
+ * at the new cursor instead; if live events keep overtaking it, concede — they are
912
+ * the newer truth, and the UI already has them. */
913
+ async #snapshotPins() {
914
+ for (let attempt = 0; attempt < SNAPSHOT_ATTEMPTS; attempt += 1) {
915
+ const takenAt = this.#cursor;
916
+ const pins = await this.#rest.listPins();
917
+ if (this.#cursor === takenAt) return pins;
918
+ }
919
+ return null;
920
+ }
921
+ async #flushOutbox() {
922
+ if (this.#flushing) return [];
923
+ this.#flushing = true;
924
+ const created = [];
925
+ try {
926
+ let remaining = this.#mirror.outbox();
927
+ for (const entry of [...remaining]) {
928
+ created.push(await this.#rest.createPin(entry.input));
929
+ remaining = remaining.filter((e) => e.localId !== entry.localId);
930
+ this.#mirror.writeOutbox(remaining);
931
+ this.#emitOutbox();
932
+ }
933
+ } finally {
934
+ this.#flushing = false;
935
+ }
936
+ return created;
937
+ }
938
+ #emitOutbox() {
939
+ this.#opts.onOutbox?.(this.#mirror.outbox().map((e) => e.localId));
940
+ }
941
+ };
942
+ //#endregion
943
+ //#region src/ui/bar.ts
944
+ const PIN_ICON = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\"><rect x=\"3\" y=\"1.5\" width=\"10\" height=\"6.5\" rx=\"1\"/><path d=\"M8 8v6.5\"/></svg>";
945
+ 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>";
946
+ const THEME_ICON = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\"><path d=\"M8 1.6a6.4 6.4 0 100 12.8A5 5 0 018 1.6z\"/></svg>";
947
+ const COPY_ICON = "<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>";
948
+ 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>";
949
+ const CONNECTION_LABEL = {
950
+ connecting: "PINBOX",
951
+ live: "PINBOX",
952
+ offline: "PINBOX · OFFLINE",
953
+ incompatible: "PINBOX · UPDATE NEEDED"
954
+ };
955
+ function createBar(doc, on) {
956
+ const root = doc.createElement("div");
957
+ root.className = "pb-bar";
958
+ 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}</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>`;
959
+ const ref = (name) => root.querySelector(`[data-ref="${name}"]`);
960
+ const label = ref("label");
961
+ const pinBtn = ref("pin");
962
+ const inboxBtn = ref("inbox");
963
+ const count = ref("count");
964
+ pinBtn.addEventListener("click", on.onPin);
965
+ inboxBtn.addEventListener("click", on.onInbox);
966
+ ref("copy").addEventListener("click", on.onCopy);
967
+ ref("theme").addEventListener("click", on.onTheme);
968
+ ref("help").addEventListener("click", on.onHelp);
969
+ return {
970
+ root,
971
+ update(state) {
972
+ const text = state.mode === "placing" ? "CLICK TO PIN" : CONNECTION_LABEL[state.connection];
973
+ if (label.textContent !== text) label.textContent = text;
974
+ pinBtn.classList.toggle("hot", state.mode === "placing");
975
+ inboxBtn.classList.toggle("lit", state.inboxOpen);
976
+ const open = String(state.pins.filter((p) => p.status !== "resolved").length);
977
+ if (count.textContent !== open) count.textContent = open;
978
+ }
979
+ };
980
+ }
981
+ //#endregion
982
+ //#region src/ui/html.ts
983
+ const ESCAPES = {
984
+ "&": "&amp;",
985
+ "<": "&lt;",
986
+ ">": "&gt;",
987
+ "\"": "&quot;"
988
+ };
989
+ /** Escape text for safe inclusion in innerHTML strings. */
990
+ function esc(value) {
991
+ return String(value ?? "").replace(/[&<>"]/g, (m) => ESCAPES[m]);
992
+ }
993
+ /**
994
+ * Allowlist a URL for use in href/src attributes. `esc()` alone is NOT enough for URL
995
+ * attributes: `javascript:alert(1)` contains no `&<>"` characters, so it survives HTML
996
+ * escaping intact — and link/attachment URLs are hub data that connectors (and, in cloud,
997
+ * other users) populate. Relative URLs and http(s) pass; every other scheme yields "".
998
+ */
999
+ function safeUrl(value) {
1000
+ const raw = String(value ?? "").replace(/[\x00-\x1f\x7f]/g, "").trim();
1001
+ if (raw === "") return "";
1002
+ const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(raw);
1003
+ if (scheme !== null && !/^https?$/i.test(scheme[1])) return "";
1004
+ return raw;
1005
+ }
1006
+ /** Two-digit pin number, prototype-style: 1 → "01". */
1007
+ function pinNumber(n) {
1008
+ return String(n).padStart(2, "0");
1009
+ }
1010
+ //#endregion
1011
+ //#region src/ui/card.ts
1012
+ const STATUS_LABEL = {
1013
+ open: "OPEN",
1014
+ waiting: "OPEN",
1015
+ replied: "REPLIED",
1016
+ resolved: "RESOLVED",
1017
+ verify: "VERIFY"
1018
+ };
1019
+ 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>";
1020
+ 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>";
1021
+ const ctxByCard = /* @__PURE__ */ new WeakMap();
1022
+ /** The prototype's `_h` innerHTML memo, kept off the DOM node. */
1023
+ const nodeMemo = /* @__PURE__ */ new WeakMap();
1024
+ function timeOf(at) {
1025
+ const d = new Date(at);
1026
+ if (Number.isNaN(d.getTime())) return "";
1027
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
1028
+ }
1029
+ function isImage(att) {
1030
+ if (att.contentType?.startsWith("image/")) return true;
1031
+ return /\.(png|webp|jpe?g|gif)$/i.test(att.url ?? att.path ?? "");
1032
+ }
1033
+ function fileName(att) {
1034
+ const source = att.url ?? att.path ?? att.id;
1035
+ return source.split("/").pop() ?? source;
1036
+ }
1037
+ /** Thumbnail when the attachment is an image; the error listener degrades it to a chip. */
1038
+ function attachmentsHtml(m) {
1039
+ if (!m.attachments?.length) return "";
1040
+ 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>`;
1041
+ }
1042
+ function messageHtml(m) {
1043
+ 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">Agent</span><span class="tm">${esc(timeOf(m.at))}</span></div><div class="txt">${esc(m.text)}</div>${attachmentsHtml(m)}</div></div>`;
1044
+ const mirror = m.role === "mirror";
1045
+ const origin = mirror ? m.origin ?? "mirror" : null;
1046
+ const who = origin ? origin.split(":")[1] ?? origin : "You";
1047
+ const initials = who.slice(0, 2).toUpperCase();
1048
+ const via = origin ? `<span class="via-tag"><span>${esc(origin)}</span></span>` : "";
1049
+ return `<div class="pb-msg you"><div class="pb-av${mirror ? " via" : ""}">${esc(initials)}</div><div class="col"><div class="line"><span class="who">${esc(who)}</span><span class="tm">${esc(timeOf(m.at))}</span>${via}</div><div class="txt">${esc(m.text)}</div>${attachmentsHtml(m)}</div></div>`;
1050
+ }
1051
+ /** Keyed thread patching: appends/patches [data-iid] nodes only, never rebuilds. */
1052
+ function patchThread(threadEl, messages) {
1053
+ let appended = false;
1054
+ for (const m of messages) {
1055
+ let node = threadEl.querySelector(`[data-iid="${m.id}"]`);
1056
+ const html = messageHtml(m);
1057
+ if (!node) {
1058
+ node = threadEl.ownerDocument.createElement("div");
1059
+ node.className = "pb-msg-w";
1060
+ node.setAttribute("data-iid", m.id);
1061
+ node.innerHTML = html;
1062
+ nodeMemo.set(node, html);
1063
+ threadEl.appendChild(node);
1064
+ appended = true;
1065
+ } else if (nodeMemo.get(node) !== html) {
1066
+ node.innerHTML = html;
1067
+ nodeMemo.set(node, html);
1068
+ }
1069
+ }
1070
+ if (appended) threadEl.scrollTop = threadEl.scrollHeight;
1071
+ }
1072
+ function ensureShell(root) {
1073
+ let card = root.querySelector(".pb-card");
1074
+ if (!card) {
1075
+ card = root.ownerDocument.createElement("div");
1076
+ card.className = "pb-card";
1077
+ card.hidden = true;
1078
+ root.appendChild(card);
1079
+ }
1080
+ if (!ctxByCard.has(card)) {
1081
+ const ctx = {
1082
+ pid: null,
1083
+ parts: {},
1084
+ actions: null
1085
+ };
1086
+ ctxByCard.set(card, ctx);
1087
+ card.addEventListener("click", (e) => onCardClick(card, ctx, e));
1088
+ }
1089
+ return card;
1090
+ }
1091
+ function submit(card, ctx) {
1092
+ const ta = card.querySelector("textarea");
1093
+ const text = ta?.value.trim();
1094
+ if (!ta || !text || !ctx.pid) return;
1095
+ ctx.actions.send(ctx.pid === "draft" ? "draft" : ctx.pid, text);
1096
+ ta.value = "";
1097
+ }
1098
+ function onCardClick(card, ctx, e) {
1099
+ const action = e.target.closest?.("[data-action]")?.getAttribute("data-action");
1100
+ if (!action || !ctx.pid) return;
1101
+ if (action === "send") submit(card, ctx);
1102
+ else if (action === "close") ctx.actions.close();
1103
+ else if (ctx.pid !== "draft") {
1104
+ if (action === "resolve") ctx.actions.resolve(ctx.pid);
1105
+ else if (action === "verify-accept") ctx.actions.verify(ctx.pid, "accepted");
1106
+ else if (action === "verify-reopen") {
1107
+ ctx.actions.verify(ctx.pid, "reopened");
1108
+ card.querySelector("textarea")?.focus();
1109
+ }
1110
+ }
1111
+ }
1112
+ function buildSkeleton(card, ctx, isDraft, hasThread) {
1113
+ card.innerHTML = "<div class=\"in\"><div class=\"pb-hd\" data-ref=\"hd\"></div><div data-ref=\"link\"></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>";
1114
+ const ta = card.querySelector("textarea");
1115
+ ta.placeholder = hasThread ? "Ask a question or request a change…" : "What should change here?";
1116
+ ta.addEventListener("keydown", (e) => {
1117
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
1118
+ e.preventDefault();
1119
+ submit(card, ctx);
1120
+ } else if (e.key === "Escape") ctx.actions.close();
1121
+ });
1122
+ card.querySelector("[data-ref=\"thread\"]")?.addEventListener("error", (e) => {
1123
+ const img = e.target;
1124
+ const wrap = img.tagName === "IMG" ? img.closest(".pb-att") : null;
1125
+ if (wrap) wrap.outerHTML = `<span class="pb-att-chip">${esc(img.getAttribute("alt") ?? "attachment")}</span>`;
1126
+ }, true);
1127
+ if (isDraft) ta.focus();
1128
+ }
1129
+ function hdHtml(n, targetLabel, status, resolvable) {
1130
+ 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">` + (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>`;
1131
+ }
1132
+ /** Link badge: pin.links[0] read-only — no picker, no unlink yet. */
1133
+ function linkHtml(pin) {
1134
+ const link = pin?.links?.[0];
1135
+ if (!link) return "";
1136
+ 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>`;
1137
+ }
1138
+ function verifyHtml(status) {
1139
+ if (status !== "verify") return "";
1140
+ 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>";
1141
+ }
1142
+ function rowHtml(hasThread) {
1143
+ return `<div class="pb-kbd">⌘ ↵</div><button type="button" class="pb-bt-solid" data-action="send">${hasThread ? "Reply" : "Comment"}</button>`;
1144
+ }
1145
+ /**
1146
+ * Viewport-aware placement, ported verbatim (prototype lines 660–668): measure
1147
+ * the rendered card, flip left when it would overflow right, clamp between
1148
+ * scrollY + margin and the command-bar clearance — never off-screen.
1149
+ */
1150
+ function position(card, at) {
1151
+ const win = card.ownerDocument.defaultView;
1152
+ if (!win) return;
1153
+ const W = 344;
1154
+ const m = 12;
1155
+ const barClear = 84;
1156
+ let left = at.x + 22;
1157
+ if (left + W > win.scrollX + win.innerWidth - m) left = at.x - W - 22;
1158
+ left = Math.max(win.scrollX + m, left);
1159
+ const h = card.querySelector(".in")?.offsetHeight ?? 0;
1160
+ const minTop = win.scrollY + m;
1161
+ const maxTop = win.scrollY + win.innerHeight - h - barClear;
1162
+ card.style.left = `${left}px`;
1163
+ card.style.top = `${Math.max(minTop, Math.min(at.y - 60, maxTop))}px`;
1164
+ }
1165
+ function setPart(card, ctx, ref, html) {
1166
+ if (ctx.parts[ref] === html) return;
1167
+ const el = card.querySelector(`[data-ref="${ref}"]`);
1168
+ if (el) {
1169
+ el.innerHTML = html;
1170
+ ctx.parts[ref] = html;
1171
+ }
1172
+ }
1173
+ function activePin(state) {
1174
+ if (!state.activePinId) return null;
1175
+ return state.pins.find((p) => p.id === state.activePinId) ?? null;
1176
+ }
1177
+ /** Ordinal among visible pins (resolved pins hide unless active); drafts number last. */
1178
+ function ordinalOf(state, pin) {
1179
+ const visible = state.pins.filter((p) => p.status !== "resolved" || p.id === state.activePinId);
1180
+ return pin ? visible.indexOf(pin) + 1 : visible.length + 1;
1181
+ }
1182
+ function anchorOf(pin, draft) {
1183
+ const r = pin?.target?.rect;
1184
+ if (!r) return draft?.placedAt ?? {
1185
+ x: 0,
1186
+ y: 0
1187
+ };
1188
+ return {
1189
+ x: r.x + r.width / 2,
1190
+ y: r.y + r.height / 2
1191
+ };
1192
+ }
1193
+ /**
1194
+ * The card's heading. A terminal `pinbox pin` has no anchor and no tag, so "PIN"
1195
+ * labels the card without claiming an element that was never captured.
1196
+ */
1197
+ function labelOf(target) {
1198
+ return target?.anchor ?? target?.tag?.toUpperCase() ?? "PIN";
1199
+ }
1200
+ function viewOf(state) {
1201
+ const pin = activePin(state);
1202
+ const pid = pin?.id ?? (state.draft ? "draft" : null);
1203
+ if (!pid) return null;
1204
+ const thread = pin ? state.threads.get(pin.id) ?? [] : [];
1205
+ return {
1206
+ pid,
1207
+ pin,
1208
+ thread,
1209
+ n: ordinalOf(state, pin),
1210
+ status: pin ? deriveUiStatus(pin, thread) : null,
1211
+ label: labelOf(pin?.target ?? state.draft?.target.target),
1212
+ at: anchorOf(pin, state.draft)
1213
+ };
1214
+ }
1215
+ /** Render the thread card for a state snapshot: the active pin, or the draft. */
1216
+ function renderCard(root, state, actions) {
1217
+ const card = ensureShell(root);
1218
+ const ctx = ctxByCard.get(card);
1219
+ ctx.actions = actions;
1220
+ const view = viewOf(state);
1221
+ if (!view) {
1222
+ card.hidden = true;
1223
+ ctx.pid = null;
1224
+ return;
1225
+ }
1226
+ if (ctx.pid !== view.pid) {
1227
+ ctx.pid = view.pid;
1228
+ ctx.parts = {};
1229
+ buildSkeleton(card, ctx, view.pid === "draft", view.thread.length > 0);
1230
+ }
1231
+ card.hidden = false;
1232
+ const queued = view.pin !== null && state.queuedIds.has(view.pin.id);
1233
+ const statusLabel = queued ? "QUEUED" : view.status ? STATUS_LABEL[view.status] : "NEW";
1234
+ const resolvable = view.pin?.status === "open" && !queued;
1235
+ setPart(card, ctx, "hd", hdHtml(view.n, view.label, statusLabel, resolvable));
1236
+ setPart(card, ctx, "link", linkHtml(view.pin));
1237
+ setPart(card, ctx, "verify", verifyHtml(view.status));
1238
+ setPart(card, ctx, "row", rowHtml(view.thread.length > 0));
1239
+ const threadEl = card.querySelector("[data-ref=\"thread\"]");
1240
+ if (threadEl) patchThread(threadEl, view.thread);
1241
+ position(card, view.at);
1242
+ }
1243
+ //#endregion
1244
+ //#region src/ui/drawer.ts
1245
+ 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>";
1246
+ const STATUS_TEXT = {
1247
+ open: "OPEN",
1248
+ waiting: "OPEN",
1249
+ replied: "REPLIED",
1250
+ resolved: "RESOLVED",
1251
+ verify: "VERIFY"
1252
+ };
1253
+ const STATUS_DOT = {
1254
+ open: "var(--pb-fg4)",
1255
+ waiting: "var(--pb-fg4)",
1256
+ replied: "var(--pb-info)",
1257
+ resolved: "var(--pb-ok)",
1258
+ verify: "var(--pb-amber)"
1259
+ };
1260
+ /**
1261
+ * The place a row names. A browser pin has a selector; a terminal `pinbox pin` has a
1262
+ * source anchor instead; a pin created with no anchor at all names nowhere.
1263
+ */
1264
+ function locusOf(pin) {
1265
+ return pin.target?.selector ?? pin.target?.source?.file ?? "";
1266
+ }
1267
+ function itemHtml(pin, n, active, thread, queued) {
1268
+ const status = deriveUiStatus(pin, thread);
1269
+ const link = pin.links?.[0];
1270
+ 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>`;
1271
+ }
1272
+ function createDrawer(doc, on) {
1273
+ const root = doc.createElement("div");
1274
+ root.className = "pb-drawer";
1275
+ root.hidden = true;
1276
+ 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>`;
1277
+ let tab = "open";
1278
+ let last = null;
1279
+ let itemsMemo = "";
1280
+ const items = root.querySelector("[data-ref=\"items\"]");
1281
+ const tabButtons = [...root.querySelectorAll("[data-tab]")];
1282
+ root.querySelector("[data-ref=\"close\"]")?.addEventListener("click", on.onClose);
1283
+ for (const btn of tabButtons) btn.addEventListener("click", () => {
1284
+ tab = btn.getAttribute("data-tab");
1285
+ if (last) render(last);
1286
+ });
1287
+ items.addEventListener("click", (e) => {
1288
+ const id = e.target.closest?.("[data-item]")?.getAttribute("data-item");
1289
+ if (id) on.onActivate(id);
1290
+ });
1291
+ function render(state) {
1292
+ const open = state.pins.filter((p) => p.status === "open");
1293
+ const resolved = state.pins.filter((p) => p.status === "resolved");
1294
+ const [openTab, doneTab] = tabButtons;
1295
+ if (openTab) {
1296
+ openTab.textContent = `OPEN · ${open.length}`;
1297
+ openTab.classList.toggle("on", tab === "open");
1298
+ }
1299
+ if (doneTab) {
1300
+ doneTab.textContent = `RESOLVED · ${resolved.length}`;
1301
+ doneTab.classList.toggle("on", tab === "resolved");
1302
+ }
1303
+ const list = tab === "open" ? open : resolved;
1304
+ const html = list.length ? list.map((p) => itemHtml(p, 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>";
1305
+ if (itemsMemo !== html) {
1306
+ items.innerHTML = html;
1307
+ itemsMemo = html;
1308
+ }
1309
+ }
1310
+ /** Show immediately; hide only after the closing animation ends (prototype rule). */
1311
+ function setVisible(open) {
1312
+ if (open) {
1313
+ if (root.hidden || root.classList.contains("closing")) {
1314
+ root.classList.remove("closing");
1315
+ root.hidden = false;
1316
+ }
1317
+ } else if (!root.hidden && !root.classList.contains("closing")) {
1318
+ root.classList.add("closing");
1319
+ root.addEventListener("animationend", (ev) => {
1320
+ if (ev.target === root && root.classList.contains("closing")) {
1321
+ root.hidden = true;
1322
+ root.classList.remove("closing");
1323
+ }
1324
+ }, { once: true });
1325
+ }
1326
+ }
1327
+ return {
1328
+ root,
1329
+ update(state) {
1330
+ last = state;
1331
+ setVisible(state.inboxOpen);
1332
+ if (state.inboxOpen) render(state);
1333
+ }
1334
+ };
1335
+ }
1336
+ //#endregion
1337
+ //#region src/ui/pins.ts
1338
+ /** The prototype's `_h` innerHTML memo, kept off the DOM node. */
1339
+ const chipMemo = /* @__PURE__ */ new WeakMap();
1340
+ /** Needle anchor point for a committed pin: center of the captured target rect. */
1341
+ function pinPoint(r) {
1342
+ return {
1343
+ x: r.x + r.width / 2,
1344
+ y: r.y + r.height / 2
1345
+ };
1346
+ }
1347
+ /** Chip contents (prototype chipBtnInner, lines 546–550): number + linked-channel tag,
1348
+ * plus the queued badge while the pin waits in the outbox for the reconnect flush. */
1349
+ function chipInner(n, pin, queued = false) {
1350
+ const link = pin?.links?.[0];
1351
+ const badge = link ? `<span class="lk"><span>${esc(link.connector)}</span></span>` : "";
1352
+ const qd = queued ? "<span class=\"qd\">QUEUED</span>" : "";
1353
+ return `<span>${pinNumber(n)}</span>${badge}${qd}`;
1354
+ }
1355
+ function ensureNode(layer, key, fresh) {
1356
+ let node = layer.querySelector(`[data-pin="${key}"]`);
1357
+ if (!node) {
1358
+ node = layer.ownerDocument.createElement("div");
1359
+ node.className = "pb-pin";
1360
+ node.setAttribute("data-pin", key);
1361
+ node.innerHTML = `${fresh ? "<div class=\"ring\"></div>" : ""}<div class="dot"></div><div class="needle"></div><button type="button" class="pb-chipBtn" data-open="${esc(key)}"></button>`;
1362
+ layer.appendChild(node);
1363
+ }
1364
+ return node;
1365
+ }
1366
+ function patchNode(node, at, hot, inner) {
1367
+ node.style.left = `${at.x}px`;
1368
+ node.style.top = `${at.y}px`;
1369
+ node.style.zIndex = hot ? "40" : "20";
1370
+ node.classList.toggle("hot", hot);
1371
+ const chip = node.querySelector(".pb-chipBtn");
1372
+ if (chip && chipMemo.get(chip) !== inner) {
1373
+ chip.innerHTML = inner;
1374
+ chipMemo.set(chip, inner);
1375
+ }
1376
+ }
1377
+ /**
1378
+ * Render the pin layer for a state snapshot. Visible pins are open pins, the
1379
+ * active pin regardless of status, and the client-only draft (key "draft").
1380
+ */
1381
+ function renderPins(layer, state) {
1382
+ const visible = state.pins.filter((p) => p.status !== "resolved" || p.id === state.activePinId);
1383
+ const placed = [];
1384
+ visible.forEach((pin, i) => {
1385
+ const rect = pin.target?.rect;
1386
+ if (rect !== void 0) placed.push({
1387
+ pin,
1388
+ n: i + 1,
1389
+ rect
1390
+ });
1391
+ });
1392
+ const keys = new Set(placed.map((entry) => entry.pin.id));
1393
+ if (state.draft) keys.add("draft");
1394
+ for (const node of [...layer.children]) if (!keys.has(node.getAttribute("data-pin") ?? "")) node.remove();
1395
+ for (const { pin, n, rect } of placed) {
1396
+ const node = ensureNode(layer, pin.id, false);
1397
+ const hot = pin.id === state.activePinId;
1398
+ const queued = state.queuedIds.has(pin.id);
1399
+ node.classList.toggle("queued", queued);
1400
+ patchNode(node, pinPoint(rect), hot, chipInner(n, pin, queued));
1401
+ }
1402
+ if (state.draft) patchNode(ensureNode(layer, "draft", true), state.draft.placedAt, true, chipInner(visible.length + 1, null));
1403
+ }
1404
+ //#endregion
1405
+ //#region src/ui/reticle.ts
1406
+ function createReticle(doc) {
1407
+ const crosshair = doc.createElement("div");
1408
+ crosshair.className = "pb-reticle";
1409
+ crosshair.innerHTML = "<div class=\"h\"></div><div class=\"v\"></div><div class=\"box\"></div><div class=\"ro\"></div>";
1410
+ const h = crosshair.querySelector(".h");
1411
+ const v = crosshair.querySelector(".v");
1412
+ const box = crosshair.querySelector(".box");
1413
+ const readout = crosshair.querySelector(".ro");
1414
+ const outline = doc.createElement("div");
1415
+ outline.className = "pb-outline";
1416
+ outline.innerHTML = "<span class=\"lab\"></span>";
1417
+ const lab = outline.querySelector(".lab");
1418
+ function setOutlineRect(rect, scroll) {
1419
+ outline.style.left = `${rect.left + scroll.x - 5}px`;
1420
+ outline.style.top = `${rect.top + scroll.y - 5}px`;
1421
+ outline.style.width = `${rect.width + 10}px`;
1422
+ outline.style.height = `${rect.height + 10}px`;
1423
+ }
1424
+ return {
1425
+ crosshair,
1426
+ outline,
1427
+ move(pos) {
1428
+ h.style.top = `${pos.clientY}px`;
1429
+ v.style.left = `${pos.clientX}px`;
1430
+ box.style.left = `${pos.clientX}px`;
1431
+ box.style.top = `${pos.clientY}px`;
1432
+ readout.style.left = `${pos.clientX}px`;
1433
+ readout.style.top = `${pos.clientY}px`;
1434
+ readout.textContent = `${Math.round(pos.pageX)} × ${Math.round(pos.pageY)}`;
1435
+ },
1436
+ snap(rect, label, scroll) {
1437
+ if (!outline.classList.contains("on")) {
1438
+ outline.style.transition = "none";
1439
+ setOutlineRect(rect, scroll);
1440
+ outline.offsetWidth;
1441
+ outline.style.transition = "";
1442
+ } else setOutlineRect(rect, scroll);
1443
+ lab.textContent = label;
1444
+ outline.classList.add("on");
1445
+ },
1446
+ release() {
1447
+ outline.classList.remove("on");
1448
+ }
1449
+ };
1450
+ }
1451
+ //#endregion
1452
+ //#region src/ui/shortcuts.ts
1453
+ const ROWS = [
1454
+ ["Drop a pin", "P"],
1455
+ ["Open inbox", "I"],
1456
+ ["Toggle theme", "D"],
1457
+ ["Send comment", "⌘ ↵"],
1458
+ ["Mark pin resolved", "R"],
1459
+ ["Copy open pins", "C"],
1460
+ ["Cancel", "ESC"]
1461
+ ];
1462
+ function createShortcutsModal(doc, onClose) {
1463
+ const root = doc.createElement("div");
1464
+ root.className = "pb-modal";
1465
+ root.hidden = true;
1466
+ 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>`;
1467
+ root.addEventListener("click", onClose);
1468
+ return {
1469
+ root,
1470
+ set(open) {
1471
+ root.hidden = !open;
1472
+ }
1473
+ };
1474
+ }
1475
+ //#endregion
1476
+ //#region src/ui/styles.ts
1477
+ /** Dark token block — also the :host default so the bare element renders sanely. */
1478
+ const DARK_TOKENS = `
1479
+ --pb-canvas:#0f0f0f; --pb-surface:#171717; --pb-elev:#1f1c1a; --pb-sunken:#0b0b0b;
1480
+ --pb-line:rgba(245,240,230,0.08); --pb-line-2:rgba(245,240,230,0.16);
1481
+ --pb-hover:rgba(245,240,230,0.06);
1482
+ --pb-fg1:#f5f0e6; --pb-fg2:#b8b0a5; --pb-fg3:#8a827a; --pb-fg4:#5a534d;
1483
+ --pb-bar:rgba(15,15,15,0.82);
1484
+ --pb-shadow:0 24px 64px rgba(0,0,0,0.6);
1485
+ --pb-scrim:rgba(7,7,7,0.62);
1486
+ --pb-amber:#d4a04a; --pb-amber-ink:#0f0f0f; --pb-amber-soft:rgba(212,160,74,0.14);
1487
+ --pb-ok:#7fb496; --pb-danger:#c46a5a; --pb-info:#8ea6b8;
1488
+ --pb-invert-bg:#f5f0e6; --pb-invert-fg:#0f0f0f;
1489
+ `;
1490
+ /** Full shadow-root stylesheet for the toolbar element. */
1491
+ const TOOLBAR_CSS = `
1492
+ :host { ${DARK_TOKENS}
1493
+ --pb-font-body: ui-sans-serif, -apple-system, "Segoe UI", sans-serif;
1494
+ --pb-font-mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, monospace;
1495
+ --pb-ease: cubic-bezier(0.22, 1, 0.36, 1);
1496
+ position: absolute; top: 0; left: 0; width: 100%; height: 0; z-index: 2147483000;
1497
+ color: var(--pb-fg1); font-family: var(--pb-font-body); -webkit-font-smoothing: antialiased;
1498
+ }
1499
+ :host([data-pb="dark"]) { ${DARK_TOKENS} }
1500
+ :host([data-pb="light"]) {
1501
+ --pb-canvas:#fbf8f2; --pb-surface:#ffffff; --pb-elev:#ffffff; --pb-sunken:#f2ede3;
1502
+ --pb-line:rgba(23,23,23,0.11); --pb-line-2:rgba(23,23,23,0.22);
1503
+ --pb-hover:rgba(23,23,23,0.05);
1504
+ --pb-fg1:#141414; --pb-fg2:#5a534d; --pb-fg3:#8a827a; --pb-fg4:#b8b0a5;
1505
+ --pb-bar:rgba(251,248,242,0.84);
1506
+ --pb-shadow:0 24px 64px rgba(58,54,51,0.16);
1507
+ --pb-scrim:rgba(58,54,51,0.36);
1508
+ --pb-amber:#b07d28; --pb-amber-ink:#fbf8f2; --pb-amber-soft:rgba(176,125,40,0.12);
1509
+ --pb-ok:#4e8368; --pb-danger:#a8503f; --pb-info:#5c7c94;
1510
+ --pb-invert-bg:#141414; --pb-invert-fg:#fbf8f2;
1511
+ }
1512
+ *, *::before, *::after { box-sizing: border-box; margin: 0; }
1513
+ button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
1514
+
1515
+ @keyframes pb-chip { 0% { opacity:0; transform:translateY(-14px) } 60% { opacity:1 } 100% { opacity:1; transform:translateY(0) } }
1516
+ @keyframes pb-needle { from { transform:scaleY(0) } to { transform:scaleY(1) } }
1517
+ @keyframes pb-ring { from { opacity:.6; transform:translate(-50%,-50%) scale(.25) } to { opacity:0; transform:translate(-50%,-50%) scale(3.2) } }
1518
+ @keyframes pb-in { from { opacity:0; transform:translateY(7px) } to { opacity:1; transform:none } }
1519
+ @keyframes pb-fade { from { opacity:0 } to { opacity:1 } }
1520
+ @keyframes pb-drawer { from { transform:translateX(100%) } to { transform:none } }
1521
+ @keyframes pb-drawer-out { to { transform:translateX(100%) } }
1522
+ @keyframes pb-caret { 50% { opacity:0 } }
1523
+ @keyframes pb-pulse { 0%,100% { opacity:.3 } 50% { opacity:1 } }
1524
+ @keyframes pb-resolve { to { opacity:0; transform:translateY(-10px) } }
1525
+
1526
+ /* overlay layer at the document origin */
1527
+ .pb-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 0; }
1528
+
1529
+ .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;
1530
+ transition: left 220ms var(--pb-ease), top 220ms var(--pb-ease), width 220ms var(--pb-ease), height 220ms var(--pb-ease), opacity 150ms linear; }
1531
+ .pb-outline.on { opacity: 1; }
1532
+ .pb-outline .lab { position: absolute; top: -20px; left: -1px; padding: 2px 7px; background: var(--pb-amber); color: var(--pb-amber-ink); font-family: var(--pb-font-mono); font-size: 9px; letter-spacing: .16em; white-space: nowrap; border-radius: 2px; }
1533
+
1534
+ .pb-reticle { position: fixed; inset: 0; pointer-events: none; z-index: 50; display: none; }
1535
+ :host([data-placing]) .pb-reticle { display: block; animation: pb-fade 160ms ease-out both; }
1536
+ .pb-reticle .h { position: absolute; left: 0; right: 0; height: 1px; background: color-mix(in srgb, var(--pb-amber) 26%, transparent); }
1537
+ .pb-reticle .v { position: absolute; top: 0; bottom: 0; width: 1px; background: color-mix(in srgb, var(--pb-amber) 26%, transparent); }
1538
+ .pb-reticle .box { position: absolute; width: 15px; height: 15px; margin: -8px 0 0 -8px; border: 1px solid var(--pb-amber); border-radius: 2px; }
1539
+ .pb-reticle .ro { position: absolute; margin: 14px 0 0 14px; padding: 3px 6px; background: var(--pb-amber); color: var(--pb-amber-ink); font-family: var(--pb-font-mono); font-size: 9.5px; letter-spacing: .12em; border-radius: 2px; white-space: nowrap; }
1540
+
1541
+ .pb-pin { position: absolute; }
1542
+ .pb-pin.resolving { animation: pb-resolve 380ms var(--pb-ease) forwards; }
1543
+ .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; }
1544
+ .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); }
1545
+ .pb-pin .needle { position: absolute; left: 0; bottom: 0; width: 1px; height: 30px; background: linear-gradient(to top, var(--pb-amber), color-mix(in srgb, var(--pb-amber) 35%, transparent)); transform-origin: bottom; animation: pb-needle 300ms var(--pb-ease) both; }
1546
+ .pb-chipBtn { position: absolute; left: -1px; bottom: 30px; display: flex; align-items: center; gap: 7px; height: 26px; padding: 0 9px; border-radius: 2px; border: 1px solid var(--pb-line-2); background: var(--pb-elev); color: var(--pb-fg1); font-family: var(--pb-font-mono); font-size: 11px; font-weight: 500; letter-spacing: .08em; white-space: nowrap; box-shadow: var(--pb-shadow); animation: pb-chip 420ms var(--pb-ease) both; transition: border-color 160ms linear, background 160ms linear, color 160ms linear; }
1547
+ .pb-chipBtn:hover { border-color: var(--pb-amber); }
1548
+ .pb-pin.hot .pb-chipBtn { background: var(--pb-amber); color: var(--pb-amber-ink); border-color: var(--pb-amber); box-shadow: 0 0 0 4px var(--pb-amber-soft); }
1549
+ .pb-chipBtn .busy { width: 5px; height: 5px; border-radius: 999px; background: currentColor; animation: pb-pulse 1s ease-in-out infinite; }
1550
+ .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; }
1551
+ .pb-pin.hot .pb-chipBtn .lk { border-left-color: color-mix(in srgb, var(--pb-amber-ink) 28%, transparent); }
1552
+ .pb-pin.queued .pb-chipBtn { border-style: dashed; }
1553
+ .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); }
1554
+ .pb-pin.hot .pb-chipBtn .qd { color: var(--pb-amber-ink); border-left-color: color-mix(in srgb, var(--pb-amber-ink) 28%, transparent); }
1555
+
1556
+ /* thread card (ui/card.ts) — prototype lines 121–190 */
1557
+ .pb-card { position: absolute; z-index: 80; width: 344px; }
1558
+ .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; }
1559
+ .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); }
1560
+ .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); }
1561
+ .pb-hd .meta .num { color: var(--pb-amber); }
1562
+ .pb-hd .meta .st { color: var(--pb-fg4); }
1563
+ .pb-ico { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 2px; color: var(--pb-fg3); }
1564
+ .pb-ico:hover { background: var(--pb-hover); color: var(--pb-fg1); }
1565
+ .pb-ico.ok:hover { color: var(--pb-ok); }
1566
+ .pb-linkbar { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--pb-line); background: color-mix(in srgb, var(--pb-info) 7%, var(--pb-surface)); animation: pb-in 300ms var(--pb-ease) both; }
1567
+ .pb-linkbar .ch { white-space: nowrap; font-family: var(--pb-font-mono); font-size: 10px; letter-spacing: .06em; }
1568
+ .pb-linkbar .mt { white-space: nowrap; font-family: var(--pb-font-mono); font-size: 9px; letter-spacing: .14em; color: var(--pb-fg4); }
1569
+ .pb-linkbar .sp { flex: 1; }
1570
+ .pb-open { display: flex; align-items: center; gap: 5px; height: 20px; padding: 0 7px; 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-fg2); text-decoration: none; }
1571
+ .pb-open:hover { border-color: var(--pb-info); color: var(--pb-fg1); }
1572
+ .pb-thread { max-height: min(392px, calc(100vh - 320px)); overflow: auto; }
1573
+ .pb-msg-w { animation: pb-in 260ms var(--pb-ease) both; }
1574
+ .pb-msg { padding: 13px 14px; display: flex; gap: 10px; }
1575
+ .pb-msg.you { border-bottom: 1px solid var(--pb-line); }
1576
+ .pb-msg .steps { display: flex; flex-direction: column; gap: 7px; }
1577
+ .pb-av { flex: none; width: 22px; height: 22px; border-radius: 999px; display: flex; align-items: center; justify-content: center; font-family: var(--pb-font-mono); font-size: 9px; letter-spacing: .04em; background: var(--pb-invert-bg); color: var(--pb-invert-fg); border: 1px solid var(--pb-invert-bg); }
1578
+ .pb-av.via { background: transparent; color: var(--pb-info); border-color: var(--pb-info); }
1579
+ .pb-av.agent { background: var(--pb-amber-soft); color: var(--pb-amber); border-color: var(--pb-amber); }
1580
+ .pb-msg .col { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
1581
+ .pb-msg .line { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }
1582
+ .pb-msg .who { font-size: 12px; }
1583
+ .pb-msg .tm { font-family: var(--pb-font-mono); font-size: 9px; letter-spacing: .14em; color: var(--pb-fg4); }
1584
+ .pb-msg .via-tag { display: flex; align-items: center; gap: 4px; font-family: var(--pb-font-mono); font-size: 9px; letter-spacing: .06em; color: var(--pb-fg3); }
1585
+ .pb-msg .txt { font-size: 13px; line-height: 1.55; letter-spacing: -.005em; color: var(--pb-fg2); text-wrap: pretty; overflow-wrap: anywhere; }
1586
+ .pb-msg .atts { display: flex; flex-wrap: wrap; gap: 6px; padding-top: 2px; }
1587
+ .pb-att img { display: block; max-width: 132px; max-height: 88px; border: 1px solid var(--pb-line-2); border-radius: 2px; }
1588
+ .pb-att-chip { display: inline-flex; align-items: center; height: 20px; padding: 0 7px; border: 1px solid var(--pb-line-2); border-radius: 2px; font-family: var(--pb-font-mono); font-size: 9px; letter-spacing: .06em; color: var(--pb-fg3); }
1589
+ .pb-step { display: flex; align-items: center; gap: 8px; font-family: var(--pb-font-mono); font-size: 10.5px; letter-spacing: .02em; animation: pb-fade 220ms ease-out both; }
1590
+ .pb-step .g { width: 10px; display: flex; justify-content: center; }
1591
+ .pb-caret { display: inline-block; width: 6px; height: 14px; margin-left: 2px; transform: translateY(2px); background: var(--pb-amber); animation: pb-caret 900ms steps(1) infinite; }
1592
+ .pb-change { margin: 0 14px 14px 46px; border: 1px solid var(--pb-line); border-radius: 2px; overflow: hidden; }
1593
+ .pb-change .fh { display: flex; align-items: center; justify-content: space-between; padding: 7px 10px; background: 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-fg3); }
1594
+ .pb-change .code { background: var(--pb-sunken); padding: 8px 0; font-family: var(--pb-font-mono); font-size: 10.5px; line-height: 1.75; }
1595
+ .pb-change .mi { padding: 0 10px; color: var(--pb-danger); background: color-mix(in srgb, var(--pb-danger) 9%, transparent); white-space: pre; overflow: auto; }
1596
+ .pb-change .pl { padding: 0 10px; color: var(--pb-ok); background: color-mix(in srgb, var(--pb-ok) 9%, transparent); white-space: pre; overflow: auto; }
1597
+ .pb-change .ft { display: flex; align-items: center; gap: 8px; padding: 9px 10px; background: var(--pb-surface); border-top: 1px solid var(--pb-line); }
1598
+ .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; }
1599
+ .pb-change .applied .hh { color: var(--pb-fg4); }
1600
+ .pb-verify { display: flex; align-items: center; gap: 8px; padding: 9px 12px; background: var(--pb-surface); border-top: 1px solid var(--pb-line); }
1601
+ .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; }
1602
+ .pb-bt-solid:hover { opacity: .9; }
1603
+ .pb-bt-ok { height: 26px; padding: 0 13px; border-radius: 2px; background: var(--pb-ok); color: var(--pb-canvas); font-size: 11.5px; }
1604
+ .pb-bt-ok:hover { opacity: .9; }
1605
+ .pb-bt-ghost { height: 26px; padding: 0 13px; border: 1px solid var(--pb-line-2); border-radius: 2px; color: var(--pb-fg2); font-size: 11.5px; }
1606
+ .pb-bt-ghost:hover { color: var(--pb-fg1); border-color: var(--pb-fg3); }
1607
+ .pb-composer { border-top: 1px solid var(--pb-line); padding: 11px 12px; background: var(--pb-surface); }
1608
+ .pb-composer textarea { width: 100%; resize: none; background: var(--pb-sunken); border: 1px solid var(--pb-line); border-radius: 2px; padding: 9px 10px; color: var(--pb-fg1); font-family: var(--pb-font-body); font-size: 13px; line-height: 1.5; letter-spacing: -.005em; outline: none; }
1609
+ .pb-composer textarea:focus { border-color: var(--pb-amber); box-shadow: 0 0 0 3px var(--pb-amber-soft); }
1610
+ .pb-composer .row { display: flex; align-items: center; justify-content: space-between; padding-top: 9px; }
1611
+ .pb-kbd { font-family: var(--pb-font-mono); font-size: 10px; letter-spacing: .14em; color: var(--pb-fg4); }
1612
+
1613
+ /* command bar */
1614
+ .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); }
1615
+ .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; }
1616
+ :host([data-placing]) .pb-bar .armed-ring { display: block; }
1617
+ .pb-bar .ident { display: flex; align-items: center; gap: 9px; padding: 0 12px 0 10px; min-width: 150px; }
1618
+ .pb-bar .ident .bl { font-family: var(--pb-font-mono); font-size: 10px; letter-spacing: .24em; white-space: nowrap; }
1619
+ .pb-bar .div { width: 1px; height: 22px; background: var(--pb-line); }
1620
+ .pb-tb { display: flex; align-items: center; gap: 8px; height: 32px; padding: 0 11px; border-radius: 2px; color: var(--pb-fg2); transition: background 140ms linear; font-family: var(--pb-font-mono); font-size: 10px; letter-spacing: .16em; }
1621
+ .pb-tb:hover { background: var(--pb-hover); }
1622
+ .pb-tb.hot { background: var(--pb-amber); color: var(--pb-amber-ink); }
1623
+ .pb-tb.lit { background: var(--pb-hover); color: var(--pb-fg1); }
1624
+ .pb-tb.sq { width: 32px; padding: 0; justify-content: center; }
1625
+
1626
+ /* inbox drawer (ui/drawer.ts) — prototype lines 206–224 */
1627
+ .pb-drawer { position: fixed; top: 0; right: 0; bottom: 0; width: 336px; z-index: 85; background: var(--pb-surface); border-left: 1px solid var(--pb-line); box-shadow: var(--pb-shadow); display: flex; flex-direction: column; animation: pb-drawer 380ms var(--pb-ease) both; }
1628
+ .pb-drawer.closing { animation: pb-drawer-out 220ms cubic-bezier(0.3, 0, 0.8, 0.15) both; }
1629
+ .pb-drawer .dh { display: flex; align-items: center; justify-content: space-between; padding: 16px 16px 12px; font-family: var(--pb-font-mono); font-size: 10px; letter-spacing: .24em; }
1630
+ .pb-tabs { display: flex; gap: 18px; padding: 0 16px; border-bottom: 1px solid var(--pb-line); }
1631
+ .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; }
1632
+ .pb-tab.on { color: var(--pb-fg1); border-bottom-color: var(--pb-amber); }
1633
+ .pb-items { flex: 1; overflow: auto; }
1634
+ .pb-item { width: 100%; text-align: left; display: flex; gap: 11px; padding: 14px 16px; border-bottom: 1px solid var(--pb-line); }
1635
+ .pb-item:hover { background: var(--pb-hover); }
1636
+ .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; }
1637
+ .pb-item.on .nn { background: var(--pb-amber); color: var(--pb-amber-ink); border-color: var(--pb-amber); }
1638
+ .pb-item .cc { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
1639
+ .pb-item .tt { font-size: 12.5px; line-height: 1.45; letter-spacing: -.005em; color: var(--pb-fg1); text-wrap: pretty; }
1640
+ .pb-item .mm { display: flex; align-items: center; gap: 7px; font-family: var(--pb-font-mono); font-size: 9px; letter-spacing: .14em; color: var(--pb-fg4); }
1641
+ .pb-item .mm .sdot { width: 5px; height: 5px; border-radius: 999px; }
1642
+ .pb-empty { padding: 26px 16px; font-size: 12.5px; color: var(--pb-fg3); }
1643
+
1644
+ /* shortcuts modal (ui/shortcuts.ts) — prototype lines 226–232 */
1645
+ .pb-modal { position: fixed; inset: 0; z-index: 120; display: flex; align-items: center; justify-content: center; background: var(--pb-scrim); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); animation: pb-fade 200ms ease-out both; }
1646
+ .pb-modal .mx { width: 430px; background: var(--pb-elev); border: 1px solid var(--pb-line-2); border-radius: 4px; box-shadow: var(--pb-shadow); animation: pb-in 280ms var(--pb-ease) both; }
1647
+ .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; }
1648
+ .pb-modal .mr { display: flex; align-items: center; justify-content: space-between; padding: 9px 0; border-bottom: 1px solid var(--pb-line); }
1649
+ .pb-modal .mw { font-size: 13px; letter-spacing: -.005em; color: var(--pb-fg2); }
1650
+ .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; }
1651
+
1652
+ [hidden] { display: none !important; }
1653
+ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 1ms !important; transition-duration: 1ms !important; animation-iteration-count: 1 !important; } .pb-chipBtn .busy, .pb-caret { animation: none; } }
1654
+ `;
1655
+ /**
1656
+ * The one rule that must live in the host document, not the shadow root: the
1657
+ * armed-state cursor flip (prototype line 100: body.placing cursor none). The
1658
+ * element injects this <style> on connect and toggles PAGE_PLACING_CLASS on body.
1659
+ */
1660
+ const PAGE_PLACING_CLASS = "pinbox-placing";
1661
+ const PAGE_CSS = `body.${PAGE_PLACING_CLASS}, body.${PAGE_PLACING_CLASS} * { cursor: none !important; }`;
1662
+ //#endregion
1663
+ //#region src/element.ts
1664
+ const BaseElement = globalThis.HTMLElement ?? class {};
1665
+ /** Keystrokes are ignored while a text control has focus (prototype line 702–703). */
1666
+ function isTextEntry(target) {
1667
+ const el = target;
1668
+ const tag = el?.tagName ?? "";
1669
+ return tag === "TEXTAREA" || tag === "INPUT" || el?.isContentEditable === true;
1670
+ }
1671
+ var PinboxToolbarElement = class extends BaseElement {
1672
+ static tagName = "pinbox-toolbar";
1673
+ store = createStore();
1674
+ /** Card → transport seam (wired by #startTransport once a config exists). */
1675
+ actions = {};
1676
+ #config = null;
1677
+ #transport = null;
1678
+ #token = "";
1679
+ #built = false;
1680
+ #bar = null;
1681
+ #reticle = null;
1682
+ #pinsLayer = null;
1683
+ #drawer = null;
1684
+ #modal = null;
1685
+ #helpOpen = false;
1686
+ #pageStyle = null;
1687
+ #unsubscribe = null;
1688
+ #hover = null;
1689
+ /** Card → element: send/verify/resolve forward to the transport seam; close dismisses. */
1690
+ #cardActions = {
1691
+ send: (pinId, text) => this.actions.send?.(pinId, text),
1692
+ verify: (pinId, outcome) => this.actions.verify?.(pinId, outcome),
1693
+ resolve: (pinId) => this.actions.resolve?.(pinId),
1694
+ close: () => this.#dismiss()
1695
+ };
1696
+ /** Programmatic path (Pinbox.init). The snippet path reads hub/token attributes. */
1697
+ configure(config) {
1698
+ this.#config = config;
1699
+ }
1700
+ get config() {
1701
+ if (this.#config) return this.#config;
1702
+ const hub = this.getAttribute("hub");
1703
+ if (!hub) return null;
1704
+ const token = this.getAttribute("token");
1705
+ return token === null ? { endpoint: hub } : {
1706
+ endpoint: hub,
1707
+ token
1708
+ };
1709
+ }
1710
+ connectedCallback() {
1711
+ if (!this.#built) {
1712
+ this.#built = true;
1713
+ this.#build();
1714
+ }
1715
+ if (!this.hasAttribute("data-pb")) {
1716
+ const dark = window.matchMedia("(prefers-color-scheme: dark)").matches;
1717
+ this.setAttribute("data-pb", dark ? "dark" : "light");
1718
+ }
1719
+ const style = document.createElement("style");
1720
+ style.textContent = PAGE_CSS;
1721
+ document.head.appendChild(style);
1722
+ this.#pageStyle = style;
1723
+ document.addEventListener("mousemove", this.#onMouseMove);
1724
+ document.addEventListener("click", this.#onClickCapture, true);
1725
+ document.addEventListener("keydown", this.#onKeyDown);
1726
+ this.#unsubscribe = this.store.subscribe((s) => this.#render(s));
1727
+ this.#render(this.store.get());
1728
+ this.#startTransport();
1729
+ }
1730
+ disconnectedCallback() {
1731
+ this.#transport?.close();
1732
+ this.#transport = null;
1733
+ document.removeEventListener("mousemove", this.#onMouseMove);
1734
+ document.removeEventListener("click", this.#onClickCapture, true);
1735
+ document.removeEventListener("keydown", this.#onKeyDown);
1736
+ this.#unsubscribe?.();
1737
+ this.#unsubscribe = null;
1738
+ this.#pageStyle?.remove();
1739
+ this.#pageStyle = null;
1740
+ document.body.classList.remove(PAGE_PLACING_CLASS);
1741
+ }
1742
+ #build() {
1743
+ const shadow = this.attachShadow({ mode: "open" });
1744
+ try {
1745
+ const sheet = new CSSStyleSheet();
1746
+ sheet.replaceSync(TOOLBAR_CSS);
1747
+ shadow.adoptedStyleSheets = [sheet];
1748
+ } catch {
1749
+ const style = document.createElement("style");
1750
+ style.textContent = TOOLBAR_CSS;
1751
+ shadow.appendChild(style);
1752
+ }
1753
+ const overlay = document.createElement("div");
1754
+ overlay.className = "pb-overlay";
1755
+ this.#pinsLayer = document.createElement("div");
1756
+ overlay.appendChild(this.#pinsLayer);
1757
+ this.#reticle = createReticle(document);
1758
+ overlay.appendChild(this.#reticle.outline);
1759
+ const card = document.createElement("div");
1760
+ card.className = "pb-card";
1761
+ card.hidden = true;
1762
+ overlay.appendChild(card);
1763
+ shadow.appendChild(overlay);
1764
+ shadow.appendChild(this.#reticle.crosshair);
1765
+ this.#bar = createBar(document, {
1766
+ onPin: () => this.#togglePlacing(),
1767
+ onInbox: () => this.store.update({ inboxOpen: !this.store.get().inboxOpen }),
1768
+ onTheme: () => this.#toggleTheme(),
1769
+ onHelp: () => this.#toggleHelp(),
1770
+ onCopy: () => this.#copyOpenPins()
1771
+ });
1772
+ shadow.appendChild(this.#bar.root);
1773
+ this.#drawer = createDrawer(document, {
1774
+ onActivate: (pinId) => this.#activateFromInbox(pinId),
1775
+ onClose: () => this.store.update({ inboxOpen: false })
1776
+ });
1777
+ shadow.appendChild(this.#drawer.root);
1778
+ this.#modal = createShortcutsModal(document, () => this.#setHelp(false));
1779
+ shadow.appendChild(this.#modal.root);
1780
+ this.#pinsLayer.addEventListener("click", (e) => this.#onChipClick(e));
1781
+ }
1782
+ /**
1783
+ * Task 8 wiring: WS events mutate the store, connection state renders in the
1784
+ * bar, card actions hit the hub. The mirror seeds pins so an offline reload
1785
+ * still renders read-only threads and queued drafts.
1786
+ */
1787
+ async #startTransport() {
1788
+ if (this.#transport !== null) return;
1789
+ const cfg = this.config;
1790
+ if (cfg === null) return;
1791
+ this.#token = cfg.token ?? await cfg.getToken?.().catch(() => void 0) ?? "";
1792
+ const transport = new HubTransport({
1793
+ endpoint: cfg.endpoint,
1794
+ token: this.#token,
1795
+ onEvent: (e) => applyHubEvent(this.store, e),
1796
+ onConnection: (connection) => this.store.update({ connection }),
1797
+ onPins: (pins) => this.store.update({ pins }),
1798
+ onOutbox: (ids) => this.store.update({ queuedIds: new Set(ids) })
1799
+ });
1800
+ this.#transport = transport;
1801
+ const queued = transport.outboxPins();
1802
+ const seed = [...transport.mirrorPins(), ...queued];
1803
+ if (seed.length > 0 && this.store.get().pins.length === 0) this.store.update({ pins: seed });
1804
+ if (queued.length > 0) this.store.update({ queuedIds: new Set(queued.map((p) => p.id)) });
1805
+ this.actions.send = (pinId, text) => void this.#send(transport, pinId, text);
1806
+ this.actions.resolve = (pinId) => void transport.resolve(pinId).then((pin) => upsertPin(this.store, pin)).catch(() => {});
1807
+ this.actions.verify = (pinId, outcome) => void transport.verify(pinId, outcome).then((pin) => upsertPin(this.store, pin)).catch(() => {});
1808
+ transport.connect();
1809
+ }
1810
+ /** draft ⇒ compose PinInput (+ best-effort screenshot) and createPin; else thread reply. */
1811
+ async #send(transport, pinId, text) {
1812
+ try {
1813
+ if (pinId === "draft") {
1814
+ const draft = this.store.get().draft;
1815
+ if (draft === null) return;
1816
+ const input = {
1817
+ text,
1818
+ kind: "note",
1819
+ target: draft.target.target,
1820
+ env: draft.target.env,
1821
+ author: { userId: transport.consumerId }
1822
+ };
1823
+ const shot = await this.#screenshot(draft.target.target.selector);
1824
+ if (shot !== null) input.attachments = [shot];
1825
+ this.store.commitDraft(await transport.createPin(input));
1826
+ } else appendThreadMessage(this.store, await transport.reply(pinId, text));
1827
+ } catch {}
1828
+ }
1829
+ /** Best-effort element screenshot: draft submit → captureElement → uploadAttachment. */
1830
+ async #screenshot(selector) {
1831
+ const cfg = this.config;
1832
+ if (cfg === null) return null;
1833
+ try {
1834
+ const el = document.querySelector(selector);
1835
+ if (el === null) return null;
1836
+ const img = await captureElement(el);
1837
+ if (img === null) return null;
1838
+ return await uploadAttachment(cfg.endpoint, this.#token, img);
1839
+ } catch {
1840
+ return null;
1841
+ }
1842
+ }
1843
+ /** Threads build from WS events; after a reload the cursor skips old ones — fetch lazily. */
1844
+ #ensureThread(pinId) {
1845
+ const transport = this.#transport;
1846
+ if (transport === null || this.store.get().threads.has(pinId)) return;
1847
+ transport.getThread(pinId).then((messages) => {
1848
+ const threads = new Map(this.store.get().threads);
1849
+ threads.set(pinId, messages);
1850
+ this.store.update({ threads });
1851
+ }).catch(() => {});
1852
+ }
1853
+ /** Inbox item click: activate the pin and scroll it into view (prototype line 700). */
1854
+ #activateFromInbox(pinId) {
1855
+ const pin = this.store.get().pins.find((p) => p.id === pinId);
1856
+ this.#ensureThread(pinId);
1857
+ this.store.update({ activePinId: pinId });
1858
+ const rect = pin?.target?.rect;
1859
+ if (rect) {
1860
+ const y = rect.y + rect.height / 2;
1861
+ window.scrollTo({
1862
+ top: Math.max(0, y - window.innerHeight / 2),
1863
+ behavior: "smooth"
1864
+ });
1865
+ }
1866
+ }
1867
+ /** The markdown offline fallback: copy every open pin's block to the clipboard. */
1868
+ #copyOpenPins() {
1869
+ const state = this.store.get();
1870
+ try {
1871
+ navigator.clipboard.writeText(pinsToMarkdown(state.pins, state.threads));
1872
+ } catch {}
1873
+ }
1874
+ #setHelp(open) {
1875
+ this.#helpOpen = open;
1876
+ this.#modal?.set(open);
1877
+ }
1878
+ #toggleHelp() {
1879
+ this.#setHelp(!this.#helpOpen);
1880
+ }
1881
+ /** Chip click toggles the pin active (prototype data-open delegation, line 675). */
1882
+ #onChipClick(e) {
1883
+ const id = e.target.closest?.("[data-open]")?.getAttribute("data-open");
1884
+ if (!id || id === "draft") return;
1885
+ const active = this.store.get().activePinId;
1886
+ if (active !== id) this.#ensureThread(id);
1887
+ this.store.update({ activePinId: active === id ? null : id });
1888
+ }
1889
+ #togglePlacing() {
1890
+ const placing = this.store.get().mode === "placing";
1891
+ this.store.update({
1892
+ mode: placing ? "idle" : "placing",
1893
+ activePinId: null
1894
+ });
1895
+ }
1896
+ #toggleInbox() {
1897
+ this.store.update({ inboxOpen: !this.store.get().inboxOpen });
1898
+ }
1899
+ #resolveActive() {
1900
+ const active = this.store.get().activePinId;
1901
+ if (active) this.actions.resolve?.(active);
1902
+ }
1903
+ #toggleTheme() {
1904
+ const next = this.getAttribute("data-pb") === "dark" ? "light" : "dark";
1905
+ this.setAttribute("data-pb", next);
1906
+ }
1907
+ /** esc / click-away: leave placing, discard the draft (client-only), deactivate. */
1908
+ #dismiss() {
1909
+ this.#setHelp(false);
1910
+ this.store.update({
1911
+ mode: "idle",
1912
+ activePinId: null
1913
+ });
1914
+ if (this.store.get().draft) this.store.discardDraft();
1915
+ }
1916
+ #onMouseMove = (e) => {
1917
+ if (this.store.get().mode !== "placing" || !this.#reticle) return;
1918
+ this.#reticle.move(e);
1919
+ const el = hitTest(document, e.clientX, e.clientY, (hit) => hit === this);
1920
+ if (el) {
1921
+ this.#hover = el;
1922
+ this.#reticle.snap(el.getBoundingClientRect(), targetLabel(el), {
1923
+ x: window.scrollX,
1924
+ y: window.scrollY
1925
+ });
1926
+ } else {
1927
+ this.#hover = null;
1928
+ this.#reticle.release();
1929
+ }
1930
+ };
1931
+ /** Placement click: capture the hovered target (or body) into a client-only draft. */
1932
+ #placeDraft(e) {
1933
+ e.preventDefault();
1934
+ e.stopPropagation();
1935
+ const el = this.#hover ?? document.body;
1936
+ this.store.place({
1937
+ target: captureTarget(el),
1938
+ placedAt: {
1939
+ x: e.pageX,
1940
+ y: e.pageY
1941
+ }
1942
+ });
1943
+ this.#reticle?.release();
1944
+ }
1945
+ #onClickCapture = (e) => {
1946
+ if (e.composedPath().includes(this)) return;
1947
+ const state = this.store.get();
1948
+ if (state.mode === "placing") this.#placeDraft(e);
1949
+ else if (state.activePinId || state.draft) this.#dismiss();
1950
+ };
1951
+ /** Prototype keyboard map (v2-command-bar.html lines 701–712). */
1952
+ #shortcuts = {
1953
+ escape: () => this.#dismiss(),
1954
+ p: () => this.#togglePlacing(),
1955
+ i: () => this.#toggleInbox(),
1956
+ d: () => this.#toggleTheme(),
1957
+ r: () => this.#resolveActive(),
1958
+ c: () => this.#copyOpenPins(),
1959
+ "?": () => this.#toggleHelp()
1960
+ };
1961
+ #onKeyDown = (e) => {
1962
+ if (isTextEntry(e.composedPath()[0])) return;
1963
+ this.#shortcuts[e.key === "?" ? "?" : e.key.toLowerCase()]?.();
1964
+ };
1965
+ #render(state) {
1966
+ const placing = state.mode === "placing";
1967
+ this.toggleAttribute("data-placing", placing);
1968
+ document.body.classList.toggle(PAGE_PLACING_CLASS, placing);
1969
+ if (!placing) this.#reticle?.release();
1970
+ if (this.#pinsLayer) renderPins(this.#pinsLayer, state);
1971
+ if (this.shadowRoot) renderCard(this.shadowRoot, state, this.#cardActions);
1972
+ this.#drawer?.update(state);
1973
+ this.#bar?.update(state);
1974
+ }
1975
+ };
1976
+ //#endregion
1977
+ //#region src/index.ts
1978
+ /** Register <pinbox-toolbar>; no-op outside a browser or when already defined. */
1979
+ function defineToolbarElement() {
1980
+ if (typeof customElements === "undefined") return;
1981
+ if (!customElements.get(PinboxToolbarElement.tagName)) customElements.define(PinboxToolbarElement.tagName, PinboxToolbarElement);
1982
+ }
1983
+ const Pinbox = { init(config) {
1984
+ defineToolbarElement();
1985
+ const el = document.createElement(PinboxToolbarElement.tagName);
1986
+ el.configure(config);
1987
+ document.body.appendChild(el);
1988
+ return el;
1989
+ } };
1990
+ defineToolbarElement();
1991
+ //#endregion
1992
+ export { HubError as a, createStore as c, HubTransport as i, deriveUiStatus as l, defineToolbarElement as n, appendThreadMessage as o, PinboxToolbarElement as r, applyHubEvent as s, Pinbox as t, upsertPin as u };