@autono/pinbox-toolbar 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ import { a as targetLabel, c as captureKey, i as hitTest, l as loadCaptureMode, r as captureTarget, s as toggleToolbarCapture, t as projectModelTarget } from "./model-target-BykKL8H5.js";
1
2
  //#region src/anchor-watch.ts
2
3
  function watchAnchors(win, onChange) {
3
4
  let frame = 0;
@@ -22,23 +23,6 @@ function watchAnchors(win, onChange) {
22
23
  } };
23
24
  }
24
25
  //#endregion
25
- //#region src/capture-mode.ts
26
- function captureKey(prefix) {
27
- return `${prefix}:capture`;
28
- }
29
- function loadCaptureMode(storage, key, fallback) {
30
- try {
31
- const raw = storage?.getItem(key);
32
- if (raw === "dom" || raw === "tab") return raw;
33
- } catch {}
34
- return fallback;
35
- }
36
- function saveCaptureMode(storage, key, mode) {
37
- try {
38
- storage?.setItem(key, mode);
39
- } catch {}
40
- }
41
- //#endregion
42
26
  //#region src/ui/actions.ts
43
27
  const PIN_GLYPH = "<rect x=\"3\" y=\"1.5\" width=\"10\" height=\"6.5\" rx=\"1\"/><path d=\"M8 8v6.5\"/>";
44
28
  const INBOX_GLYPH = "<path d=\"M1.8 8.5h3.4l1 2h3.6l1-2h3.4\"/><path d=\"M2.6 3.2h10.8l1.2 5.3v4a1 1 0 01-1 1H2.4a1 1 0 01-1-1v-4z\"/>";
@@ -208,10 +192,11 @@ function threadTail(thread) {
208
192
  ];
209
193
  }
210
194
  function block(pin, thread) {
211
- const { selector, url, source } = pin.target ?? {};
195
+ const { selector, url, source, model } = pin.target ?? {};
212
196
  return [
213
197
  `## Pin ${pin.n === void 0 ? pin.id : `#${pin.n} (${pin.id})`} — ${pin.status.toUpperCase()}`,
214
198
  `- label: ${line(label(pin))}`,
199
+ ...model ? [`- model: ${line(model.modelId)} / ${line(model.partId)} @ ${line(model.revision)}`, `- position: ${model.position.join(", ")} ${model.units}`] : [],
215
200
  ...selector === void 0 ? [] : [`- selector: \`${line(selector)}\``],
216
201
  ...(pin.target?.targets ?? []).map((t) => t.selector ?? t.anchor ?? t.tag).filter((locus) => locus !== void 0).map((locus) => `- also: \`${line(locus)}\``),
217
202
  ...source === void 0 ? [] : [`- source: ${line(source.line === void 0 ? source.file : `${source.file}:${source.line}`)}`],
@@ -808,254 +793,123 @@ function createMinimize(host) {
808
793
  };
809
794
  }
810
795
  //#endregion
811
- //#region src/targeting/dom.ts
812
- /**
813
- * Deepest element under (clientX, clientY) that the caller does not ignore, or null when there is
814
- * nothing there but page chrome (html/body).
815
- *
816
- * Looks THROUGH our own overlay rather than giving up at it. The single-element form could not:
817
- * the drag-aim grip sits exactly on the point being aimed at, so it is always the topmost thing
818
- * under the crosshair, and every probe came back "nothing" the moment touch aiming existed.
819
- */
820
- function hitTest(doc, x, y, ignore) {
821
- const stack = doc.elementsFromPoint?.(x, y) ?? [doc.elementFromPoint(x, y)];
822
- for (const el of stack) {
823
- if (!el || el === doc.body || el === doc.documentElement) return null;
824
- if (!ignore(el)) return el;
825
- }
826
- return null;
827
- }
828
- /** CLASS-or-TAG display name with a sibling index when needed (prototype nodeName). */
829
- function nodeName(el) {
830
- const key = el.classList[0];
831
- let name = (key ?? el.tagName).toUpperCase();
832
- const parent = el.parentElement;
833
- if (parent) {
834
- const sibs = [...parent.children].filter((c) => c.classList[0] === key && c.tagName === el.tagName);
835
- if (sibs.length > 1) name += ` ${sibs.indexOf(el) + 1}`;
836
- }
837
- return name;
796
+ //#region src/reveal-settle.ts
797
+ const pending = /* @__PURE__ */ new WeakMap();
798
+ function cancelRevealSettle(doc) {
799
+ pending.get(doc)?.();
838
800
  }
839
- /**
840
- * Human label for a target: an explicit data-pb-el annotation wins; otherwise a
841
- * CLASS/TAG ancestry chain of at most 3 parts joined with ›, terminating early
842
- * at the first annotated ancestor.
843
- */
844
- function targetLabel(el) {
845
- const own = el.getAttribute("data-pb-el");
846
- if (own) return own;
847
- const parts = [nodeName(el)];
848
- const body = el.ownerDocument.body;
849
- let node = el.parentElement;
850
- while (node && node !== body && parts.length < 3) {
851
- const anchor = node.getAttribute("data-pb-el");
852
- if (anchor) {
853
- parts.unshift(anchor);
854
- break;
855
- }
856
- if (node.classList[0]) parts.unshift(nodeName(node));
857
- node = node.parentElement;
858
- }
859
- return parts.join(" › ");
860
- }
861
- const SAFE_ID = /^[A-Za-z][\w-]*$/;
862
- /** Data attributes trusted as stable hooks, in priority order. */
863
- const STABLE_DATA_ATTRS = [
864
- "data-pb-anchor",
865
- "data-pb-el",
866
- "data-testid"
867
- ];
868
- function attrSegment(el, doc) {
869
- for (const attr of STABLE_DATA_ATTRS) {
870
- const value = el.getAttribute(attr);
871
- if (value === null || value.includes("\"") || value.includes("\\")) continue;
872
- const selector = `${el.tagName.toLowerCase()}[${attr}="${value}"]`;
873
- if (doc.querySelectorAll(selector).length === 1) return selector;
874
- }
875
- return null;
876
- }
877
- function nthSegment(el) {
878
- const tag = el.tagName.toLowerCase();
879
- const parent = el.parentElement;
880
- if (!parent) return tag;
881
- const sameTag = [...parent.children].filter((c) => c.tagName === el.tagName);
882
- return sameTag.length > 1 ? `${tag}:nth-of-type(${sameTag.indexOf(el) + 1})` : tag;
883
- }
884
- /**
885
- * Stable CSS path for an element: ids > stable data attributes > an
886
- * nth-of-type chain. Guaranteed round-trip: querySelector(buildSelector(el)) === el.
887
- */
888
- function buildSelector(el) {
889
- const doc = el.ownerDocument;
890
- const segments = [];
891
- let node = el;
892
- while (node && node !== doc.documentElement) {
893
- const id = node.getAttribute("id");
894
- if (id && SAFE_ID.test(id) && doc.querySelectorAll(`#${id}`).length === 1) {
895
- segments.unshift(`#${id}`);
896
- return segments.join(" > ");
897
- }
898
- const byAttr = attrSegment(node, doc);
899
- if (byAttr) {
900
- segments.unshift(byAttr);
901
- return segments.join(" > ");
902
- }
903
- segments.unshift(nthSegment(node));
904
- node = node.parentElement;
905
- }
906
- return segments.join(" > ");
801
+ function settlePinReveal(doc, reveal) {
802
+ cancelRevealSettle(doc);
803
+ const win = doc.defaultView;
804
+ if (!win) return;
805
+ let attempts = 0;
806
+ let timer = 0;
807
+ const events = [
808
+ "wheel",
809
+ "touchstart",
810
+ "pointerdown",
811
+ "keydown"
812
+ ];
813
+ const stop = () => {
814
+ win.clearTimeout(timer);
815
+ for (const event of events) doc.removeEventListener(event, stop, true);
816
+ pending.delete(doc);
817
+ };
818
+ for (const event of events) doc.addEventListener(event, stop, {
819
+ capture: true,
820
+ passive: true
821
+ });
822
+ const tick = () => {
823
+ reveal();
824
+ if (++attempts >= 10) stop();
825
+ else timer = win.setTimeout(tick, 150);
826
+ };
827
+ pending.set(doc, stop);
828
+ timer = win.setTimeout(tick, 150);
907
829
  }
908
830
  //#endregion
909
- //#region src/capture.ts
910
- /** Curated computed-style subset enough to reconstruct layout intent, tiny on the wire. */
911
- const STYLE_KEYS = [
912
- "display",
913
- "position",
914
- "font-size",
915
- "color",
916
- "background-color",
917
- "margin",
918
- "padding",
919
- "overflow"
920
- ];
921
- const NEARBY_TEXT_MAX = 160;
922
- function styleSubset(win, el) {
923
- let cs;
831
+ //#region src/pin-reveal.ts
832
+ /** Query ordering is not a view change; repeated parameter ordering remains intact. */
833
+ function samePinView(win, value) {
834
+ if (!value) return true;
924
835
  try {
925
- cs = win.getComputedStyle(el);
836
+ const target = new URL(value, win.location.href);
837
+ const current = new URL(win.location.href);
838
+ target.searchParams.sort();
839
+ current.searchParams.sort();
840
+ return target.pathname === current.pathname && target.search === current.search;
926
841
  } catch {
927
- return;
928
- }
929
- const out = {};
930
- for (const key of STYLE_KEYS) {
931
- const value = cs.getPropertyValue(key);
932
- if (value !== "") out[key] = value;
842
+ return true;
933
843
  }
934
- return Object.keys(out).length > 0 ? out : void 0;
935
844
  }
936
- function ariaMap(el) {
937
- const out = {};
938
- for (const name of el.getAttributeNames()) if (name.startsWith("aria-")) out[name] = el.getAttribute(name) ?? "";
939
- return Object.keys(out).length > 0 ? out : void 0;
845
+ function pagePin(pin) {
846
+ return pin.target?.selector === "html" || pin.target?.selector === "body";
940
847
  }
941
- function nearbyText(el) {
942
- const text = (el.textContent ?? "").replace(/\s+/g, " ").trim();
943
- return text === "" ? void 0 : text.slice(0, NEARBY_TEXT_MAX);
944
- }
945
- /** The user's selection, only when it intersects the captured element. */
946
- function selectedText(win, el) {
848
+ /** Resolve before clipping: an offscreen row is precisely the target we need to scroll. */
849
+ function scrollToPin(doc, pin) {
850
+ if (pin.target?.model || pagePin(pin)) return false;
851
+ const win = doc.defaultView;
852
+ if (!win || !samePinView(win, pin.target?.url) || !pin.target?.selector) return false;
947
853
  try {
948
- const sel = win.getSelection?.();
949
- if (!sel || sel.isCollapsed || sel.rangeCount === 0) return void 0;
950
- if (!sel.getRangeAt(0).intersectsNode(el)) return void 0;
951
- const text = sel.toString().trim();
952
- return text === "" ? void 0 : text;
854
+ const element = doc.querySelector(pin.target.selector);
855
+ if (!element?.getClientRects().length) return false;
856
+ element.scrollIntoView({
857
+ behavior: "instant",
858
+ block: "center",
859
+ inline: "center"
860
+ });
861
+ return true;
953
862
  } catch {
954
- return;
863
+ return false;
955
864
  }
956
865
  }
957
- /** `fixed` detected via ancestry: any ancestor with computed position: fixed. */
958
- function isFixed(win, el) {
959
- for (let node = el; node !== null; node = node.parentElement) try {
960
- if (win.getComputedStyle(node).position === "fixed") return true;
866
+ const key = (endpoint) => `pinbox:${endpoint}:reveal`;
867
+ /** Returns true when leaving this document. Never navigates to a different origin. */
868
+ function revealPin(doc, pin, endpoint) {
869
+ cancelRevealSettle(doc);
870
+ const win = doc.defaultView;
871
+ if (!win) return false;
872
+ try {
873
+ win.sessionStorage.removeItem(key(endpoint));
874
+ } catch {}
875
+ if (pagePin(pin) || pin.target?.model) return false;
876
+ if (pin.target?.url && !samePinView(win, pin.target.url)) try {
877
+ const target = new URL(pin.target.url, win.location.href);
878
+ if (target.origin !== win.location.origin || !["http:", "https:"].includes(target.protocol)) return false;
879
+ win.sessionStorage.setItem(key(endpoint), JSON.stringify({
880
+ id: pin.id,
881
+ expires: Date.now() + 3e4
882
+ }));
883
+ win.location.assign(target.href);
884
+ return true;
961
885
  } catch {
962
886
  return false;
963
887
  }
888
+ scrollToPin(doc, pin);
964
889
  return false;
965
890
  }
966
- /** Beyond this an element is not a thing you pinned, it is a region. Too many to rewrite as a set. */
967
- const MAX_RUNS = 40;
968
- const MAX_RUN_LENGTH = 200;
969
- /** Text that is not content: a script body or a stylesheet is not something to rewrite. */
970
- const NON_CONTENT = /* @__PURE__ */ new Set([
971
- "SCRIPT",
972
- "STYLE",
973
- "NOSCRIPT",
974
- "TEMPLATE"
975
- ]);
976
- /**
977
- * The element's text, split the way the browser stores it: one entry per run of characters.
978
- *
979
- * This walks TEXT NODES, not elements, and that distinction is the whole point — it makes no
980
- * assumption about how a site is built. A heading is one run. A nav bar is one per link. A
981
- * paragraph with a bold word in the middle is three, in reading order, including the halves either
982
- * side of the bold. An earlier version keyed off "elements with no element children", which
983
- * quietly lost the "Hello " in `<p>Hello <b>world</b></p>` — text a person can obviously see and
984
- * would obviously expect to be able to change.
985
- *
986
- * `nearbyText` runs them all together, which is fine to read and useless to edit: it cannot tell
987
- * an agent that "work approach people contact" is four separate places. This can.
988
- */
989
- function textRuns(el) {
990
- const runs = [];
991
- const walk = (node) => {
992
- if (node.nodeType === 3) {
993
- const text = (node.nodeValue ?? "").trim();
994
- if (text.length > 0) runs.push(text.slice(0, MAX_RUN_LENGTH));
995
- return runs.length <= MAX_RUNS;
891
+ /** Called on pin updates and DOM changes: SPA content may arrive after the toolbar. */
892
+ function pendingPinReveal(doc, pins, endpoint) {
893
+ const win = doc.defaultView;
894
+ if (!win) return null;
895
+ try {
896
+ const raw = win.sessionStorage.getItem(key(endpoint));
897
+ if (!raw) return null;
898
+ const pending = JSON.parse(raw);
899
+ if (typeof pending.id !== "string" || !Number.isFinite(pending.expires) || pending.expires < Date.now()) {
900
+ win.sessionStorage.removeItem(key(endpoint));
901
+ return null;
996
902
  }
997
- if (node.nodeType !== 1 || NON_CONTENT.has(node.tagName)) return true;
998
- for (const child of node.childNodes) if (!walk(child)) return false;
999
- return true;
1000
- };
1001
- if (!walk(el) || runs.length === 0) return void 0;
1002
- return runs;
1003
- }
1004
- function buildContext(win, el) {
1005
- const context = {};
1006
- if (el.classList.length > 0) context.classes = [...el.classList];
1007
- const styles = styleSubset(win, el);
1008
- if (styles !== void 0) context.styles = styles;
1009
- const aria = ariaMap(el);
1010
- if (aria !== void 0) context.aria = aria;
1011
- const nearby = nearbyText(el);
1012
- if (nearby !== void 0) context.nearbyText = nearby;
1013
- const selected = selectedText(win, el);
1014
- if (selected !== void 0) context.selectedText = selected;
1015
- const runs = textRuns(el);
1016
- if (runs !== void 0) context.textRuns = runs;
1017
- return Object.keys(context).length > 0 ? context : void 0;
1018
- }
1019
- /** Fills PinInput.target/env from a chosen element (shapes come from the pin schema). */
1020
- function captureTarget(el, opts) {
1021
- const win = el.ownerDocument.defaultView;
1022
- const r = el.getBoundingClientRect();
1023
- const target = {
1024
- url: win.location.href,
1025
- selector: buildSelector(el),
1026
- tag: el.tagName.toLowerCase(),
1027
- rect: {
1028
- x: r.left + win.scrollX,
1029
- y: r.top + win.scrollY,
1030
- width: r.width,
1031
- height: r.height
1032
- },
1033
- fixed: isFixed(win, el)
1034
- };
1035
- if (opts?.anchor !== void 0) target.anchor = opts.anchor;
1036
- if (opts?.at !== void 0 && r.width > 0 && r.height > 0) {
1037
- const fx = (opts.at.x - (r.left + win.scrollX)) / r.width;
1038
- const fy = (opts.at.y - (r.top + win.scrollY)) / r.height;
1039
- if (fx >= 0 && fx <= 1 && fy >= 0 && fy <= 1) target.spot = {
1040
- x: fx,
1041
- y: fy
1042
- };
903
+ const pin = pins.find((p) => p.id === pending.id);
904
+ if (!pin || !scrollToPin(doc, pin)) return null;
905
+ win.sessionStorage.removeItem(key(endpoint));
906
+ settlePinReveal(doc, () => {
907
+ scrollToPin(doc, pin);
908
+ });
909
+ return pin.id;
910
+ } catch {
911
+ return null;
1043
912
  }
1044
- const context = buildContext(win, el);
1045
- if (context !== void 0) target.context = context;
1046
- return {
1047
- target,
1048
- env: {
1049
- viewport: {
1050
- w: win.innerWidth,
1051
- h: win.innerHeight,
1052
- dpr: win.devicePixelRatio
1053
- },
1054
- browser: win.navigator.userAgent,
1055
- os: win.navigator.platform || "unknown",
1056
- colorScheme: win.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
1057
- }
1058
- };
1059
913
  }
1060
914
  //#endregion
1061
915
  //#region src/ui/aim.ts
@@ -1311,7 +1165,8 @@ function createPlacement(deps) {
1311
1165
  function placeDraft(e) {
1312
1166
  e.preventDefault();
1313
1167
  e.stopPropagation();
1314
- const capture = captureTarget(hover ?? doc.body, { at: {
1168
+ const el = hover ?? doc.body;
1169
+ const capture = captureTarget(el, { at: {
1315
1170
  x: e.pageX,
1316
1171
  y: e.pageY
1317
1172
  } });
@@ -2368,8 +2223,8 @@ function createBar(doc, on) {
2368
2223
  inboxBtn.classList.toggle("lit", state.inboxOpen);
2369
2224
  const open = String(openTaskCount(state.pins));
2370
2225
  if (count.textContent !== open) count.textContent = open;
2371
- captureBtn.classList.toggle("lit", state.captureMode === "tab");
2372
- captureBtn.title = state.captureMode === "tab" ? "Tab capture on — real pixels, Chrome asks once per page load (S)" : "Screenshots: DOM snapshot, no prompt — press for tab capture (S)";
2226
+ captureBtn.classList.toggle("lit", !state.captureLabel && state.captureMode === "tab");
2227
+ captureBtn.title = state.captureLabel ?? (state.captureMode === "tab" ? "Tab capture on — real pixels, Chrome asks once per page load (S)" : "Screenshots: DOM snapshot, no prompt — press for tab capture (S)");
2373
2228
  if (hideShown !== state.pinsHidden) {
2374
2229
  hideShown = state.pinsHidden;
2375
2230
  hideBtn.innerHTML = icon(state.pinsHidden ? EYE_GLYPH : EYE_OFF_GLYPH, 14);
@@ -2649,20 +2504,6 @@ const chipMemo = /* @__PURE__ */ new WeakMap();
2649
2504
  function nextOrdinal(pins) {
2650
2505
  return Math.max(pins.length, ...pins.map((p) => p.n ?? 0)) + 1;
2651
2506
  }
2652
- /**
2653
- * Does the pin's captured URL still describe the view on screen? Path + search
2654
- * only — hashes are anchors, not views. An absent or unparseable URL never
2655
- * gates: old pins (and CLI pins) keep rendering exactly as before.
2656
- */
2657
- function sameView(win, url) {
2658
- if (url === void 0) return true;
2659
- try {
2660
- const target = new URL(url, win.location.href);
2661
- return target.pathname === win.location.pathname && target.search === win.location.search;
2662
- } catch {
2663
- return true;
2664
- }
2665
- }
2666
2507
  /** A stored (document-space) rect or point, in today's viewport. */
2667
2508
  function toViewport(win, p) {
2668
2509
  return {
@@ -2687,22 +2528,23 @@ function anchorRect(doc, pin) {
2687
2528
  }
2688
2529
  /** The same resolution for any captured target — a pin's, or the draft's before it commits. */
2689
2530
  function targetRect(doc, target) {
2531
+ if (target?.model) return projectModelTarget(doc, target.model);
2690
2532
  const stored = target?.rect;
2691
- if (stored === void 0) return null;
2692
2533
  const win = doc.defaultView;
2693
- if (win === null) return stored;
2694
- if (!sameView(win, target?.url)) return null;
2534
+ if (win === null) return stored ?? null;
2535
+ if (!samePinView(win, target?.url)) return null;
2695
2536
  const selector = target?.selector;
2696
- if (selector === void 0) return toViewport(win, stored);
2537
+ if (selector === void 0) return stored ? toViewport(win, stored) : null;
2538
+ if (!stored && (selector === "html" || selector === "body")) return null;
2697
2539
  let el;
2698
2540
  try {
2699
2541
  el = doc.querySelector(selector);
2700
2542
  } catch {
2701
- return toViewport(win, stored);
2543
+ return stored ? toViewport(win, stored) : null;
2702
2544
  }
2703
2545
  if (el === null) return null;
2704
2546
  const r = el.getBoundingClientRect();
2705
- if (r.width <= 0 && r.height <= 0) return toViewport(win, stored);
2547
+ if (r.width <= 0 && r.height <= 0) return stored ? toViewport(win, stored) : null;
2706
2548
  return clipToScrollAncestors(win, el, {
2707
2549
  x: r.left,
2708
2550
  y: r.top,
@@ -3016,6 +2858,8 @@ function anchorOf(root, pin, draft) {
3016
2858
  * labels the card without claiming an element that was never captured.
3017
2859
  */
3018
2860
  function labelOf(target) {
2861
+ if (target?.selector === "html" || target?.selector === "body") return "PAGE";
2862
+ if (target?.model) return `3D · ${target.model.partId}`;
3019
2863
  return target?.anchor ?? target?.tag?.toUpperCase() ?? "PIN";
3020
2864
  }
3021
2865
  function viewOf(root, state) {
@@ -3753,11 +3597,7 @@ var PinboxToolbarElement = class extends BaseElement {
3753
3597
  return loadCaptureMode(globalThis.localStorage, key, this.config?.capture ?? "dom");
3754
3598
  }
3755
3599
  #toggleCapture() {
3756
- const next = this.store.get().captureMode === "tab" ? "dom" : "tab";
3757
- this.store.update({ captureMode: next });
3758
- if (next === "dom") releaseCapture();
3759
- saveCaptureMode(globalThis.localStorage, captureKey(`pinbox:${this.config?.endpoint ?? ""}`), next);
3760
- return true;
3600
+ return toggleToolbarCapture(this, this.config?.endpoint ?? "", releaseCapture);
3761
3601
  }
3762
3602
  /** Theme from the OS when the host set none; the page-level CSS (placing cursor) into <head>. */
3763
3603
  #applyPageDefaults() {
@@ -4028,16 +3868,15 @@ var PinboxToolbarElement = class extends BaseElement {
4028
3868
  /** Inbox item click: activate the pin and scroll it into view (prototype line 700). */
4029
3869
  #activateFromInbox(pinId) {
4030
3870
  const pin = this.store.get().pins.find((p) => p.id === pinId);
3871
+ if (!pin) return;
3872
+ this.store.update({
3873
+ inboxOpen: false,
3874
+ activePinId: null,
3875
+ pinsHidden: false
3876
+ });
3877
+ if (revealPin(document, pin, this.config?.endpoint ?? "")) return;
4031
3878
  this.#ensureThread(pinId);
4032
3879
  this.store.update({ activePinId: pinId });
4033
- const rect = pin === void 0 ? null : anchorRect(document, pin);
4034
- if (rect) {
4035
- const y = window.scrollY + rect.y + rect.height / 2;
4036
- window.scrollTo({
4037
- top: Math.max(0, y - window.innerHeight / 2),
4038
- behavior: "smooth"
4039
- });
4040
- }
4041
3880
  }
4042
3881
  /**
4043
3882
  * Nudge a stale pin: re-post the last human message as a new thread message. A watcher that
@@ -4186,6 +4025,16 @@ var PinboxToolbarElement = class extends BaseElement {
4186
4025
  });
4187
4026
  };
4188
4027
  #render(state) {
4028
+ const restored = pendingPinReveal(document, state.pins, this.config?.endpoint ?? "");
4029
+ if (restored) {
4030
+ this.#ensureThread(restored);
4031
+ this.store.update({
4032
+ activePinId: restored,
4033
+ inboxOpen: false,
4034
+ pinsHidden: false
4035
+ });
4036
+ return;
4037
+ }
4189
4038
  const placing = state.mode === "placing";
4190
4039
  this.toggleAttribute("data-placing", placing);
4191
4040
  document.body.classList.toggle(PAGE_PLACING_CLASS, placing);
@@ -4198,6 +4047,184 @@ var PinboxToolbarElement = class extends BaseElement {
4198
4047
  }
4199
4048
  };
4200
4049
  //#endregion
4050
+ //#region src/ui/preview-styles.ts
4051
+ const PREVIEW_STYLES = `
4052
+ .pb-preview-wrap { display:flex; align-items:center; border-left:1px solid var(--pb-line); margin-left:4px; padding-left:4px; }
4053
+ .pb-preview-trigger { max-width:160px; gap:7px; }
4054
+ .pb-preview-trigger .name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:110px; }
4055
+ .pb-preview-trigger svg { flex-shrink:0; }
4056
+ .pb-preview-menu { position:fixed; margin:0; padding:0; width:304px; max-width:calc(100vw - 24px); max-height:70vh; overflow:auto; background:var(--pb-surface); color:var(--pb-fg1); border:1px solid var(--pb-line-2); border-radius:4px; box-shadow:var(--pb-shadow); font-family:var(--pb-font-body); }
4057
+ .pb-preview-menu::backdrop { background:transparent; }
4058
+ .pb-preview-head { display:flex; align-items:center; justify-content:space-between; padding:10px 12px 8px 16px; border-bottom:1px solid var(--pb-line); }
4059
+ .pb-preview-head span { font:10px var(--pb-font-mono); letter-spacing:.18em; color:var(--pb-fg3); }
4060
+ .pb-preview-list { padding:5px; }
4061
+ .pb-preview-option { display:flex; align-items:center; gap:10px; width:100%; text-align:left; padding:11px 10px; border-radius:2px; color:var(--pb-fg2); }
4062
+ .pb-preview-option:hover, .pb-preview-option:focus-visible { background:var(--pb-hover); outline:1px solid var(--pb-line-2); }
4063
+ .pb-preview-option[aria-checked="true"] { background:var(--pb-amber-soft); color:var(--pb-fg1); }
4064
+ .pb-preview-option:disabled { opacity:.45; cursor:not-allowed; }
4065
+ .pb-preview-option .check { width:14px; flex-shrink:0; color:var(--pb-amber); }
4066
+ .pb-preview-option .copy { display:flex; flex-direction:column; gap:4px; min-width:0; }
4067
+ .pb-preview-option .title { font-size:12px; font-weight:500; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
4068
+ .pb-preview-option .branch { font:10px var(--pb-font-mono); color:var(--pb-fg3); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
4069
+ .pb-preview-foot { border-top:1px solid var(--pb-line); padding:11px 16px; display:flex; flex-direction:column; gap:8px; }
4070
+ .pb-preview-status { font-size:11px; line-height:1.5; color:var(--pb-fg3); }
4071
+ .pb-preview-foot a { font:10px var(--pb-font-mono); letter-spacing:.08em; color:var(--pb-amber); text-decoration:none; }
4072
+ `;
4073
+ //#endregion
4074
+ //#region src/ui/preview-switcher.ts
4075
+ const CHEVRON = icon("<path d=\"m4 6 4 4 4-4\"/>", 14);
4076
+ const REFRESH = icon("<path d=\"M2 8a6 6 0 0 1 10.5-4L14 6M14 2v4h-4M14 8a6 6 0 0 1-10.5 4L2 10M2 14v-4h4\"/>", 14);
4077
+ const BRANCH = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.3\"><circle cx=\"4\" cy=\"3\" r=\"1.5\"/><circle cx=\"4\" cy=\"13\" r=\"1.5\"/><circle cx=\"12\" cy=\"4\" r=\"1.5\"/><path d=\"M4 4.5v7M12 5.5v1a3 3 0 0 1-3 3H7a3 3 0 0 0-3 3\"/></svg>";
4078
+ function createPreviewView(doc, root, bar) {
4079
+ const style = doc.createElement("style");
4080
+ style.textContent = PREVIEW_STYLES;
4081
+ root.append(style);
4082
+ const wrap = doc.createElement("div");
4083
+ wrap.dataset["previewSwitcher"] = "";
4084
+ wrap.className = "pb-preview-wrap";
4085
+ wrap.innerHTML = `<button type="button" class="pb-tb pb-preview-trigger" aria-label="Switch preview" aria-haspopup="menu" aria-controls="pb-preview-menu" aria-expanded="false">${BRANCH}<span class="name">PREVIEW</span>${CHEVRON}</button>
4086
+ <div id="pb-preview-menu" class="pb-preview-menu" popover="auto" aria-label="Local previews">
4087
+ <div class="pb-preview-head"><span>LOCAL PREVIEWS</span><button type="button" class="pb-tb sq" aria-label="Refresh previews">${REFRESH}</button></div>
4088
+ <div class="pb-preview-list" role="menu" aria-label="Preview branch or worktree"></div>
4089
+ <div class="pb-preview-foot"><span class="pb-preview-status" role="status">Loading previews…</span><a target="_blank" rel="noopener noreferrer" hidden>OPEN PR ↗</a></div>
4090
+ </div>`;
4091
+ bar.append(wrap);
4092
+ const trigger = wrap.querySelector(".pb-preview-trigger");
4093
+ const panel = wrap.querySelector(".pb-preview-menu");
4094
+ const list = wrap.querySelector(".pb-preview-list");
4095
+ const reload = wrap.querySelector("[aria-label=\"Refresh previews\"]");
4096
+ const status = wrap.querySelector("[role=\"status\"]");
4097
+ const pr = wrap.querySelector("a");
4098
+ wirePopover(trigger, panel, list, doc);
4099
+ return {
4100
+ trigger,
4101
+ list,
4102
+ status,
4103
+ reload,
4104
+ render(choices) {
4105
+ renderOptions(doc, list, choices);
4106
+ const current = choices.find((p) => p.current);
4107
+ trigger.querySelector(".name").textContent = compactLabel(current);
4108
+ trigger.setAttribute("aria-label", `Switch preview: ${current?.label ?? "local previews"}`);
4109
+ trigger.title = current ? `${current.branch} · ${current.commit.slice(0, 8)}` : "Choose a preview";
4110
+ status.textContent = current?.backend || "Local frontend preview";
4111
+ pr.hidden = !current?.pr;
4112
+ if (current?.pr) pr.href = current.pr;
4113
+ }
4114
+ };
4115
+ }
4116
+ function compactLabel(current) {
4117
+ if (!current) return "PREVIEW";
4118
+ return current.label.match(/^PR #\d+/)?.[0] ?? (current.branch === "main" ? "MAIN" : current.branch.replace(/^[^/]+\//, ""));
4119
+ }
4120
+ function renderOptions(doc, list, choices) {
4121
+ list.replaceChildren();
4122
+ for (const item of choices) {
4123
+ const button = doc.createElement("button");
4124
+ button.type = "button";
4125
+ button.className = "pb-preview-option";
4126
+ button.dataset["previewId"] = item.id;
4127
+ button.setAttribute("role", "menuitemradio");
4128
+ button.setAttribute("aria-checked", String(item.current));
4129
+ button.disabled = !item.ready;
4130
+ button.innerHTML = "<span class=\"check\" aria-hidden=\"true\"></span><span class=\"copy\"><span class=\"title\"></span><span class=\"branch\"></span></span>";
4131
+ button.querySelector(".check").textContent = item.current ? "✓" : "";
4132
+ button.querySelector(".title").textContent = item.label;
4133
+ button.querySelector(".branch").textContent = `${item.branch}${item.ready ? "" : " · unavailable"}`;
4134
+ list.append(button);
4135
+ }
4136
+ }
4137
+ function wirePopover(trigger, panel, list, doc) {
4138
+ trigger.addEventListener("click", () => {
4139
+ panel.togglePopover();
4140
+ const rect = trigger.getBoundingClientRect();
4141
+ const width = doc.documentElement.clientWidth;
4142
+ panel.style.left = `${Math.max(12, Math.min(rect.right - 304, width - 316))}px`;
4143
+ panel.style.top = `${rect.top > panel.offsetHeight + 20 ? rect.top - panel.offsetHeight - 10 : rect.bottom + 10}px`;
4144
+ (list.querySelector("button[aria-checked=\"true\"]:not(:disabled)") ?? list.querySelector("button:not(:disabled)"))?.focus();
4145
+ });
4146
+ panel.addEventListener("toggle", () => trigger.setAttribute("aria-expanded", String(panel.matches(":popover-open"))));
4147
+ panel.addEventListener("keydown", (e) => {
4148
+ const buttons = [...list.querySelectorAll("button:not(:disabled)")];
4149
+ const index = buttons.indexOf(panel.getRootNode().activeElement);
4150
+ if ([
4151
+ "ArrowDown",
4152
+ "ArrowUp",
4153
+ "Home",
4154
+ "End"
4155
+ ].includes(e.key)) {
4156
+ e.preventDefault();
4157
+ buttons[e.key === "Home" ? 0 : e.key === "End" ? buttons.length - 1 : (index + (e.key === "ArrowDown" ? 1 : -1) + buttons.length) % buttons.length]?.focus();
4158
+ }
4159
+ if (e.key === "Escape") {
4160
+ panel.hidePopover();
4161
+ trigger.focus();
4162
+ }
4163
+ });
4164
+ }
4165
+ //#endregion
4166
+ //#region src/previews.ts
4167
+ function previewDestination(origin, current) {
4168
+ const target = new URL(origin);
4169
+ const source = new URL(current);
4170
+ if (!["http:", "https:"].includes(target.protocol) || target.username || target.password || target.pathname !== "/" || target.search || target.hash) throw new Error("Invalid preview origin");
4171
+ target.pathname = source.pathname;
4172
+ target.search = source.search;
4173
+ target.hash = source.hash;
4174
+ return target.href;
4175
+ }
4176
+ function mountPreviewSwitcher(host, endpoint) {
4177
+ const root = host.shadowRoot;
4178
+ const bar = root?.querySelector(".pb-bar");
4179
+ if (!root || !bar || root.querySelector("[data-preview-switcher]")) return;
4180
+ const doc = host.ownerDocument;
4181
+ const { trigger, list, status, reload, render } = createPreviewView(doc, root, bar);
4182
+ let choices = [];
4183
+ let busy = false;
4184
+ const load = async () => {
4185
+ const response = await fetch(endpoint, {
4186
+ credentials: "same-origin",
4187
+ cache: "no-store"
4188
+ });
4189
+ const envelope = await response.json();
4190
+ if (!response.ok || !envelope.ok || !Array.isArray(envelope.data)) throw new Error("Preview discovery unavailable");
4191
+ return envelope.data;
4192
+ };
4193
+ async function refresh() {
4194
+ if (busy) return;
4195
+ busy = true;
4196
+ try {
4197
+ choices = await load();
4198
+ render(choices);
4199
+ trigger.disabled = false;
4200
+ } catch {
4201
+ status.textContent = "Preview discovery unavailable";
4202
+ trigger.disabled = false;
4203
+ } finally {
4204
+ busy = false;
4205
+ }
4206
+ }
4207
+ reload.addEventListener("click", () => {
4208
+ refresh();
4209
+ });
4210
+ list.addEventListener("click", async (event) => {
4211
+ const target = event.target.closest("[data-preview-id]");
4212
+ const id = target?.dataset["previewId"];
4213
+ if (!id || target?.disabled) return;
4214
+ trigger.disabled = true;
4215
+ status.textContent = "Checking preview…";
4216
+ try {
4217
+ const destination = (await load()).find((p) => p.id === id);
4218
+ if (!destination?.ready || !destination.url) throw new Error("unavailable");
4219
+ doc.defaultView?.location.assign(previewDestination(destination.url, doc.location.href));
4220
+ } catch {
4221
+ status.textContent = "Preview unavailable. Refresh to try again.";
4222
+ trigger.disabled = false;
4223
+ }
4224
+ });
4225
+ refresh();
4226
+ }
4227
+ //#endregion
4201
4228
  //#region src/index.ts
4202
4229
  /** Register <pinbox-toolbar>; no-op outside a browser or when already defined. */
4203
4230
  function defineToolbarElement() {
@@ -4213,4 +4240,4 @@ const Pinbox = { init(config) {
4213
4240
  } };
4214
4241
  defineToolbarElement();
4215
4242
  //#endregion
4216
- 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 };
4243
+ export { HubTransport as a, applyHubEvent as c, upsertPin as d, PinboxToolbarElement as i, createStore as l, defineToolbarElement as n, HubError as o, mountPreviewSwitcher as r, appendThreadMessage as s, Pinbox as t, deriveUiStatus as u };