@orbytes/astrolab 0.3.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.
Files changed (94) hide show
  1. package/LICENSE +37 -0
  2. package/README.md +410 -0
  3. package/bin/lab-cull.mjs +401 -0
  4. package/bin/pin-gallery.mjs +121 -0
  5. package/defaults.mjs +120 -0
  6. package/dist/core/astro-integration.js +130 -0
  7. package/dist/core/index.js +6 -0
  8. package/dist/core/lib-paths.js +29 -0
  9. package/dist/core/options.js +173 -0
  10. package/dist/core/utils/get-exports.js +52 -0
  11. package/dist/core/utils/invariant.js +12 -0
  12. package/dist/core/utils/kebab-case.js +15 -0
  13. package/dist/core/utils/path-builder.js +22 -0
  14. package/dist/core/utils/path.js +53 -0
  15. package/dist/core/virtual-module/get-story-modules.js +60 -0
  16. package/dist/core/virtual-module/story-modules.js +8 -0
  17. package/dist/core/virtual-module/virtual-module-ids.js +22 -0
  18. package/dist/core/virtual-module/virtual-routes.js +83 -0
  19. package/dist/core/virtual-module/vite-plugin.js +98 -0
  20. package/docs/PIN-CONTRACT.md +263 -0
  21. package/docs/PIN.md +429 -0
  22. package/index.d.ts +279 -0
  23. package/index.mjs +347 -0
  24. package/package.json +93 -0
  25. package/src/Empty.astro +4 -0
  26. package/src/Home.astro +298 -0
  27. package/src/LabHead.astro +1102 -0
  28. package/src/core/LICENSE-astrobook +166 -0
  29. package/src/core/astro-integration.ts +166 -0
  30. package/src/core/client.ts +89 -0
  31. package/src/core/index.ts +7 -0
  32. package/src/core/lib/components/empty.astro +1 -0
  33. package/src/core/lib/components/head.astro +1 -0
  34. package/src/core/lib/components/home.astro +8 -0
  35. package/src/core/lib/components/with-decorators.astro +22 -0
  36. package/src/core/lib/pages/app.astro +19 -0
  37. package/src/core/lib/pages/preview.astro +17 -0
  38. package/src/core/lib/pages/story.astro +16 -0
  39. package/src/core/lib-paths.ts +72 -0
  40. package/src/core/options.ts +262 -0
  41. package/src/core/utils/get-exports.ts +59 -0
  42. package/src/core/utils/invariant.ts +13 -0
  43. package/src/core/utils/kebab-case.ts +30 -0
  44. package/src/core/utils/path-builder.ts +45 -0
  45. package/src/core/utils/path.ts +80 -0
  46. package/src/core/virtual-module/get-story-modules.ts +110 -0
  47. package/src/core/virtual-module/story-modules.ts +9 -0
  48. package/src/core/virtual-module/virtual-module-ids.ts +17 -0
  49. package/src/core/virtual-module/virtual-routes.ts +130 -0
  50. package/src/core/virtual-module/vite-plugin.ts +125 -0
  51. package/src/pin/board.mjs +1521 -0
  52. package/src/pin/index.mjs +666 -0
  53. package/src/pin/shot.mjs +427 -0
  54. package/src/pin/source-stamp.mjs +159 -0
  55. package/src/pin/tickets.mjs +697 -0
  56. package/src/pin/toolbar.js +3181 -0
  57. package/src/shell/Browse.astro +371 -0
  58. package/src/shell/CardGrid.astro +297 -0
  59. package/src/shell/Viewport.astro +1330 -0
  60. package/src/shell/index.json.ts +12 -0
  61. package/src/shell/lab-index.ts +344 -0
  62. package/src/shell/lab-params.ts +245 -0
  63. package/src/shell/live-files.mjs +164 -0
  64. package/src/shell/marks.mjs +136 -0
  65. package/src/types/index.ts +6 -0
  66. package/src/types/types.ts +239 -0
  67. package/src/types/virtual.d.ts +29 -0
  68. package/src/ui/components/app.astro +13 -0
  69. package/src/ui/components/build-path.ts +13 -0
  70. package/src/ui/components/build-tree.ts +108 -0
  71. package/src/ui/components/collapse-duration.ts +28 -0
  72. package/src/ui/components/compress-terms.ts +10 -0
  73. package/src/ui/components/dashboard-layout.astro +39 -0
  74. package/src/ui/components/home.astro +65 -0
  75. package/src/ui/components/layout.astro +110 -0
  76. package/src/ui/components/preview-layout.astro +109 -0
  77. package/src/ui/components/sidebar-button-fullscreen.astro +38 -0
  78. package/src/ui/components/sidebar-button-search.astro +23 -0
  79. package/src/ui/components/sidebar-button-theme.astro +9 -0
  80. package/src/ui/components/sidebar-button.astro +24 -0
  81. package/src/ui/components/sidebar-resize-handle.astro +74 -0
  82. package/src/ui/components/sidebar-search-panel.astro +41 -0
  83. package/src/ui/components/sidebar-search-script.ts +103 -0
  84. package/src/ui/components/sidebar-title.astro +17 -0
  85. package/src/ui/components/sidebar-tree-node.astro +143 -0
  86. package/src/ui/components/sidebar-tree.astro +84 -0
  87. package/src/ui/components/sidebar.astro +29 -0
  88. package/src/ui/components/theme-message.ts +26 -0
  89. package/src/ui/components/theme-script.astro +71 -0
  90. package/src/ui/components/theme-toggle.astro +63 -0
  91. package/src/ui/components/theme.ts +32 -0
  92. package/src/ui/index.ts +4 -0
  93. package/src/ui/lab.css +549 -0
  94. package/virtual.d.ts +42 -0
@@ -0,0 +1,3181 @@
1
+ /**
2
+ * orbytes-pin — the dev toolbar app.
3
+ *
4
+ * Click a rendered element on `astro dev`, type or dictate a comment, and the dev server writes a
5
+ * markdown ticket plus a screenshot to local disk. This file is the browser half only; the
6
+ * integration (index.mjs) owns the server half and registers this file as the app entrypoint.
7
+ *
8
+ * Two surfaces live here, and they are allowed to be open at once:
9
+ *
10
+ * · THE PICKER — the crosshair, the climb, the comment box. Everything below `the app`.
11
+ * · THE TICKET PANEL and its PAGE MARKERS — a right-hand rail with an Open and a Resolved
12
+ * tab, and a map pin on every ticket's element on this page: blue for open, grey for
13
+ * resolved, none at all for one that cannot be placed honestly. Everything under
14
+ * `the ticket panel`, whose own contract notes say why each part is shaped as it is.
15
+ *
16
+ * The panel reads tickets from the board's own per-card JSON payload (`renderBoard`,
17
+ * src/board.mjs) and writes through the package's one endpoint, whose address it takes from
18
+ * the board's `data-api` — it never parses a ticket file and never assembles a URL.
19
+ *
20
+ * Self-contained on purpose: it imports no FILE from its own package — only `astro/toolbar`
21
+ * (the public app helper), two of Astro's own dev-toolbar helpers, and one virtual module the
22
+ * integration generates (`virtual:orbytes-pin/config.mjs`, the resolved options — a toolbar app
23
+ * entrypoint is handed none, so this is the only way in). The helpers are both reachable through
24
+ * astro's declared `./client/*` export map:
25
+ *
26
+ * node_modules/astro/package.json "./toolbar" -> dist/toolbar/index.js
27
+ * node_modules/astro/package.json "./client/*" -> dist/runtime/client/*
28
+ *
29
+ * Astro 7.3.1 APIs used, with the file each was read from:
30
+ * dist/types/public/toolbar.d.ts:30 init(canvas: ShadowRoot, app, server)
31
+ * dist/types/public/toolbar.d.ts:31 beforeTogglingOff(canvas): boolean
32
+ * dist/toolbar/index.js:1 defineToolbarApp(app)
33
+ * dist/runtime/client/dev-toolbar/helpers.js:65/79 server.send / server.on
34
+ * dist/runtime/client/dev-toolbar/helpers.js:36 app.onToggled({ state })
35
+ * dist/runtime/client/dev-toolbar/apps/utils/highlight.js createHighlight/positionHighlight
36
+ * dist/runtime/client/dev-toolbar/apps/utils/window.js createWindowElement/synchronizePlacementOnUpdate
37
+ */
38
+
39
+ import { defineToolbarApp } from 'astro/toolbar';
40
+ import {
41
+ createHighlight,
42
+ getElementsPositionInDocument,
43
+ positionHighlight,
44
+ } from 'astro/client/dev-toolbar/apps/utils/highlight.js';
45
+ import {
46
+ createWindowElement,
47
+ synchronizePlacementOnUpdate,
48
+ } from 'astro/client/dev-toolbar/apps/utils/window.js';
49
+ import pinConfig from 'virtual:orbytes-pin/config.mjs';
50
+
51
+ const SRC_ATTR = 'data-orbytes-src';
52
+ const RESTORE_KEY = 'orbytes-pin:last';
53
+ const RESTORE_TTL_MS = 30 * 60 * 1000;
54
+ const FLASH_MS = 1200;
55
+ const OUTER_HTML_LIMIT = 400;
56
+ const REPLY_TIMEOUT_MS = 8000;
57
+ /**
58
+ * How long a transient acknowledgement stays on screen. Long enough to read the ticket id after
59
+ * looking away to the page; short enough that the panel is not carrying yesterday's news. A
60
+ * FAILURE never uses this — it has no timer at all (› `say`).
61
+ */
62
+ const ACK_MS = 6000;
63
+ /** How far the hit test will follow nested frames before it stops and says so. */
64
+ const MAX_FRAME_DEPTH = 8;
65
+
66
+ /**
67
+ * Layering. The built-in window is `position: fixed; z-index: 999999999`
68
+ * (ui-library/window.js) and the toolbar bar is `z-index: 2000000010` (toolbar.js:47).
69
+ * Everything this app paints has to sit ABOVE the page and BELOW the window, or the pick
70
+ * surface swallows clicks meant for the comment box.
71
+ */
72
+ const Z = {
73
+ surface: 999_998_000,
74
+ hover: 999_998_500,
75
+ selected: 999_998_600,
76
+ chip: 999_998_700,
77
+ flash: 999_998_800,
78
+ /** A pin marker: over the pick surface so it is clickable, under the pick highlight so the
79
+ * crosshair's own chrome always reads on top of it. */
80
+ marker: 999_998_400,
81
+ /** The ticket panel: over everything this app paints on the page, under the comment window,
82
+ * which must never be covered by anything. */
83
+ panel: 999_998_900,
84
+ };
85
+
86
+ /* ------------------------------------------------------------------ pure helpers */
87
+
88
+ /**
89
+ * Words that name a RUNTIME CONDITION rather than a variant. A class ending in one of these
90
+ * moves between elements while the page is alive — `swiper-slide-active` is on slide 1 now and
91
+ * slide 2 once the carousel scrolls — so a selector built on one is dead the moment the
92
+ * component moves, exactly like an id minted at init.
93
+ *
94
+ * This is English state vocabulary, not one library's namespace, and that is the point: the
95
+ * same list retires `swiper-slide-active`, `slick-active`, `glide__slide--active`,
96
+ * `owl-item-active`, `tns-slide-active` and a hand-written `is-open` without naming any of them.
97
+ * Variant words — `solid`, `small`, `medium`, `wide`, `outline`, `tan` — are deliberately absent,
98
+ * because a variant is authored and permanent.
99
+ *
100
+ * Measured on this site 2026-09-21 (311 distinct classes across four pages): this list retires
101
+ * nine of them. Six are genuinely runtime (`swiper-slide-active/-visible/-fully-visible/-next`,
102
+ * `swiper-backface-hidden`, `is-unbuilt`); three are authored and stable but state-named
103
+ * (`founder__arrow-icon--next/--prev`, `navbar__toggle-icon--open`). Losing those three costs a
104
+ * slightly longer selector and nothing else, because cssPathOf verifies whatever it builds and
105
+ * falls through to tag + :nth-of-type. That asymmetry is the whole argument for erring wide: a
106
+ * false positive costs readability, a false negative costs the ticket.
107
+ */
108
+ const STATE_WORDS = new Set([
109
+ 'active', 'inactive', 'animate', 'animated', 'animating', 'busy', 'checked', 'closed',
110
+ 'closing', 'collapsed', 'current', 'dimmed', 'dirty', 'disabled', 'dragged', 'dragging',
111
+ 'duplicate', 'empty', 'enabled', 'entered', 'entering', 'error', 'exiting', 'filled',
112
+ 'first', 'focus', 'focused', 'hidden', 'highlighted', 'hover', 'hovered', 'init',
113
+ 'initialized', 'invalid', 'inview', 'invisible', 'last', 'leaving', 'loaded', 'loading',
114
+ 'mounted', 'next', 'offscreen', 'onscreen', 'open', 'opened', 'opening', 'paused',
115
+ 'pinned', 'playing', 'pressed', 'prev', 'previous', 'pristine', 'ready', 'revealed',
116
+ 'revealing', 'scrolled', 'scrolling', 'selected', 'showing', 'shown', 'stuck', 'sticky',
117
+ 'touched', 'transitioning', 'unchecked', 'valid', 'visible',
118
+ ]);
119
+
120
+ /**
121
+ * True for a class that describes what the element is DOING rather than what it IS.
122
+ * Two shapes, both library-agnostic:
123
+ * · the universal state prefixes — `is-open`, `has-error`, `js-active`;
124
+ * · a trailing state word — `swiper-slide-active`, `foo--visible`, `active`.
125
+ */
126
+ function isTransientClass(name) {
127
+ const words = String(name ?? '').toLowerCase().split(/[-_]+/).filter(Boolean);
128
+ if (!words.length) return false;
129
+ if (words.length > 1 && (words[0] === 'is' || words[0] === 'has' || words[0] === 'js')) return true;
130
+ return STATE_WORDS.has(words[words.length - 1]);
131
+ }
132
+
133
+ /**
134
+ * Classes that are machine output, not authored intent — never put them in a selector, because
135
+ * the value changes and the selector stops resolving. Two families:
136
+ * · build hashes, which change when the file is next touched;
137
+ * · runtime state (› isTransientClass), which changes while the page is open.
138
+ *
139
+ * Still deliberately narrow about authored names: BEM blocks, elements and modifiers
140
+ * (`hero__title`, `button--solid`, `section--medium`) are authored and must survive, and
141
+ * anything this misses is still caught by the selector verification in cssPathOf().
142
+ */
143
+ function isNoiseClass(name) {
144
+ if (!name) return true;
145
+ if (/^astro-[a-z0-9]{6,}$/i.test(name)) return true; // Astro scoped style class
146
+ if (/^svelte-[a-z0-9]{5,}$/i.test(name)) return true;
147
+ if (/^_[A-Za-z0-9-]+_[a-z0-9]{4,}$/i.test(name)) return true; // Vite CSS-module hash
148
+ if (isTransientClass(name)) return true;
149
+ return false;
150
+ }
151
+
152
+ /**
153
+ * The base class this one decorates with a SINGLE dash, when that base is also on the element —
154
+ * `swiper-slide` under `swiper-slide-active`, `swiper` under `swiper-initialized`. Returns null
155
+ * otherwise.
156
+ *
157
+ * The separator is the discriminator, and it is a convention rather than a law, which is why the
158
+ * state lexicon above is the primary rule and this is the backstop. A library that mints state
159
+ * onto its own block class almost always does it with one dash (`slick-initialized`,
160
+ * `owl-loaded`, `tns-horizontal`, `swiper-watch-progress`); a human writing BEM reaches for `--`
161
+ * or `__`. Measured here: of 48 same-element extensions on this site, every single-dash one is
162
+ * Swiper's and every `--` one is authored (`button--solid`, `footer__band--cta`,
163
+ * `approach__step-slot--1`). Cost of a wrong call is again readability only.
164
+ */
165
+ function extendsSiblingClass(name, all) {
166
+ for (const base of all) {
167
+ if (base === name || !base) continue;
168
+ if (
169
+ name.length > base.length + 1 &&
170
+ name.startsWith(base) &&
171
+ name[base.length] === '-' &&
172
+ name[base.length + 1] !== '-'
173
+ ) {
174
+ return base;
175
+ }
176
+ }
177
+ return null;
178
+ }
179
+
180
+ function stableClasses(el) {
181
+ const all = Array.prototype.slice.call(el.classList);
182
+ const out = [];
183
+ for (const name of all) {
184
+ if (isNoiseClass(name)) continue;
185
+ if (extendsSiblingClass(name, all)) continue;
186
+ out.push(name);
187
+ if (out.length === 3) break;
188
+ }
189
+ return out;
190
+ }
191
+
192
+ function escapeIdent(value) {
193
+ if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') return CSS.escape(value);
194
+ return String(value).replace(/([^\w-])/g, '\\$1');
195
+ }
196
+
197
+ /** 1-based index among same-tag siblings, or null when the tag is unique in its parent. */
198
+ function nthOfType(el) {
199
+ const parent = el.parentElement;
200
+ if (!parent) return null;
201
+ let index = 0;
202
+ let seen = 0;
203
+ for (const sibling of parent.children) {
204
+ if (sibling.tagName !== el.tagName) continue;
205
+ seen += 1;
206
+ if (sibling === el) index = seen;
207
+ }
208
+ return seen > 1 ? index : null;
209
+ }
210
+
211
+ /** Full positional path — the fallback that always describes exactly one node. */
212
+ function nthChildPath(el) {
213
+ const parts = [];
214
+ const root = el.ownerDocument.documentElement;
215
+ let node = el;
216
+ while (node && node.nodeType === 1 && node !== root) {
217
+ const parent = node.parentElement;
218
+ if (!parent) break;
219
+ const index = Array.prototype.indexOf.call(parent.children, node) + 1;
220
+ parts.unshift(`${node.tagName.toLowerCase()}:nth-child(${index})`);
221
+ node = parent;
222
+ }
223
+ return parts.length ? `html > ${parts.join(' > ')}` : '';
224
+ }
225
+
226
+ function matchesExactly(selector, el) {
227
+ if (!selector) return false;
228
+ try {
229
+ // The element's OWN document, not this one. Inside the component lab the element being
230
+ // described lives in a story iframe, and a selector verified against the shell around it
231
+ // would be verified against a document it will never be run in.
232
+ const found = el.ownerDocument.querySelectorAll(selector);
233
+ return found.length === 1 && found[0] === el;
234
+ } catch {
235
+ return false;
236
+ }
237
+ }
238
+
239
+ /** How many nodes a selector finds in `doc`, or -1 when it does not parse. */
240
+ function countMatches(selector, doc) {
241
+ if (!selector) return -1;
242
+ try {
243
+ return doc.querySelectorAll(selector).length;
244
+ } catch {
245
+ return -1;
246
+ }
247
+ }
248
+
249
+ /**
250
+ * One `-`/`_`/`.`/`:`-separated piece of an id that a human plausibly typed: a word, a small
251
+ * counter, or a word with a small counter welded on (`h1`, `col2`, `step3`).
252
+ */
253
+ const HUMAN_ID_SEGMENT = /^(?:[A-Za-z]{1,20}|\d{1,3}|[A-Za-z]{1,5}\d{1,3})$/;
254
+
255
+ /**
256
+ * Whether an id is safe to anchor a selector on — which is NOT the same question as whether it
257
+ * is unique right now. Swiper mints `id="swiper-wrapper-<random hex>"` when it initialises and
258
+ * mints a DIFFERENT one on every page load, so an id can be perfectly unique at pick time and
259
+ * name nothing at all ten seconds later. Measured on this site 2026-09-21: the same wrapper came
260
+ * back as `…2a2469caeb1c8a14`, then `…54d9ec0d44549cd5`, then `…78419b56f1752d81` on three
261
+ * consecutive loads of `/`.
262
+ *
263
+ * The test is an ALLOWLIST of authored shapes rather than a blocklist of generated ones, and
264
+ * that direction is the whole design:
265
+ * · a blocklist has to predict the next library's garbage — ban "swiper" and React's `:r0:`,
266
+ * Radix's `radix-:r3:`, Emotion's `css-1q2w3e` and MUI's hashes all still walk through, and
267
+ * each one emits a confident dead selector, which is the defect this exists to stop;
268
+ * · an allowlist only has to describe what a person types — words, separators, small counters
269
+ * — and everything else falls through to the structural path, which cssPathOf verifies
270
+ * against the live document anyway.
271
+ * · The two error costs are not symmetric. Refusing a real id costs a longer selector.
272
+ * Trusting a generated one costs the ticket: PIN-005 carried
273
+ * `#swiper-wrapper-884c31f1c76fd5de > … > blockquote`, resolved to one node when written and
274
+ * zero on every load after, and nothing on the ticket said so.
275
+ *
276
+ * Measured against every id this site actually emits (7 routes, two loads each): `navbar-mega`,
277
+ * `main` and `services` pass; `swiper-wrapper-<16 hex>` is the only id that fails, and it is the
278
+ * only one that changed between loads. No false positives, no false negatives, on real data.
279
+ *
280
+ * What it does NOT catch, stated plainly rather than papered over: framework COUNTER ids —
281
+ * `mui-1`, `cdk-overlay-0`, `ember42` — are word-plus-small-number, which is exactly what a
282
+ * person types, so no shape test can separate them from `faq-item-3`. They are unstable all the
283
+ * same. That residue is why shot.mjs re-counts every selector on a genuinely fresh load and
284
+ * writes the answer into the ticket: this function lowers the failure rate, the fresh-load count
285
+ * makes whatever is left visible instead of silent.
286
+ */
287
+ function isTrustworthyId(id) {
288
+ const raw = String(id ?? '');
289
+ if (!raw || raw.length > 60) return false;
290
+ // Split without `+` so a leading, trailing or doubled separator leaves an empty segment and
291
+ // is rejected — `:r0:` and `__nuxt` are framework markers, not names anyone chose.
292
+ for (const segment of raw.split(/[-_.:]/)) {
293
+ if (!HUMAN_ID_SEGMENT.test(segment)) return false;
294
+ // A pure-letter segment drawn only from the hex alphabet is a hash that got lucky
295
+ // (`deadbeefcafe`). Real words this long inside a-f do not occur; `facade` is six.
296
+ if (segment.length >= 8 && /^[a-f]+$/i.test(segment)) return false;
297
+ }
298
+ return true;
299
+ }
300
+
301
+ /**
302
+ * Every way ONE node can name itself, shortest first: `tag.class.class`, then the same with
303
+ * `:nth-of-type(n)` welded on. A unique, trustworthy id replaces both and ends the walk, because
304
+ * nothing above a document-unique id can narrow anything below it.
305
+ *
306
+ * `anchored` is that fact, returned rather than re-derived: the search reads it to know it has
307
+ * hit the ceiling.
308
+ */
309
+ function partCandidates(node) {
310
+ const id = node.getAttribute && node.getAttribute('id');
311
+ if (id && isTrustworthyId(id) && matchesExactly(`#${escapeIdent(id)}`, node)) {
312
+ return { parts: [`#${escapeIdent(id)}`], anchored: true };
313
+ }
314
+ let base = node.tagName.toLowerCase();
315
+ for (const cls of stableClasses(node)) base += `.${escapeIdent(cls)}`;
316
+ const parts = [base];
317
+ const nth = nthOfType(node);
318
+ if (nth !== null) parts.push(`${base}:nth-of-type(${nth})`);
319
+ return { parts, anchored: false };
320
+ }
321
+
322
+ /** How far up the search will look for a narrowing ancestor before it gives up and falls back. */
323
+ const MAX_SELECTOR_HOPS = 12;
324
+
325
+ /** How many levels the selector may name at once. Each one doubles the combinations tried. */
326
+ const MAX_SELECTOR_LEVELS = 4;
327
+
328
+ /**
329
+ * Every selector a chain of levels (outermost first) can spell, FEWEST QUALIFIERS FIRST.
330
+ *
331
+ * The ordering is the point, and it is what makes an ancestor pay for itself twice. Retaining
332
+ * `a.navbar__card:nth-of-type(3)` also settles which `span.navbar__card-arrow` is meant, so the
333
+ * span no longer needs its own `:nth-of-type(1)` — but only a search that re-tries the span's
334
+ * plain form AFTER the ancestor is in hand will ever find that out. Cost is the number of levels
335
+ * spelled with a positional qualifier; ties break on the shorter string.
336
+ */
337
+ function selectorCombos(levels) {
338
+ let out = [{ text: '', cost: 0 }];
339
+ for (const level of levels) {
340
+ const next = [];
341
+ for (const acc of out) {
342
+ for (let index = 0; index < level.parts.length; index++) {
343
+ next.push({
344
+ text: acc.text ? `${acc.text} ${level.parts[index]}` : level.parts[index],
345
+ cost: acc.cost + index,
346
+ });
347
+ }
348
+ }
349
+ out = next;
350
+ }
351
+ out.sort((a, b) => a.cost - b.cost || a.text.length - b.text.length);
352
+ return out.map((combo) => combo.text);
353
+ }
354
+
355
+ /** The most specific spelling of a level chain — the one with the fewest possible matches. */
356
+ function mostSpecific(levels) {
357
+ return levels.map((level) => level.parts[level.parts.length - 1]).join(' ');
358
+ }
359
+
360
+ /**
361
+ * The SHORTEST readable CSS selector that resolves to exactly this element — nothing more.
362
+ *
363
+ * Until 2026-09-22 this built the full ancestor chain down from the nearest trustworthy id and
364
+ * welded `:nth-of-type(n)` onto every step that had same-tag siblings, whether or not either was
365
+ * needed. The pseudo-classes turned out not to be necessary at all: of the 1,809 elements
366
+ * sampled across six routes, 96.6% carried an `:nth-of-type` and the average selector ran 175
367
+ * characters.
368
+ *
369
+ * Length was the visible half. The invisible half is that **both additions make the selector
370
+ * more fragile, not less**. `section.statement:nth-of-type(2)` stops resolving the moment a
371
+ * sibling section is added above it; `.statement__story`, which names the same node, does not
372
+ * care. Every ancestor in the chain is one more node whose class or position can move. A
373
+ * qualifier is worth its fragility only when it is doing work.
374
+ *
375
+ * So the search builds up instead of stripping down, and each step is verified before it is
376
+ * accepted:
377
+ *
378
+ * 1. **The element alone** — `div.statement__story`, then `…:nth-of-type(n)` if that was
379
+ * ambiguous. One of these ends it for most elements on a component-built site, because a
380
+ * BEM class is already unique.
381
+ * 2. **Climb for a NARROWING ancestor**, nearest first. An ancestor is prepended (as a
382
+ * descendant, ` `, not a child, `>`, so the intermediate nodes can be skipped entirely)
383
+ * only when it actually cuts the number of matches. A wrapper div that narrows nothing is
384
+ * exactly the noise this exists to drop, and it is dropped.
385
+ * 3. **Stop at a unique id**, for the reason in partCandidates.
386
+ * 4. **Fall back to the positional path** when even that is ambiguous — unchanged, and still
387
+ * the thing that always describes one node.
388
+ *
389
+ * Shortening is only safe because the verification it inherits is real: every candidate is run
390
+ * against the live document with `matchesExactly` — one node, and that node is this one — before
391
+ * it is returned. It is a search over verified candidates, not a guess at a prettier string.
392
+ *
393
+ * The element that prompted the question is the worked example — 118 characters to 21, naming
394
+ * the same node:
395
+ *
396
+ * before #main > section.section.statement:nth-of-type(2) >
397
+ * div.container.statement__inner > div.statement__story:nth-of-type(1)
398
+ * after div.statement__story
399
+ *
400
+ * Measured 2026-09-22 against the old builder over 1,809 elements sampled from six routes (`/`,
401
+ * `/about-us`, `/faqs`, `/payment-and-insurance`, `/individual-services/individual-therapy`,
402
+ * `/evaluative-services/forensic-evaluations`). The method, because a number is worth only the
403
+ * run behind it: build a selector for every sampled element on one load, then count each one
404
+ * with `querySelectorAll` on a GENUINELY fresh load in a new browser context — never on the DOM
405
+ * it was built against, which always flatters it.
406
+ *
407
+ * dead (0 matches on reload) 0 → 0 no selector got worse
408
+ * ambiguous (>1 on reload) 0 → 0
409
+ * average length 175 → 57 characters
410
+ * average parts 5.9 → 2.2
411
+ * carrying an :nth-of-type 96.6% → 63.5%
412
+ *
413
+ * That last figure is the honest one: two thirds of elements still take a positional qualifier,
414
+ * and on inspection they have earned it — `a.navbar__card:nth-of-type(3)` is the third of four
415
+ * identical cards, and no class tells them apart. What has gone is the *redundant* qualifier:
416
+ * `span.navbar__card-arrow` inside that card no longer repeats the index its ancestor already
417
+ * settled, which is why the combination search tries the plain form again after an ancestor is
418
+ * retained rather than only before.
419
+ *
420
+ * The carousel case separately, since a Swiper selector is the one that historically died: 80
421
+ * elements inside `.swiper` on `/`, selectors built with the carousel ADVANCED two slides, then
422
+ * counted on a fresh un-advanced load — 0 dead and 0 ambiguous, before and after.
423
+ */
424
+ function cssPathOf(el) {
425
+ if (!el || el.nodeType !== 1) return '';
426
+ const doc = el.ownerDocument;
427
+ const root = doc.documentElement;
428
+
429
+ let levels = [partCandidates(el)];
430
+ for (const candidate of selectorCombos(levels)) if (matchesExactly(candidate, el)) return candidate;
431
+
432
+ let matches = countMatches(mostSpecific(levels), doc);
433
+ let hops = 0;
434
+ for (
435
+ let node = el.parentElement;
436
+ node && node !== root && hops < MAX_SELECTOR_HOPS;
437
+ node = node.parentElement, hops++
438
+ ) {
439
+ const ancestor = partCandidates(node);
440
+ const trial = [ancestor].concat(levels);
441
+ for (const candidate of selectorCombos(trial)) if (matchesExactly(candidate, el)) return candidate;
442
+
443
+ // Keep the ancestor only when it actually cut the field. A wrapper div that narrows
444
+ // nothing is exactly the noise this search exists to drop, so it is dropped and the
445
+ // walk carries on past it.
446
+ const count = countMatches(mostSpecific(trial), doc);
447
+ if (count > 0 && count < matches) {
448
+ levels = trial;
449
+ matches = count;
450
+ }
451
+ if (ancestor.anchored) break;
452
+ if (levels.length >= MAX_SELECTOR_LEVELS) break;
453
+ }
454
+
455
+ const best = mostSpecific(levels);
456
+ if (matchesExactly(best, el)) return best;
457
+ const positional = nthChildPath(el);
458
+ if (matchesExactly(positional, el)) return positional;
459
+ return best || positional;
460
+ }
461
+
462
+ /**
463
+ * The element's box in DOCUMENT coordinates — getBoundingClientRect() is viewport-relative,
464
+ * so the page's scroll offset has to be added back. The contract's `rect` and Playwright's
465
+ * screenshot `clip` are both document-relative, which is why this must not be skipped and
466
+ * why the server must not add `scroll` to it a second time.
467
+ */
468
+ function docRect(el, scrollX, scrollY) {
469
+ const r = el.getBoundingClientRect();
470
+ return {
471
+ x: Math.round(r.left + scrollX),
472
+ y: Math.round(r.top + scrollY),
473
+ width: Math.round(r.width),
474
+ height: Math.round(r.height),
475
+ };
476
+ }
477
+
478
+ /** True for a path that would leak a machine-local location into a ticket. */
479
+ function looksAbsolute(value) {
480
+ return /^\//.test(value) || /^[A-Za-z]:[\\/]/.test(value) || /^file:/i.test(value);
481
+ }
482
+
483
+ /* ------------------------------------------------------------------ frames */
484
+
485
+ /**
486
+ * The component lab renders every story inside an <iframe> of `/lab/stories/<id>` — one in the
487
+ * viewport configurator (`#vp-frame`, astro-lab/src/shell/Viewport.astro) and one per card in the
488
+ * grid (CardGrid.astro). This toolbar lives in the PARENT document, so until 2026-09-21 a click
489
+ * on a story landed on the iframe ELEMENT and the ticket described the shell instead of the
490
+ * thing that was pointed at. Reproduced that day as PIN-005: `selector: '#vp-frame'`,
491
+ * `source: unresolved`, `url: /lab/viewport/…`, a 1440x900 rect and the iframe's own tag as
492
+ * `outer_html`. Nothing in it names an element.
493
+ *
494
+ * Every lab frame is same-origin — same dev server, same port — so `contentDocument` is readable
495
+ * and the hit test simply keeps going. Three things travel with it, and the third is the one that
496
+ * fails silently if it is got wrong:
497
+ *
498
+ * · the POINT goes IN, through whatever scale the parent applied. The configurator scales a
499
+ * wrapper DIV around the frame; the card grid scales the frame itself. Neither is special-cased.
500
+ * · the GEOMETRY comes back OUT, because the highlight and the chip are painted in the TOP
501
+ * document and have to land over the right pixels there.
502
+ * · the TICKET describes the INNER document and nothing else — its url, its viewport, its
503
+ * scroll, its document-coordinate rect. `shot.mjs` re-loads `url` and clips `rect` against
504
+ * THAT page, so a ticket carrying the shell's url and the story's rect photographs the wrong
505
+ * document and leaves a clean trail behind it. Taking all four from `el.ownerDocument`'s own
506
+ * window is what makes the parent's transform structurally irrelevant: a scaled iframe does
507
+ * not change its content window's `innerWidth`, and a rect measured inside the frame never
508
+ * saw the transform at all.
509
+ *
510
+ * A frame this cannot see into is not an error. It is picked as an element in its own right and
511
+ * the reason is said out loud in the panel.
512
+ */
513
+
514
+ /** The frame's document, or null when it is cross-origin, empty, or not loaded yet. */
515
+ function frameDocument(frame) {
516
+ try {
517
+ const doc = frame.contentDocument;
518
+ return doc && doc.documentElement ? doc : null;
519
+ } catch {
520
+ return null; // cross-origin: touching contentDocument throws
521
+ }
522
+ }
523
+
524
+ /**
525
+ * Whether every transform between `el` and its document root is a plain scale and/or translate.
526
+ * A rotation, skew, mirror, or 3D transform cannot be undone by dividing by a ratio of widths, so
527
+ * a frame under one is picked as an element rather than entered with geometry that would be
528
+ * quietly wrong.
529
+ */
530
+ function isAxisAligned(el) {
531
+ const win = el.ownerDocument && el.ownerDocument.defaultView;
532
+ if (!win) return false;
533
+ for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
534
+ const value = win.getComputedStyle(node).transform;
535
+ if (!value || value === 'none') continue;
536
+ let m;
537
+ try {
538
+ m = new win.DOMMatrixReadOnly(value);
539
+ } catch {
540
+ return false;
541
+ }
542
+ if (!m.is2D) return false;
543
+ if (Math.abs(m.b) > 1e-6 || Math.abs(m.c) > 1e-6) return false;
544
+ if (m.a <= 0 || m.d <= 0) return false;
545
+ }
546
+ return true;
547
+ }
548
+
549
+ /**
550
+ * How a point in the PARENT's viewport maps into this frame's own viewport.
551
+ *
552
+ * `getBoundingClientRect()` is the VISUAL box — every transform between the frame and the root
553
+ * has already been applied to it — while `offsetWidth`/`offsetHeight` is the LAYOUT box, which a
554
+ * transform never touches. Their ratio is therefore the total accumulated scale, wherever in the
555
+ * ancestor chain it was applied: the configurator scales `.vp__wrapper` around the frame and the
556
+ * card grid scales the frame itself, and this reads both without knowing which.
557
+ *
558
+ * Border and padding are removed in VISUAL px (they are layout px, so they scale too), because
559
+ * the frame's content box — not its border box — is what its viewport coordinates start from.
560
+ */
561
+ function frameGeometry(frame) {
562
+ const rect = frame.getBoundingClientRect();
563
+ const ow = frame.offsetWidth;
564
+ const oh = frame.offsetHeight;
565
+ if (!ow || !oh || !rect.width || !rect.height) return null;
566
+ if (!isAxisAligned(frame)) return null;
567
+ const win = frame.ownerDocument.defaultView;
568
+ const style = win.getComputedStyle(frame);
569
+ const scaleX = rect.width / ow;
570
+ const scaleY = rect.height / oh;
571
+ const px = (name) => parseFloat(style[name]) || 0;
572
+ return {
573
+ scaleX,
574
+ scaleY,
575
+ left: rect.left + (px('borderLeftWidth') + px('paddingLeft')) * scaleX,
576
+ top: rect.top + (px('borderTopWidth') + px('paddingTop')) * scaleY,
577
+ };
578
+ }
579
+
580
+ /**
581
+ * Live geometry for a whole chain of frames, outermost first. Recomputed on every use rather
582
+ * than cached, because a scroll or a resize anywhere in the chain moves all of it.
583
+ * @returns {Array|null} null when any hop has gone away or stopped being translatable.
584
+ */
585
+ function chainGeometry(frames) {
586
+ const geos = [];
587
+ for (const frame of frames) {
588
+ if (!frame || !frame.isConnected) return null;
589
+ const geo = frameGeometry(frame);
590
+ if (!geo) return null;
591
+ geos.push(geo);
592
+ }
593
+ return geos;
594
+ }
595
+
596
+ /** A rect in the innermost frame's viewport coordinates, expressed in the TOP document's. */
597
+ function outward(geos, rect) {
598
+ let left = rect.left;
599
+ let top = rect.top;
600
+ let width = rect.width;
601
+ let height = rect.height;
602
+ for (let i = geos.length - 1; i >= 0; i--) {
603
+ const g = geos[i];
604
+ left = g.left + left * g.scaleX;
605
+ top = g.top + top * g.scaleY;
606
+ width *= g.scaleX;
607
+ height *= g.scaleY;
608
+ }
609
+ return { left, top, width, height, right: left + width, bottom: top + height };
610
+ }
611
+
612
+ /** `el`'s box in the TOP document's viewport coordinates, or null when the chain has broken. */
613
+ function viewportRect(el, frames) {
614
+ const geos = chainGeometry(frames || []);
615
+ if (!geos) return null;
616
+ return outward(geos, el.getBoundingClientRect());
617
+ }
618
+
619
+ /**
620
+ * The iframe to descend into for a point that hit `el`, or null.
621
+ *
622
+ * Two shapes, because the lab has both. The configurator's `#vp-frame` takes pointer events, so
623
+ * it arrives as `el` itself. The grid's thumbnails carry `pointer-events: none` (CardGrid.astro,
624
+ * so the card stays one link), which means `elementsFromPoint` never returns them at all and the
625
+ * hit is the `.lab-card__thumb` wrapper — the frame has to be found INSIDE what was hit. Only a
626
+ * descendant counts: a pill drawn over a thumbnail is a pill, not the story underneath it.
627
+ */
628
+ function frameUnder(el, x, y) {
629
+ if (el.tagName === 'IFRAME' || el.tagName === 'FRAME') return el;
630
+ if (!el.getElementsByTagName) return null;
631
+ for (const frame of el.getElementsByTagName('iframe')) {
632
+ const r = frame.getBoundingClientRect();
633
+ if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return frame;
634
+ }
635
+ return null;
636
+ }
637
+
638
+ /** The path of the document `el` actually lives in — the story's, not the shell's. */
639
+ function documentPath(el) {
640
+ const win = el.ownerDocument && el.ownerDocument.defaultView;
641
+ if (!win) return '';
642
+ return win.location.pathname + win.location.search;
643
+ }
644
+
645
+ /**
646
+ * Walk up to the nearest ancestor stamped by the source-stamp Vite plugin.
647
+ * Re-read from the live DOM on every pick: another toolbar app may have mutated the tree
648
+ * since the last look (the built-in audit app strips `data-astro-source-file` and
649
+ * `data-astro-source-loc` in apps/audit/annotations.js:12-16 — it does not touch this
650
+ * attribute, but nothing guarantees the next app is as polite).
651
+ */
652
+ function resolveSource(el) {
653
+ if (!el || typeof el.closest !== 'function') return { source: null, error: null };
654
+ const holder = el.closest(`[${SRC_ATTR}]`);
655
+ if (!holder) return { source: null, error: null };
656
+ const raw = (holder.getAttribute(SRC_ATTR) || '').trim();
657
+ if (!raw) return { source: null, error: null };
658
+ if (looksAbsolute(raw)) {
659
+ return {
660
+ source: null,
661
+ error: `The source attribute on <${holder.tagName.toLowerCase()}> is an absolute path. Refusing to put it in a ticket.`,
662
+ };
663
+ }
664
+ return { source: raw, error: null };
665
+ }
666
+
667
+ function truncate(value, limit) {
668
+ const text = String(value ?? '');
669
+ return text.length > limit ? text.slice(0, limit) : text;
670
+ }
671
+
672
+
673
+ /* ------------------------------------------------------------ the ticket panel: the contract */
674
+
675
+ /**
676
+ * Where the board is served — the panel fetches it for the ticket list, and the head's "Board ↗"
677
+ * button opens it.
678
+ *
679
+ * It is the RESOLVED `route` option, delivered by `virtual:orbytes-pin/config.mjs` (› index.mjs,
680
+ * `pinConfigPlugin`). It was the literal `'/pin'` until 2026-09-22, which was wrong for any site
681
+ * that set `pin: { route: "/__pin" }` — the panel fetched a path the middleware does not serve and
682
+ * showed an empty list, and the new button would have opened the same empty tab. The fallback
683
+ * is the documented default, for the one case the virtual module cannot
684
+ * cover: this file imported directly, outside the integration that generates it.
685
+ */
686
+ const BOARD_ROUTE = (pinConfig && pinConfig.route) || '/pin';
687
+
688
+ /**
689
+ * The status vocabulary, ruled 2026-09-22. Six values, in this order:
690
+ *
691
+ * Backlog an agent should NOT pick this up yet
692
+ * Ready for agent replaces `To Do` — an agent may start
693
+ * In Progress
694
+ * Ready for review an agent has finished; this is THE HUMAN REVIEW QUEUE
695
+ * Resolved a human has accepted it. Only a human ever sets it — no agent, ever.
696
+ * Cancelled an archive. It must not take up space or override anything.
697
+ *
698
+ * The panel has exactly TWO tabs, which is fixed and not a preference, so the six map onto them:
699
+ * **Open is everything that is not `Resolved` and not `Cancelled`**, `Resolved` is its own tab, and
700
+ * `Cancelled` appears in neither and draws no marker — drawing an archived ticket on the page is
701
+ * precisely the "taking up space" the status exists to stop. It stays reachable on `/pin`.
702
+ *
703
+ * These names are for CLASSIFYING and LABELLING only. Every write sends the status string the
704
+ * board itself reported (› `setStatus`), never a string from this list, because `expect` is
705
+ * compared against what is on disk and the two vocabularies are mid-migration: `tickets.mjs` is
706
+ * the owner of `STATUSES` and this panel must not race it.
707
+ */
708
+ const STATUS_RESOLVED = 'Resolved';
709
+
710
+ /** Statuses written before 2026-09-22, and what they read as today. Never emitted. */
711
+ const LEGACY_STATUS = Object.freeze({ 'to do': 'Ready for agent', done: STATUS_RESOLVED });
712
+
713
+ /**
714
+ * Where "Reopen" sends a ticket, most-wanted first. Reopening means "this needs more work", so it
715
+ * lands back in the agent's lane rather than in the human review queue.
716
+ *
717
+ * It is a PREFERENCE LIST rather than one value because the target is chosen against the statuses
718
+ * the board actually reports (› `ticketsFromBoardHtml`), not against this file's idea of them. The
719
+ * write endpoint refuses any status outside `STATUSES`, and `STATUSES` lives in a file this panel
720
+ * does not own and is being changed in parallel — so picking from what the board renders is the
721
+ * only way this control cannot break the moment the vocabulary moves under it.
722
+ */
723
+ const REOPEN_PREFERENCE = ['Ready for agent', 'To Do', 'Backlog', 'In Progress'];
724
+
725
+ /** Per-viewer, survives a reload, degrades silently. Not shared, not in a ticket. */
726
+ const SHOW_RESOLVED_KEY = 'orbytes-pin:show-resolved-markers';
727
+
728
+ /** The marker glyph's box, in CSS px. The tip is the bottom centre of the viewBox. */
729
+ const MARKER_W = 26;
730
+ const MARKER_H = 34;
731
+
732
+ /** A status as this panel classifies it — one word, from any of the vocabularies in play. */
733
+ function statusKind(raw) {
734
+ const s = String(raw ?? '').trim().toLowerCase();
735
+ if (s === 'resolved' || s === 'done') return 'resolved';
736
+ if (s === 'cancelled' || s === 'canceled') return 'cancelled';
737
+ if (s === 'ready for review') return 'review';
738
+ if (s === 'ready for agent' || s === 'to do' || s === 'todo') return 'agent';
739
+ if (s === 'backlog') return 'backlog';
740
+ if (s === 'in progress') return 'progress';
741
+ return 'other';
742
+ }
743
+
744
+ /** The status in today's words. A legacy value is SHOWN as what it reads as, never rewritten. */
745
+ function statusLabel(raw) {
746
+ const s = String(raw ?? '').trim();
747
+ return LEGACY_STATUS[s.toLowerCase()] ?? (s || 'No status');
748
+ }
749
+
750
+ const isResolvedStatus = (raw) => statusKind(raw) === 'resolved';
751
+ const isCancelledStatus = (raw) => statusKind(raw) === 'cancelled';
752
+
753
+ /**
754
+ * Open is a NEGATIVE test on purpose — not `Resolved`, not `Cancelled` — so a status nobody here
755
+ * has heard of still appears somewhere rather than vanishing. The one failure this panel must not
756
+ * have is a ticket that exists on disk and is on no tab.
757
+ */
758
+ const isOpenStatus = (raw) => !isResolvedStatus(raw) && !isCancelledStatus(raw);
759
+
760
+ /** Where a status sorts inside the Open tab: the review queue first, then work not yet begun. */
761
+ const OPEN_ORDER = { review: 0, progress: 1, agent: 2, backlog: 3, other: 4 };
762
+
763
+ /**
764
+ * The tickets, read from the board's own machine-readable payload.
765
+ *
766
+ * This panel does NOT parse a ticket file. `collectTickets`/`parseTicket` (board.mjs, tickets.mjs)
767
+ * are the reader, they run on the server, and `renderBoard` already serialises each ticket it read
768
+ * into a `<script type="application/json" class="card-data">` block beside its card — that block is
769
+ * what this reads. A second markdown parser in the browser is how three earlier bugs in this system
770
+ * happened; there is no read endpoint to use instead, so the board's own emission is the nearest
771
+ * thing to one, and it is generated from the same call the board draws itself from.
772
+ *
773
+ * Two other things come out of the same document rather than being hardcoded here:
774
+ * · `apiPath` — the write endpoint, from `[data-board] data-api`. The board only carries it when
775
+ * it is writable, so its absence is the honest answer to "can this surface write?".
776
+ * · `statuses` — every status the board renders a column for, which is the live vocabulary from
777
+ * `STATUSES` including anything a ticket carries that is not in it.
778
+ *
779
+ * Refuses rather than warns: cards present with no payload means the board changed shape, and a
780
+ * silently short list would be worse than a sentence saying so.
781
+ */
782
+ function ticketsFromBoardHtml(html, route = BOARD_ROUTE) {
783
+ const doc = new DOMParser().parseFromString(String(html ?? ''), 'text/html');
784
+ const board = doc.querySelector('[data-board]');
785
+ const cards = doc.querySelectorAll('[data-card]');
786
+ const blocks = doc.querySelectorAll('script.card-data');
787
+ if (!board && !cards.length && !doc.querySelector('.none, .broken')) {
788
+ throw new Error(`${route} did not answer with the pin board — is the orbytes-pin integration loaded?`);
789
+ }
790
+ if (cards.length && !blocks.length) {
791
+ throw new Error(
792
+ `the board no longer puts a card-data payload beside each card, which is where this panel reads tickets from — the panel needs updating alongside ${route}.`,
793
+ );
794
+ }
795
+ const tickets = [];
796
+ for (const block of blocks) {
797
+ try {
798
+ tickets.push(JSON.parse(block.textContent || ''));
799
+ } catch (error) {
800
+ throw new Error(`a card on ${route} carried a payload this panel could not read: ${error && error.message}`);
801
+ }
802
+ }
803
+ const statuses = Array.prototype.map
804
+ .call(doc.querySelectorAll('[data-col][data-status]'), (col) => col.getAttribute('data-status'))
805
+ .filter(Boolean);
806
+ const broken = Array.prototype.map.call(doc.querySelectorAll('.broken li code'), (el) => el.textContent);
807
+ return { tickets, apiPath: board ? board.getAttribute('data-api') : null, statuses, broken };
808
+ }
809
+
810
+ /** `/a/` and `/a` are the same page; a query string is part of the address and is not dropped. */
811
+ function normalisePath(value) {
812
+ const raw = String(value ?? '');
813
+ if (!raw) return '';
814
+ const cut = raw.indexOf('?');
815
+ const path = cut === -1 ? raw : raw.slice(0, cut);
816
+ const query = cut === -1 ? '' : raw.slice(cut);
817
+ return (path.length > 1 ? path.replace(/\/+$/, '') : path) + query;
818
+ }
819
+
820
+ const samePath = (a, b) => normalisePath(a) === normalisePath(b);
821
+
822
+ /** `3d`, `4h`, `now` — short enough for a row, from a `YYYY-MM-DD` or any parseable date. */
823
+ function ageLabel(value, now = Date.now()) {
824
+ const at = Date.parse(String(value ?? ''));
825
+ if (!Number.isFinite(at)) return '';
826
+ const seconds = Math.max(0, (now - at) / 1000);
827
+ if (seconds < 90) return 'now';
828
+ const minutes = seconds / 60;
829
+ if (minutes < 60) return `${Math.round(minutes)}m`;
830
+ const hours = minutes / 60;
831
+ if (hours < 24) return `${Math.round(hours)}h`;
832
+ const days = hours / 24;
833
+ if (days < 7) return `${Math.round(days)}d`;
834
+ const weeks = days / 7;
835
+ if (weeks < 6) return `${Math.round(weeks)}w`;
836
+ return `${Math.round(days / 30)}mo`;
837
+ }
838
+
839
+ /** `PIN-007` → `7`, for the number drawn inside a marker. */
840
+ function ticketNumber(id) {
841
+ const m = /(\d+)/.exec(String(id ?? ''));
842
+ return m ? String(Number(m[1])) : '';
843
+ }
844
+
845
+ /* ------------------------------------------------------------------ the app */
846
+
847
+ export default defineToolbarApp({
848
+ init(canvas, app, server) {
849
+ /** Every shadow host between the pick surface and the document, so the hit test can
850
+ * ignore this app's own chrome. `document.elementsFromPoint` retargets shadow content
851
+ * to its host, so skipping the hosts skips the surface, the window, the highlights,
852
+ * the chip and the toolbar bar in one test. */
853
+ const ownHosts = [];
854
+ {
855
+ let root = canvas;
856
+ while (root && root.host) {
857
+ ownHosts.push(root.host);
858
+ root = root.host.getRootNode ? root.host.getRootNode() : null;
859
+ }
860
+ }
861
+
862
+ let mode = 'idle'; // idle | picking | selected
863
+ let hoverEl = null;
864
+ let selectedEl = null;
865
+ /**
866
+ * The climb. `chain` is what was clicked at index 0 and each ancestor above it after
867
+ * that, stopping short of <body>; `climbIndex` says which one the pin currently
868
+ * describes, and `selectedEl` is always `chain[climbIndex]`.
869
+ *
870
+ * Index 0 is kept as an ELEMENT, not as a selector or a remembered index, because
871
+ * climbing back down has to land on the thing actually pointed at — a button among
872
+ * four identical ones is not recoverable from a description of where it was.
873
+ */
874
+ let chain = [];
875
+ let climbIndex = 0;
876
+ // The chain of same-origin iframes each of those lives inside, outermost first. Empty on
877
+ // an ordinary page; one deep in the lab's viewport configurator and card grid.
878
+ let hoverFrames = [];
879
+ let selectedFrames = [];
880
+ let busy = false;
881
+ let replyTimer = null;
882
+ let swallowEscapeKeyup = false;
883
+ let rafPending = false;
884
+ let lastPointer = { x: 0, y: 0 };
885
+ /** The timer that clears a transient acknowledgement. Null while a failure is on screen. */
886
+ let ackTimer = null;
887
+
888
+ /* ---------- chrome ---------- */
889
+
890
+ const surface = document.createElement('div');
891
+ surface.setAttribute('data-orbytes-pin-ui', 'surface');
892
+ Object.assign(surface.style, {
893
+ position: 'fixed',
894
+ inset: '0',
895
+ zIndex: String(Z.surface),
896
+ cursor: 'crosshair',
897
+ background: 'transparent',
898
+ });
899
+
900
+ const hoverHighlight = makeHighlight(Z.hover, 'blue');
901
+ const selectedHighlight = makeHighlight(Z.selected, 'purple');
902
+ const chip = makeChip();
903
+
904
+ function makeHighlight(zIndex, style) {
905
+ const el = createHighlight({ top: 0, left: 0, width: 0, height: 0 });
906
+ el.highlightStyle = style;
907
+ el.tabIndex = -1;
908
+ el.style.display = 'none';
909
+ el.style.zIndex = String(zIndex);
910
+ // createHighlight() sets tabIndex 0 and leaves pointer events on, which would let
911
+ // the highlight sit between the cursor and the pick surface and freeze hovering.
912
+ el.style.pointerEvents = 'none';
913
+ return el;
914
+ }
915
+
916
+ function makeChip() {
917
+ const el = document.createElement('div');
918
+ el.setAttribute('data-orbytes-pin-ui', 'chip');
919
+ Object.assign(el.style, {
920
+ position: 'absolute',
921
+ display: 'none',
922
+ zIndex: String(Z.chip),
923
+ pointerEvents: 'none',
924
+ maxWidth: '60vw',
925
+ overflow: 'hidden',
926
+ textOverflow: 'ellipsis',
927
+ whiteSpace: 'nowrap',
928
+ padding: '3px 8px',
929
+ borderRadius: '9999px',
930
+ border: '1px solid rgba(113, 24, 226, 1)',
931
+ background: '#13151A',
932
+ color: '#fff',
933
+ font: '500 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace',
934
+ boxShadow: '0 2px 6px rgba(19, 21, 26, 0.5)',
935
+ });
936
+ return el;
937
+ }
938
+
939
+ const win = createWindowElement(`
940
+ <style>
941
+ [hidden] { display: none !important; }
942
+ .pin { display: flex; flex-direction: column; gap: 12px; overflow: auto; }
943
+ .pin-head { display: flex; align-items: center; gap: 8px; }
944
+ .pin-head h1 {
945
+ margin: 0; font-size: 16px; font-weight: 600; color: #fff;
946
+ display: flex; align-items: center; gap: 8px;
947
+ }
948
+ .pin-head astro-dev-toolbar-icon { width: 1em; height: 1em; display: block; }
949
+ .pin-spacer { flex: 1; }
950
+ .pin-hint { margin: 0; font-size: 13px; line-height: 1.5; }
951
+ .pin-kbd {
952
+ font: 600 11px ui-monospace, SFMono-Regular, Menlo, monospace;
953
+ border: 1px solid rgba(145, 152, 173, 0.6); border-radius: 4px;
954
+ padding: 1px 5px; color: #fff;
955
+ }
956
+ /* One line, always. It scrolls sideways rather than wrapping, because a chain
957
+ that grows a second row pushes the comment box down the panel — and the box
958
+ is the thing being typed or dictated into. */
959
+ .pin-crumbs {
960
+ display: flex; align-items: center; gap: 2px;
961
+ overflow-x: auto; overflow-y: hidden; white-space: nowrap;
962
+ scrollbar-width: thin; padding-bottom: 2px;
963
+ }
964
+ .pin-crumbs[hidden] { display: none; }
965
+ .pin-crumbs button {
966
+ flex: none; cursor: pointer; border-radius: 6px;
967
+ padding: 2px 7px; border: 1px solid transparent; background: transparent;
968
+ color: rgba(145, 152, 173, 1);
969
+ font: 500 11px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace;
970
+ }
971
+ .pin-crumbs button:hover { color: #fff; background: rgba(52, 56, 65, 1); }
972
+ .pin-crumbs button[data-current] {
973
+ color: #fff; background: rgba(113, 24, 226, 0.35);
974
+ border-color: rgba(113, 24, 226, 1);
975
+ }
976
+ .pin-crumbs .sep { flex: none; color: rgba(145, 152, 173, 0.5); font-size: 10px; }
977
+ .pin-target {
978
+ display: flex; flex-direction: column; gap: 4px;
979
+ padding: 8px 10px; border-radius: 8px;
980
+ background: rgba(27, 30, 36, 1); border: 1px solid rgba(52, 56, 65, 1);
981
+ font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
982
+ word-break: break-all;
983
+ }
984
+ .pin-target .src { color: #fff; }
985
+ .pin-target .src[data-unresolved] { color: rgba(255, 236, 179, 1); }
986
+ .pin-target .sel { color: rgba(145, 152, 173, 1); }
987
+ .pin-target .url { color: rgba(145, 152, 173, 1); }
988
+ .pin-target .url::before { content: '@ '; }
989
+ #pin-comment {
990
+ width: 100%; box-sizing: border-box; min-height: 92px; resize: vertical;
991
+ padding: 10px; border-radius: 8px;
992
+ border: 1px solid rgba(52, 56, 65, 1); background: rgba(19, 21, 26, 1);
993
+ color: #fff; font: 14px/1.5 ui-sans-serif, system-ui, sans-serif;
994
+ }
995
+ #pin-comment:focus { outline: 2px solid rgba(113, 24, 226, 1); outline-offset: 1px; }
996
+ .pin-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
997
+ .pin-dispatch {
998
+ display: flex; align-items: center; gap: 8px; font-size: 13px;
999
+ padding: 4px 10px 4px 4px; border-radius: 9999px;
1000
+ border: 1px solid rgba(52, 56, 65, 1);
1001
+ }
1002
+ .pin-dispatch .on { color: #fff; font-weight: 600; }
1003
+ /* ONE line, never a list. The four colours are the ones the running log used, to
1004
+ the value — this replaced the log's behaviour, not its look. */
1005
+ .pin-status {
1006
+ margin: 0; font-size: 12px; line-height: 1.5; cursor: pointer;
1007
+ display: flex; gap: 6px; align-items: baseline;
1008
+ }
1009
+ .pin-status[hidden] { display: none; }
1010
+ .pin-status[data-level="error"] { color: rgba(249, 196, 215, 1); }
1011
+ .pin-status[data-level="warn"] { color: rgba(255, 236, 179, 1); }
1012
+ .pin-status[data-level="ok"] { color: rgba(213, 249, 196, 1); }
1013
+ .pin-status[data-level="info"] { color: rgba(191, 193, 201, 1); }
1014
+ /* A failure stays until it is dismissed or replaced, so it says so. */
1015
+ .pin-status::after {
1016
+ content: '✕'; margin-left: auto; opacity: 0.45; font-size: 11px;
1017
+ }
1018
+ .pin-status:hover::after { opacity: 1; }
1019
+ [data-busy] { opacity: 0.5; pointer-events: none; }
1020
+ </style>
1021
+ <div class="pin">
1022
+ <div class="pin-head">
1023
+ <h1><astro-dev-toolbar-icon icon="file-search"></astro-dev-toolbar-icon>orbytes pin</h1>
1024
+ <span class="pin-spacer"></span>
1025
+ <!-- Two different things, deliberately: "Tickets" toggles the in-page rail
1026
+ over this page, "Board" opens the full kanban at BOARD_ROUTE in its own
1027
+ tab. The tab is where a ticket is dragged between statuses and where a
1028
+ screenshot is looked at full size — which is why there is a way to
1029
+ reach it from here (added 2026-09-22). -->
1030
+ <astro-dev-toolbar-button id="pin-tickets" size="small" button-style="ghost">Tickets</astro-dev-toolbar-button>
1031
+ <astro-dev-toolbar-button id="pin-open-board" size="small" button-style="ghost">Board ↗</astro-dev-toolbar-button>
1032
+ <astro-dev-toolbar-badge id="pin-mode" size="small" badge-style="gray">idle</astro-dev-toolbar-badge>
1033
+ </div>
1034
+
1035
+ <p class="pin-hint" id="pin-hint"></p>
1036
+
1037
+ <section id="pin-form" hidden>
1038
+ <!-- The ancestor chain of what was clicked, outermost first, so it reads in
1039
+ the same direction as the selector underneath it. Clicking one moves the
1040
+ pin to that element; ⌥↑ / ⌥↓ do the same without leaving the keyboard. -->
1041
+ <nav class="pin-crumbs" id="pin-crumbs" aria-label="Ancestors of the picked element" hidden></nav>
1042
+ <div class="pin-target">
1043
+ <span class="src" id="pin-src"></span>
1044
+ <span class="sel" id="pin-sel"></span>
1045
+ <!-- The page the ticket will name, which is the page the screenshot will be
1046
+ taken of. On the lab that is the STORY route inside the frame, not the
1047
+ shell around it, and seeing it is the only way to catch it being wrong
1048
+ before a ticket is written. -->
1049
+ <span class="url" id="pin-url"></span>
1050
+ </div>
1051
+ <textarea id="pin-comment" autocomplete="off" autocapitalize="sentences"
1052
+ placeholder="What is wrong with it? Talk or type — this goes into the ticket verbatim."></textarea>
1053
+ <div class="pin-row">
1054
+ <span class="pin-dispatch">
1055
+ <astro-dev-toolbar-toggle id="pin-dispatch" toggle-style="purple"></astro-dev-toolbar-toggle>
1056
+ <span id="pin-dispatch-label">queue</span>
1057
+ </span>
1058
+ <astro-dev-toolbar-button id="pin-submit" size="small" button-style="purple">Create ticket</astro-dev-toolbar-button>
1059
+ <astro-dev-toolbar-button id="pin-again" size="small" button-style="ghost">Pick another</astro-dev-toolbar-button>
1060
+ <astro-dev-toolbar-button id="pin-cancel" size="small" button-style="ghost">Cancel</astro-dev-toolbar-button>
1061
+ </div>
1062
+ </section>
1063
+
1064
+ <section id="pin-idle" hidden>
1065
+ <astro-dev-toolbar-button id="pin-start" size="small" button-style="purple">Pick an element</astro-dev-toolbar-button>
1066
+ </section>
1067
+
1068
+ <p class="pin-status" id="pin-status" title="Dismiss" hidden></p>
1069
+ </div>
1070
+ `);
1071
+
1072
+ canvas.append(win, hoverHighlight, selectedHighlight, chip);
1073
+ synchronizePlacementOnUpdate(app, canvas);
1074
+
1075
+ const ui = {
1076
+ badge: win.querySelector('#pin-mode'),
1077
+ hint: win.querySelector('#pin-hint'),
1078
+ form: win.querySelector('#pin-form'),
1079
+ idle: win.querySelector('#pin-idle'),
1080
+ crumbs: win.querySelector('#pin-crumbs'),
1081
+ src: win.querySelector('#pin-src'),
1082
+ sel: win.querySelector('#pin-sel'),
1083
+ url: win.querySelector('#pin-url'),
1084
+ comment: win.querySelector('#pin-comment'),
1085
+ dispatch: win.querySelector('#pin-dispatch'),
1086
+ dispatchLabel: win.querySelector('#pin-dispatch-label'),
1087
+ submit: win.querySelector('#pin-submit'),
1088
+ again: win.querySelector('#pin-again'),
1089
+ cancel: win.querySelector('#pin-cancel'),
1090
+ start: win.querySelector('#pin-start'),
1091
+ tickets: win.querySelector('#pin-tickets'),
1092
+ openBoard: win.querySelector('#pin-open-board'),
1093
+ status: win.querySelector('#pin-status'),
1094
+ };
1095
+
1096
+ /* ---------- status ---------- */
1097
+
1098
+ /**
1099
+ * ONE line, not a log — ruled 2026-09-22, when the running log was found redundant and the
1100
+ * one thing worth keeping from it became a button that opens the pin tab. Until then this
1101
+ * was a running four-entry list
1102
+ * that narrated every step — *"Sending to the dev server…"*, *"Selection dropped."*,
1103
+ * *"PIN-002 written to backlog/tasks/…"* — most of it about things that had plainly
1104
+ * worked, and most of it repeating what the badge, the hint line and the target box
1105
+ * already said. Those calls are gone from their call sites, not merely hidden here.
1106
+ *
1107
+ * What survives, and why each is not commentary:
1108
+ *
1109
+ * · A FAILURE — a refused ticket, a screenshot that did not happen, a selector that dies
1110
+ * on reload — is the one thing nothing else on screen can say. It shows and it STAYS:
1111
+ * no timer, so it cannot scroll away unread. Click it, or trigger the next message,
1112
+ * to clear it.
1113
+ * · The TICKET-WRITTEN ACK stays too, transiently. The contract makes a point of the
1114
+ * panel confirming on the same synchronous tick as the write, in under 30ms
1115
+ * (../../docs/PIN-CONTRACT.md › Screenshots), and a confirmation nobody can see is not
1116
+ * a confirmation. It clears itself after ACK_MS.
1117
+ * · A CAVEAT nothing else on screen reveals — pinning inside a frame, a climb that has
1118
+ * run out of ancestors — is transient too.
1119
+ *
1120
+ * A new message always replaces the one before it. That is what makes this a status line
1121
+ * and not a shorter log.
1122
+ */
1123
+ function say(level, message) {
1124
+ const persistent = level === 'error' || level === 'warn';
1125
+ clearTimeout(ackTimer);
1126
+ ui.status.textContent = message;
1127
+ ui.status.dataset.level = level;
1128
+ ui.status.hidden = false;
1129
+ if (!persistent) ackTimer = setTimeout(clearStatus, ACK_MS);
1130
+ }
1131
+
1132
+ function clearStatus() {
1133
+ clearTimeout(ackTimer);
1134
+ ui.status.hidden = true;
1135
+ ui.status.textContent = '';
1136
+ ui.status.removeAttribute('data-level');
1137
+ }
1138
+
1139
+ /** say(), minus the repeat — Escape and ⌥↑ both get pressed more than once. */
1140
+ function sayOnce(level, message) {
1141
+ if (!ui.status.hidden && ui.status.dataset.level === level && ui.status.textContent === message) {
1142
+ return;
1143
+ }
1144
+ say(level, message);
1145
+ }
1146
+
1147
+ ui.status.addEventListener('click', clearStatus);
1148
+
1149
+ /* ---------- the ticket panel ---------- */
1150
+
1151
+ /**
1152
+ * ONE token block for every colour, length and typeface the panel and the markers use.
1153
+ *
1154
+ * This package is being merged into the component lab and shipped as one app, and it is ruled
1155
+ * twice over — once for the lab, once for this board — that the chrome is standard across
1156
+ * every site and never takes the host site's styling. The lab does that with a namespaced
1157
+ * token set and one stylesheet. So everything below is declared once, here, under a `--pin-`
1158
+ * prefix: the merge repoints this block at the lab's tokens in a single edit instead of
1159
+ * someone hunting for literals through two thousand lines. Do not write a hex value, a px
1160
+ * value or a font stack anywhere else in the panel, the markers or their handlers.
1161
+ *
1162
+ * Not `--lab-*`: that stylesheet lives in another repo and is not present here, so naming it
1163
+ * would only break. The namespace is also what stops a consumer's own `--color-background`
1164
+ * reaching this chrome if the two ever share a document.
1165
+ *
1166
+ * **No bare element selectors.** Every rule is reached by class or by a scoped root — a bare
1167
+ * `div {}` or `button {}` in a shared stylesheet inherits down into the page being previewed,
1168
+ * which the lab learned the hard way.
1169
+ */
1170
+ const panelStyle = document.createElement('style');
1171
+ panelStyle.textContent = `
1172
+ .pin-chrome {
1173
+ /* colour — surfaces */
1174
+ --pin-bg: rgba(19, 21, 26, 0.98);
1175
+ --pin-bg-raised: rgba(27, 30, 36, 1);
1176
+ --pin-bg-sunken: rgba(15, 17, 21, 1);
1177
+ --pin-bg-hover: rgba(52, 56, 65, 1);
1178
+ --pin-border: rgba(52, 56, 65, 1);
1179
+ --pin-border-strong: rgba(71, 78, 94, 1);
1180
+ /* colour — ink */
1181
+ --pin-ink: #fff;
1182
+ --pin-ink-dim: rgba(145, 152, 173, 1);
1183
+ --pin-ink-faint: rgba(145, 152, 173, 0.62);
1184
+ --pin-ink-bad: rgba(249, 196, 215, 1);
1185
+ --pin-ink-warn: rgba(255, 236, 179, 1);
1186
+ /* colour — this app's own accent, shared with the picker's highlights */
1187
+ --pin-accent: rgba(113, 24, 226, 1);
1188
+ --pin-accent-soft: rgba(113, 24, 226, 0.35);
1189
+ /* colour — the two marker states. Ruled 2026-09-22: open pins blue, resolved grey. */
1190
+ --pin-open: rgb(47, 123, 255);
1191
+ --pin-open-ink: #fff;
1192
+ --pin-resolved: rgb(139, 145, 158);
1193
+ --pin-resolved-ink: #fff;
1194
+ /* colour — Ready for review is the human review queue, and the thing the eye finds first */
1195
+ --pin-review: rgb(255, 191, 0);
1196
+ --pin-review-ink: rgb(26, 20, 0);
1197
+ /* colour — Backlog means an agent must NOT start; it sits next to Ready for agent
1198
+ and means the opposite, so it is drawn as held back rather than as ready */
1199
+ --pin-hold: rgba(145, 152, 173, 0.55);
1200
+ --pin-hold-ink: rgba(19, 21, 26, 1);
1201
+ /* a marker has to read on a white hero and on this site's near-black sections alike,
1202
+ so it carries its own light ring and its own dark shadow and depends on neither */
1203
+ --pin-halo: rgba(255, 255, 255, 0.95);
1204
+ --pin-marker-shadow: 0 2px 7px rgba(0, 0, 0, 0.55);
1205
+ --pin-shadow: 0 10px 34px rgba(0, 0, 0, 0.5);
1206
+ /* space */
1207
+ --pin-space-1: 4px;
1208
+ --pin-space-2: 8px;
1209
+ --pin-space-3: 12px;
1210
+ --pin-space-4: 16px;
1211
+ --pin-radius: 8px;
1212
+ --pin-radius-sm: 6px;
1213
+ --pin-radius-pill: 9999px;
1214
+ /* type */
1215
+ --pin-font: ui-sans-serif, system-ui, -apple-system, sans-serif;
1216
+ --pin-font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
1217
+ --pin-fs-body: 13px;
1218
+ --pin-fs-small: 11.5px;
1219
+ --pin-fs-tiny: 10px;
1220
+ --pin-fs-title: 14px;
1221
+ --pin-lh: 1.45;
1222
+ /* geometry */
1223
+ --pin-panel-w: 384px;
1224
+ --pin-panel-bottom: 76px;
1225
+ --pin-thumb-w: 64px;
1226
+ --pin-marker-w: ${MARKER_W}px;
1227
+ --pin-marker-h: ${MARKER_H}px;
1228
+ /* layering — above the page, below the comment window, which always wins */
1229
+ --pin-z-marker: ${Z.marker};
1230
+ --pin-z-panel: ${Z.panel};
1231
+ }
1232
+
1233
+ .pin-panel {
1234
+ position: fixed;
1235
+ top: var(--pin-space-3);
1236
+ right: var(--pin-space-3);
1237
+ bottom: var(--pin-panel-bottom);
1238
+ z-index: var(--pin-z-panel);
1239
+ width: var(--pin-panel-w);
1240
+ max-width: calc(100vw - var(--pin-space-3) * 2);
1241
+ display: flex;
1242
+ flex-direction: column;
1243
+ box-sizing: border-box;
1244
+ border: 1px solid var(--pin-border-strong);
1245
+ border-radius: var(--pin-radius);
1246
+ background: var(--pin-bg);
1247
+ box-shadow: var(--pin-shadow);
1248
+ color: var(--pin-ink);
1249
+ font-family: var(--pin-font);
1250
+ font-size: var(--pin-fs-body);
1251
+ line-height: var(--pin-lh);
1252
+ backdrop-filter: blur(6px);
1253
+ overflow: hidden;
1254
+ }
1255
+ .pin-panel[hidden] { display: none; }
1256
+
1257
+ .pin-panel__head {
1258
+ display: flex; align-items: center; gap: var(--pin-space-2);
1259
+ padding: var(--pin-space-2) var(--pin-space-2) var(--pin-space-2) var(--pin-space-3);
1260
+ border-bottom: 1px solid var(--pin-border);
1261
+ flex: none;
1262
+ }
1263
+ .pin-panel__title { font-size: var(--pin-fs-title); font-weight: 600; }
1264
+ .pin-panel__spacer { flex: 1; }
1265
+ .pin-panel__icon {
1266
+ flex: none; cursor: pointer; width: 26px; height: 26px; padding: 0;
1267
+ display: flex; align-items: center; justify-content: center;
1268
+ border: 1px solid transparent; border-radius: var(--pin-radius-sm);
1269
+ background: transparent; color: var(--pin-ink-dim);
1270
+ font-family: var(--pin-font); font-size: var(--pin-fs-title); line-height: 1;
1271
+ }
1272
+ .pin-panel__icon:hover { background: var(--pin-bg-hover); color: var(--pin-ink); }
1273
+ .pin-panel__icon:focus-visible { outline: 2px solid var(--pin-accent); outline-offset: 1px; }
1274
+
1275
+ .pin-panel__tabs {
1276
+ display: flex; gap: var(--pin-space-1); flex: none;
1277
+ padding: var(--pin-space-2) var(--pin-space-2) 0;
1278
+ }
1279
+ .pin-panel__tab {
1280
+ flex: 1; cursor: pointer; padding: var(--pin-space-2); border-radius: var(--pin-radius-sm);
1281
+ border: 1px solid transparent; background: transparent; color: var(--pin-ink-dim);
1282
+ font-family: var(--pin-font); font-size: var(--pin-fs-body); font-weight: 600;
1283
+ display: flex; align-items: center; justify-content: center; gap: var(--pin-space-2);
1284
+ }
1285
+ .pin-panel__tab:hover { color: var(--pin-ink); background: var(--pin-bg-hover); }
1286
+ .pin-panel__tab[aria-selected="true"] {
1287
+ color: var(--pin-ink); background: var(--pin-accent-soft); border-color: var(--pin-accent);
1288
+ }
1289
+ .pin-panel__tab-count {
1290
+ font-family: var(--pin-font-mono); font-size: var(--pin-fs-small); font-weight: 700;
1291
+ padding: 1px 6px; border-radius: var(--pin-radius-pill);
1292
+ background: var(--pin-bg-sunken); color: var(--pin-ink);
1293
+ }
1294
+
1295
+ .pin-panel__switch {
1296
+ display: flex; align-items: center; gap: var(--pin-space-2); flex: none;
1297
+ padding: var(--pin-space-2) var(--pin-space-3);
1298
+ color: var(--pin-ink-dim); font-size: var(--pin-fs-small);
1299
+ }
1300
+ .pin-panel__switch-text { cursor: pointer; }
1301
+
1302
+ .pin-panel__list {
1303
+ /* min-height: 0 is load-bearing, not tidying. A flex item defaults to
1304
+ min-height: auto, so without it this column refuses to shrink below its
1305
+ content, grows past the panel's own bottom edge and is then clipped by the
1306
+ panel's overflow: hidden — measured here 2026-09-22 with an expanded ticket,
1307
+ whose Resolve button sat 369px below the panel, invisible and unclickable,
1308
+ while scrollTop stayed 0 because nothing was scrollable. */
1309
+ flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden;
1310
+ padding: 0 var(--pin-space-2) var(--pin-space-2);
1311
+ scrollbar-width: thin;
1312
+ }
1313
+ .pin-panel__group {
1314
+ margin: var(--pin-space-3) 0 var(--pin-space-1);
1315
+ padding: 0 var(--pin-space-1);
1316
+ color: var(--pin-ink-faint);
1317
+ font-size: var(--pin-fs-tiny); font-weight: 700;
1318
+ text-transform: uppercase; letter-spacing: 0.09em;
1319
+ }
1320
+ .pin-panel__empty {
1321
+ margin: var(--pin-space-4) var(--pin-space-2);
1322
+ color: var(--pin-ink-dim); font-size: var(--pin-fs-body);
1323
+ }
1324
+ .pin-panel__note {
1325
+ flex: none; margin: 0; padding: var(--pin-space-2) var(--pin-space-3);
1326
+ border-top: 1px solid var(--pin-border);
1327
+ color: var(--pin-ink-dim); font-size: var(--pin-fs-small);
1328
+ }
1329
+ .pin-panel__note[data-level="error"] { color: var(--pin-ink-bad); }
1330
+ .pin-panel__note[data-level="warn"] { color: var(--pin-ink-warn); }
1331
+ .pin-panel__note[hidden] { display: none; }
1332
+
1333
+ .pin-row {
1334
+ border: 1px solid var(--pin-border); border-radius: var(--pin-radius);
1335
+ background: var(--pin-bg-raised); margin-top: var(--pin-space-2);
1336
+ overflow: hidden;
1337
+ }
1338
+ .pin-row[data-kind="review"] { border-left: 3px solid var(--pin-review); }
1339
+ .pin-row[data-current] { border-color: var(--pin-accent); }
1340
+ .pin-row__summary {
1341
+ width: 100%; box-sizing: border-box; cursor: pointer; text-align: left;
1342
+ display: flex; gap: var(--pin-space-2); align-items: flex-start;
1343
+ padding: var(--pin-space-2); border: 0; background: transparent;
1344
+ color: var(--pin-ink); font-family: var(--pin-font); font-size: var(--pin-fs-body);
1345
+ }
1346
+ .pin-row__summary:hover { background: var(--pin-bg-hover); }
1347
+ .pin-row__summary:focus-visible { outline: 2px solid var(--pin-accent); outline-offset: -2px; }
1348
+ .pin-row__thumb {
1349
+ flex: none; width: var(--pin-thumb-w); aspect-ratio: 4 / 3; overflow: hidden;
1350
+ border-radius: var(--pin-radius-sm); background: var(--pin-bg-sunken);
1351
+ border: 1px solid var(--pin-border);
1352
+ display: flex; align-items: center; justify-content: center;
1353
+ color: var(--pin-ink-faint); font-size: var(--pin-fs-tiny); text-align: center;
1354
+ }
1355
+ .pin-row__shot { width: 100%; height: 100%; object-fit: cover; display: block; }
1356
+ .pin-row__main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--pin-space-1); }
1357
+ .pin-row__title {
1358
+ font-weight: 600; line-height: 1.35;
1359
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
1360
+ }
1361
+ .pin-row__meta { display: flex; align-items: center; gap: var(--pin-space-1); flex-wrap: wrap; }
1362
+ .pin-row__id {
1363
+ font-family: var(--pin-font-mono); font-size: var(--pin-fs-small); color: var(--pin-ink-dim);
1364
+ }
1365
+ .pin-row__age { font-size: var(--pin-fs-small); color: var(--pin-ink-faint); }
1366
+ .pin-row__src {
1367
+ font-family: var(--pin-font-mono); font-size: var(--pin-fs-tiny); color: var(--pin-ink-faint);
1368
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left;
1369
+ }
1370
+
1371
+ .pin-chip {
1372
+ font-size: var(--pin-fs-tiny); font-weight: 700; line-height: 1.6;
1373
+ padding: 0 var(--pin-space-2); border-radius: var(--pin-radius-pill);
1374
+ background: var(--pin-bg-sunken); color: var(--pin-ink-dim);
1375
+ border: 1px solid var(--pin-border-strong); white-space: nowrap;
1376
+ }
1377
+ .pin-chip[data-kind="review"] {
1378
+ background: var(--pin-review); color: var(--pin-review-ink); border-color: var(--pin-review);
1379
+ }
1380
+ .pin-chip[data-kind="progress"] {
1381
+ background: var(--pin-open); color: var(--pin-open-ink); border-color: var(--pin-open);
1382
+ }
1383
+ .pin-chip[data-kind="resolved"] {
1384
+ background: var(--pin-resolved); color: var(--pin-resolved-ink); border-color: var(--pin-resolved);
1385
+ }
1386
+ .pin-chip[data-kind="backlog"] {
1387
+ background: transparent; color: var(--pin-ink-faint); border-style: dashed;
1388
+ }
1389
+ .pin-chip[data-kind="here"] {
1390
+ background: transparent; color: var(--pin-open); border-color: var(--pin-open);
1391
+ }
1392
+ .pin-chip[data-kind="nomarker"] {
1393
+ background: transparent; color: var(--pin-ink-warn); border-color: var(--pin-ink-warn);
1394
+ }
1395
+
1396
+ .pin-row__detail {
1397
+ padding: 0 var(--pin-space-2) var(--pin-space-2);
1398
+ display: flex; flex-direction: column; gap: var(--pin-space-2);
1399
+ }
1400
+ .pin-row__detail[hidden] { display: none; }
1401
+ .pin-row__full { width: 100%; display: block; border-radius: var(--pin-radius-sm); border: 1px solid var(--pin-border); }
1402
+ .pin-row__comment { margin: 0; white-space: pre-wrap; word-break: break-word; }
1403
+ .pin-row__warn {
1404
+ margin: 0; padding: var(--pin-space-2); border-radius: var(--pin-radius-sm);
1405
+ background: var(--pin-bg-sunken); border: 1px solid var(--pin-border-strong);
1406
+ color: var(--pin-ink-warn); font-size: var(--pin-fs-small);
1407
+ }
1408
+ .pin-row__meta-list {
1409
+ margin: 0; display: grid; grid-template-columns: auto 1fr;
1410
+ gap: var(--pin-space-1) var(--pin-space-2); font-size: var(--pin-fs-small);
1411
+ }
1412
+ .pin-row__key { color: var(--pin-ink-faint); }
1413
+ .pin-row__val {
1414
+ margin: 0; color: var(--pin-ink-dim); font-family: var(--pin-font-mono);
1415
+ font-size: var(--pin-fs-tiny); word-break: break-all;
1416
+ }
1417
+ .pin-row__actions { display: flex; gap: var(--pin-space-2); flex-wrap: wrap; align-items: center; }
1418
+
1419
+ /* ---- the markers ---- */
1420
+
1421
+ .pin-markers { position: absolute; top: 0; left: 0; width: 0; height: 0; }
1422
+ .pin-marker {
1423
+ position: absolute; padding: 0; margin: 0; border: 0; background: transparent;
1424
+ width: var(--pin-marker-w); height: var(--pin-marker-h);
1425
+ z-index: var(--pin-z-marker);
1426
+ transform: translate(-50%, -100%);
1427
+ cursor: pointer; line-height: 0;
1428
+ filter: drop-shadow(var(--pin-marker-shadow));
1429
+ transition: transform 120ms ease-out;
1430
+ }
1431
+ .pin-marker:hover { transform: translate(-50%, -100%) scale(1.12); }
1432
+ .pin-marker:focus-visible { outline: 2px solid var(--pin-accent); outline-offset: 2px; }
1433
+ .pin-marker[data-state="resolved"] { opacity: 0.85; }
1434
+ .pin-marker__glyph { width: 100%; height: 100%; display: block; overflow: visible; }
1435
+ .pin-marker__body { fill: var(--pin-open); stroke: var(--pin-halo); stroke-width: 1.8; }
1436
+ .pin-marker[data-state="resolved"] .pin-marker__body { fill: var(--pin-resolved); }
1437
+ .pin-marker__disc { fill: var(--pin-halo); }
1438
+ .pin-marker__num {
1439
+ fill: var(--pin-open); font-family: var(--pin-font); font-size: 11px; font-weight: 700;
1440
+ }
1441
+ .pin-marker[data-state="resolved"] .pin-marker__num { fill: var(--pin-resolved); }
1442
+ .pin-marker[data-current] .pin-marker__body { stroke: var(--pin-accent); stroke-width: 2.6; }
1443
+ .pin-marker[data-current] { transform: translate(-50%, -100%) scale(1.15); }
1444
+
1445
+ /* While the picker is armed the page belongs to it: a click anywhere must pin the
1446
+ element underneath, never open a ticket. The markers stay visible — seeing where the
1447
+ existing comments are is useful while leaving a new one — they just stop taking clicks. */
1448
+ .pin-markers[data-picking] .pin-marker { pointer-events: none; opacity: 0.55; }
1449
+
1450
+ @media (max-width: 480px) {
1451
+ .pin-chrome {
1452
+ --pin-panel-w: 100vw;
1453
+ --pin-thumb-w: 52px;
1454
+ }
1455
+ .pin-panel { top: var(--pin-space-2); right: var(--pin-space-2); left: var(--pin-space-2); }
1456
+ }
1457
+ @media (prefers-reduced-motion: reduce) {
1458
+ .pin-marker { transition: none; }
1459
+ }
1460
+ `;
1461
+
1462
+ /* ---------- the ticket panel ---------- */
1463
+
1464
+ /**
1465
+ * Panel state. `tickets` is whatever the last read of the board said; nothing here is a
1466
+ * cache of the files, and every write is followed by a re-read rather than an optimistic
1467
+ * edit of this array — the status on screen after a resolve is the status the file now has.
1468
+ */
1469
+ let panelOpen = false;
1470
+ let panelTab = 'open'; // open | resolved
1471
+ let panelTickets = [];
1472
+ let panelApi = null;
1473
+ let panelStatuses = [];
1474
+ let panelBroken = [];
1475
+ let expandedId = null;
1476
+ let panelLoading = false;
1477
+ let panelLoadedOnce = false;
1478
+ /** The last read's failure, or null. A failed read must never read as an empty board. */
1479
+ let panelLoadError = null;
1480
+ /** id → { el, target, isFixed } for every marker currently on the page. */
1481
+ const markers = new Map();
1482
+ let markerFrame = false;
1483
+
1484
+ const panel = document.createElement('aside');
1485
+ panel.className = 'pin-chrome pin-panel';
1486
+ panel.setAttribute('data-orbytes-pin-ui', 'panel');
1487
+ panel.setAttribute('aria-label', 'Pin tickets');
1488
+ panel.hidden = true;
1489
+ panel.innerHTML = `
1490
+ <div class="pin-panel__head">
1491
+ <span class="pin-panel__title">Tickets</span>
1492
+ <span class="pin-panel__spacer"></span>
1493
+ <button type="button" class="pin-panel__icon" data-p-refresh title="Re-read the board" aria-label="Re-read the board">&#8635;</button>
1494
+ <button type="button" class="pin-panel__icon" data-p-close title="Close the panel" aria-label="Close the panel">&times;</button>
1495
+ </div>
1496
+ <div class="pin-panel__tabs" role="tablist">
1497
+ <button type="button" class="pin-panel__tab" role="tab" data-p-tab="open" aria-selected="true">
1498
+ Open <span class="pin-panel__tab-count" data-p-count="open">0</span>
1499
+ </button>
1500
+ <button type="button" class="pin-panel__tab" role="tab" data-p-tab="resolved" aria-selected="false">
1501
+ Resolved <span class="pin-panel__tab-count" data-p-count="resolved">0</span>
1502
+ </button>
1503
+ </div>
1504
+ <div class="pin-panel__switch">
1505
+ <astro-dev-toolbar-toggle data-p-show-resolved toggle-style="gray"></astro-dev-toolbar-toggle>
1506
+ <span class="pin-panel__switch-text" data-p-show-resolved-label>Show resolved pins on the page</span>
1507
+ </div>
1508
+ <div class="pin-panel__list" data-p-list></div>
1509
+ <p class="pin-panel__note" data-p-note hidden></p>
1510
+ `;
1511
+
1512
+ /**
1513
+ * The markers live in the toolbar's own canvas, never in `document.body`, and that is not a
1514
+ * matter of tidiness. `shot.mjs` hides exactly one thing before it takes a screenshot —
1515
+ * `astro-dev-toolbar{display:none!important}` — so a marker parented to the page would be
1516
+ * photographed into every ticket from then on, and every screenshot would carry the pins of
1517
+ * the tickets before it. Inside the canvas it is hidden with the toolbar, it cannot be
1518
+ * reached by the site's CSS, and the hit test already skips it.
1519
+ *
1520
+ * The consequence, stated plainly: markers are visible while the Pin app is open, because
1521
+ * Astro sets `display: none` on an app's canvas when its app is toggled off.
1522
+ */
1523
+ const markerLayer = document.createElement('div');
1524
+ markerLayer.className = 'pin-chrome pin-markers';
1525
+ markerLayer.setAttribute('data-orbytes-pin-ui', 'markers');
1526
+
1527
+ canvas.append(panelStyle, markerLayer, panel);
1528
+
1529
+ const pui = {
1530
+ list: panel.querySelector('[data-p-list]'),
1531
+ note: panel.querySelector('[data-p-note]'),
1532
+ counts: {
1533
+ open: panel.querySelector('[data-p-count="open"]'),
1534
+ resolved: panel.querySelector('[data-p-count="resolved"]'),
1535
+ },
1536
+ tabs: Array.prototype.slice.call(panel.querySelectorAll('[data-p-tab]')),
1537
+ showResolved: panel.querySelector('[data-p-show-resolved]'),
1538
+ showResolvedLabel: panel.querySelector('[data-p-show-resolved-label]'),
1539
+ };
1540
+
1541
+ /** The toggle's choice, per viewer. Storage can be off; the panel then just forgets it. */
1542
+ function readShowResolved() {
1543
+ try {
1544
+ const raw = localStorage.getItem(SHOW_RESOLVED_KEY);
1545
+ return raw === null ? true : raw === '1';
1546
+ } catch {
1547
+ return true;
1548
+ }
1549
+ }
1550
+ function writeShowResolved(value) {
1551
+ try {
1552
+ localStorage.setItem(SHOW_RESOLVED_KEY, value ? '1' : '0');
1553
+ } catch {
1554
+ /* private mode, storage disabled — the choice is a convenience, not a contract */
1555
+ }
1556
+ }
1557
+ let showResolvedMarkers = readShowResolved();
1558
+
1559
+ /**
1560
+ * Keep the panel clear of the comment window.
1561
+ *
1562
+ * The window is centred and this rail is right-aligned, so on a narrow-enough viewport the
1563
+ * two overlap — and the window is painted above (z-index 999999999, ui-library/window.js)
1564
+ * because the comment box must never be covered by anything. Measured here 2026-09-22 at
1565
+ * 800x620: the window occupied x 80-720, y 377-549 and swallowed the bottom half of the
1566
+ * panel, including the Resolve button, which took the click and did nothing visible.
1567
+ *
1568
+ * So the PANEL yields, not the window: when the two would share a column, the panel's bottom
1569
+ * edge is lifted to sit above the window. Nothing is covered, nothing is layered over the
1570
+ * comment box, and on a wide screen — where they never meet — the panel keeps its full height.
1571
+ */
1572
+ function fitPanelAroundWindow() {
1573
+ if (!panelOpen) return;
1574
+ const floor = 76; // the toolbar bar's own strip along the bottom
1575
+ let bottom = floor;
1576
+ const wr = win.getBoundingClientRect();
1577
+ const pr = panel.getBoundingClientRect();
1578
+ const overlapsColumn = wr.width > 0 && wr.right > pr.left && wr.left < pr.right;
1579
+ if (overlapsColumn) bottom = Math.max(floor, Math.round(window.innerHeight - wr.top + 12));
1580
+ panel.style.bottom = `${bottom}px`;
1581
+ }
1582
+
1583
+ function panelNote(level, message) {
1584
+ if (!message) {
1585
+ pui.note.hidden = true;
1586
+ pui.note.textContent = '';
1587
+ return;
1588
+ }
1589
+ pui.note.hidden = false;
1590
+ pui.note.dataset.level = level;
1591
+ pui.note.textContent = message;
1592
+ }
1593
+
1594
+ /* ---------- reading the board ---------- */
1595
+
1596
+ /**
1597
+ * Re-read every ticket from the board. One fetch, no polling: this is a dev tool on the
1598
+ * page being built, and a timer hitting the dev server every few seconds would be noise
1599
+ * for a file set that only changes when a person or an agent changes it. It refreshes when the
1600
+ * panel opens, when a ticket is written, when a screenshot lands, after a write, and on
1601
+ * the refresh button.
1602
+ */
1603
+ async function loadBoard({ quiet = false } = {}) {
1604
+ if (panelLoading) return;
1605
+ panelLoading = true;
1606
+ if (!quiet) panelNote('info', 'Reading the board…');
1607
+ try {
1608
+ const response = await fetch(BOARD_ROUTE, { cache: 'no-store', credentials: 'same-origin' });
1609
+ if (!response.ok) {
1610
+ throw new Error(`${BOARD_ROUTE} answered ${response.status} ${response.statusText}`);
1611
+ }
1612
+ const parsed = ticketsFromBoardHtml(await response.text(), BOARD_ROUTE);
1613
+ panelTickets = parsed.tickets;
1614
+ panelApi = parsed.apiPath;
1615
+ panelStatuses = parsed.statuses;
1616
+ panelBroken = parsed.broken;
1617
+ panelLoadedOnce = true;
1618
+ panelLoadError = null;
1619
+ panelNote(
1620
+ panelBroken.length ? 'warn' : 'info',
1621
+ panelBroken.length
1622
+ ? `${panelBroken.length} ticket${panelBroken.length === 1 ? '' : 's'} on disk could not be read — ${BOARD_ROUTE} names them.`
1623
+ : '',
1624
+ );
1625
+ } catch (error) {
1626
+ panelTickets = [];
1627
+ panelApi = null;
1628
+ panelLoadError = (error && error.message) || String(error);
1629
+ panelNote('error', `Could not read the tickets: ${panelLoadError}`);
1630
+ } finally {
1631
+ panelLoading = false;
1632
+ renderPanel();
1633
+ syncMarkers();
1634
+ // The count on the toolbar button is read from the same list, so it moves with it.
1635
+ updateTicketsButton();
1636
+ }
1637
+ }
1638
+
1639
+ /* ---------- placement ---------- */
1640
+
1641
+ /**
1642
+ * Where this ticket's marker goes on THIS page, decided against the live DOM.
1643
+ *
1644
+ * A ticket carries a `selector`, a document-coordinate `rect` taken when it was written,
1645
+ * and `selector_matches` — how many nodes the selector found on a genuinely fresh load,
1646
+ * counted by shot.mjs before the shutter. The rect is the tempting fallback and it is the
1647
+ * one thing this must not do: the page has been edited since, which is the whole point of
1648
+ * the ticket, so a marker drawn at a stale rect points confidently at the wrong element.
1649
+ * That is worse than no marker, because nothing on screen says it is wrong.
1650
+ *
1651
+ * So placement is decided by running the selector here, now, which is also the most honest
1652
+ * test available — more current than the recorded count, which was taken at capture time.
1653
+ * Exactly one match is placed. Nothing, several, or no selector at all is UNPLACEABLE, and
1654
+ * an unplaceable ticket is shown in the panel with the reason in plain words and no marker
1655
+ * on the page.
1656
+ */
1657
+ function placementFor(ticket) {
1658
+ if (isCancelledStatus(ticket.status)) {
1659
+ return { kind: 'archived', why: 'Cancelled, so it is archived and draws nothing.' };
1660
+ }
1661
+ if (!samePath(ticket.url, currentPath())) {
1662
+ return { kind: 'elsewhere', why: `It was left on ${ticket.url || 'an unrecorded page'}.` };
1663
+ }
1664
+ if (!ticket.selector) {
1665
+ return { kind: 'none', why: 'This ticket records no selector, so there is nothing to find.' };
1666
+ }
1667
+ let found;
1668
+ try {
1669
+ found = document.querySelectorAll(ticket.selector);
1670
+ } catch {
1671
+ return { kind: 'bad', why: 'Its selector is not valid CSS any more, so it cannot be looked up.' };
1672
+ }
1673
+ if (found.length === 1) return { kind: 'placed', el: found[0] };
1674
+ if (found.length === 0) {
1675
+ return {
1676
+ kind: 'dead',
1677
+ why: 'Its selector finds nothing on this page now, so a marker would have to be guessed from where the element used to be — which would point at the wrong thing. Work it from the screenshot, the source file and the HTML snippet.',
1678
+ };
1679
+ }
1680
+ return {
1681
+ kind: 'ambiguous',
1682
+ why: `Its selector finds ${found.length} elements here, so which one was meant is not recorded. No marker is drawn rather than one on the wrong element.`,
1683
+ };
1684
+ }
1685
+
1686
+ /** What the ticket itself recorded about its selector, for the detail panel. */
1687
+ function recordedSelectorNote(ticket) {
1688
+ if (ticket.flag && ticket.flag.label) return `${ticket.flag.label} when it was captured`;
1689
+ if (ticket.selectorNote) return `${ticket.selectorNote} when it was captured`;
1690
+ return 'found exactly one element when it was captured';
1691
+ }
1692
+
1693
+ /* ---------- markers ---------- */
1694
+
1695
+ function markerVisible(ticket) {
1696
+ if (isCancelledStatus(ticket.status)) return false;
1697
+ if (isResolvedStatus(ticket.status)) return showResolvedMarkers;
1698
+ return true;
1699
+ }
1700
+
1701
+ function makeMarker(ticket) {
1702
+ const button = document.createElement('button');
1703
+ button.type = 'button';
1704
+ button.className = 'pin-marker';
1705
+ button.setAttribute('data-orbytes-pin-ui', 'marker');
1706
+ button.dataset.id = ticket.id;
1707
+ const ns = 'http://www.w3.org/2000/svg';
1708
+ const svg = document.createElementNS(ns, 'svg');
1709
+ svg.setAttribute('class', 'pin-marker__glyph');
1710
+ svg.setAttribute('viewBox', `0 0 ${MARKER_W} ${MARKER_H}`);
1711
+ svg.setAttribute('aria-hidden', 'true');
1712
+ const body = document.createElementNS(ns, 'path');
1713
+ body.setAttribute('class', 'pin-marker__body');
1714
+ // A map pin: a circle of radius 11.6 centred at (13, 13) drawn down to a tip at
1715
+ // (13, 33.2), which is the bottom centre of the box — so `translate(-50%, -100%)`
1716
+ // puts that tip exactly on the anchor point.
1717
+ body.setAttribute(
1718
+ 'd',
1719
+ 'M13 33.2C13 33.2 24.6 21.4 24.6 13A11.6 11.6 0 1 0 1.4 13C1.4 21.4 13 33.2 13 33.2Z',
1720
+ );
1721
+ const disc = document.createElementNS(ns, 'circle');
1722
+ disc.setAttribute('class', 'pin-marker__disc');
1723
+ disc.setAttribute('cx', '13');
1724
+ disc.setAttribute('cy', '13');
1725
+ disc.setAttribute('r', '7.4');
1726
+ const num = document.createElementNS(ns, 'text');
1727
+ num.setAttribute('class', 'pin-marker__num');
1728
+ num.setAttribute('x', '13');
1729
+ num.setAttribute('y', '13');
1730
+ num.setAttribute('text-anchor', 'middle');
1731
+ num.setAttribute('dominant-baseline', 'central');
1732
+ num.textContent = ticketNumber(ticket.id);
1733
+ svg.append(body, disc, num);
1734
+ button.append(svg);
1735
+ button.addEventListener('click', (event) => {
1736
+ event.preventDefault();
1737
+ event.stopPropagation();
1738
+ openPanel({ focus: false });
1739
+ expandedId = ticket.id;
1740
+ panelTab = isResolvedStatus(ticket.status) ? 'resolved' : 'open';
1741
+ renderPanel();
1742
+ const row = pui.list.querySelector(`[data-p-row="${CSS.escape(ticket.id)}"]`);
1743
+ if (row && row.scrollIntoView) row.scrollIntoView({ block: 'nearest' });
1744
+ syncMarkers();
1745
+ handFocusBack();
1746
+ });
1747
+ return button;
1748
+ }
1749
+
1750
+ function decorateMarker(button, ticket) {
1751
+ button.dataset.state = isResolvedStatus(ticket.status) ? 'resolved' : 'open';
1752
+ if (ticket.id === expandedId) button.setAttribute('data-current', '');
1753
+ else button.removeAttribute('data-current');
1754
+ const label = `${ticket.id} — ${ticket.title} (${statusLabel(ticket.status)})`;
1755
+ button.title = label;
1756
+ button.setAttribute('aria-label', label);
1757
+ }
1758
+
1759
+ /**
1760
+ * Reconcile the marker layer with the tickets. Called after every read, every write, every
1761
+ * tab change and every toggle — it adds, updates and removes rather than rebuilding, so a
1762
+ * marker keeps its place across a refresh and nothing flickers under the cursor.
1763
+ */
1764
+ function syncMarkers() {
1765
+ const wanted = new Map();
1766
+ for (const ticket of panelTickets) {
1767
+ if (!markerVisible(ticket)) continue;
1768
+ const placement = placementFor(ticket);
1769
+ if (placement.kind !== 'placed') continue;
1770
+ wanted.set(ticket.id, { ticket, el: placement.el });
1771
+ }
1772
+ for (const [id, record] of markers) {
1773
+ if (!wanted.has(id)) {
1774
+ record.el.remove();
1775
+ markers.delete(id);
1776
+ }
1777
+ }
1778
+ for (const [id, { ticket, el }] of wanted) {
1779
+ let record = markers.get(id);
1780
+ if (!record) {
1781
+ record = { el: makeMarker(ticket), target: el };
1782
+ markers.set(id, record);
1783
+ markerLayer.append(record.el);
1784
+ }
1785
+ record.target = el;
1786
+ decorateMarker(record.el, ticket);
1787
+ }
1788
+ watchTargets();
1789
+ placeMarkers();
1790
+ }
1791
+
1792
+ /**
1793
+ * Layout moves under a marker for reasons no scroll or resize event reports — an image
1794
+ * settling, a font swapping, a section being edited two screens up. The highlight code
1795
+ * this app already runs answers scroll and resize; a ResizeObserver on the page root and
1796
+ * on each marked element answers the rest, all of it funnelled through one rAF so a
1797
+ * cascade of observations costs one layout read.
1798
+ */
1799
+ const targetObserver =
1800
+ typeof ResizeObserver === 'function' ? new ResizeObserver(() => schedulePlaceMarkers()) : null;
1801
+ function watchTargets() {
1802
+ if (!targetObserver) return;
1803
+ targetObserver.disconnect();
1804
+ try {
1805
+ targetObserver.observe(document.documentElement);
1806
+ if (document.body) targetObserver.observe(document.body);
1807
+ targetObserver.observe(win);
1808
+ } catch {
1809
+ /* nothing to do — placement still runs on scroll and resize */
1810
+ }
1811
+ for (const record of markers.values()) {
1812
+ try {
1813
+ targetObserver.observe(record.target);
1814
+ } catch {
1815
+ /* the element went away between the sync and here; placeMarkers drops it */
1816
+ }
1817
+ }
1818
+ }
1819
+
1820
+ function schedulePlaceMarkers() {
1821
+ if (markerFrame) return;
1822
+ markerFrame = true;
1823
+ requestAnimationFrame(() => {
1824
+ markerFrame = false;
1825
+ placeMarkers();
1826
+ fitPanelAroundWindow();
1827
+ });
1828
+ }
1829
+
1830
+ /**
1831
+ * Put every marker on its element, the same way the picker's highlights are placed:
1832
+ * document coordinates for an element in normal flow (so ordinary scrolling needs no work
1833
+ * at all), viewport coordinates when anything in its ancestry is `position: fixed`.
1834
+ *
1835
+ * The tip lands inside the element's top-left corner, which is where a pin belongs on a
1836
+ * section as much as on a button, and is clamped so it can never be drawn off the top or
1837
+ * the left of the page.
1838
+ */
1839
+ function placeMarkers() {
1840
+ for (const [id, record] of markers) {
1841
+ const el = record.target;
1842
+ if (!el || !el.isConnected) {
1843
+ record.el.style.display = 'none';
1844
+ continue;
1845
+ }
1846
+ const r = el.getBoundingClientRect();
1847
+ if (!r.width && !r.height) {
1848
+ record.el.style.display = 'none';
1849
+ continue;
1850
+ }
1851
+ const { isFixed } = getElementsPositionInDocument(el);
1852
+ const scrollX = isFixed ? 0 : window.scrollX;
1853
+ const scrollY = isFixed ? 0 : window.scrollY;
1854
+ const tipX = r.left + Math.min(MARKER_W * 0.62, Math.max(2, r.width / 2));
1855
+ const tipY = r.top + Math.min(MARKER_H, Math.max(MARKER_H * 0.6, r.height));
1856
+ record.el.style.display = 'block';
1857
+ record.el.style.position = isFixed ? 'fixed' : 'absolute';
1858
+ record.el.style.left = `${Math.max(tipX + scrollX, MARKER_W / 2)}px`;
1859
+ record.el.style.top = `${Math.max(tipY + scrollY, MARKER_H)}px`;
1860
+ }
1861
+ }
1862
+
1863
+ /* ---------- the list ---------- */
1864
+
1865
+ function mkEl(tag, className, text) {
1866
+ const node = document.createElement(tag);
1867
+ if (className) node.className = className;
1868
+ if (text !== undefined && text !== null) node.textContent = String(text);
1869
+ return node;
1870
+ }
1871
+
1872
+ function statusChip(kind, text) {
1873
+ const node = mkEl('span', 'pin-chip', text);
1874
+ node.dataset.kind = kind;
1875
+ return node;
1876
+ }
1877
+
1878
+ function metaRow(list, key, value) {
1879
+ if (value === undefined || value === null || value === '') return;
1880
+ list.append(mkEl('dt', 'pin-row__key', key), mkEl('dd', 'pin-row__val', value));
1881
+ }
1882
+
1883
+ /** The two tabs, and the review queue first inside Open. `Cancelled` is on neither, by rule. */
1884
+ function ticketsForTab(tab) {
1885
+ const rows = panelTickets.filter((t) =>
1886
+ tab === 'resolved' ? isResolvedStatus(t.status) : isOpenStatus(t.status),
1887
+ );
1888
+ const here = currentPath();
1889
+ rows.sort((a, b) => {
1890
+ const aHere = samePath(a.url, here) ? 0 : 1;
1891
+ const bHere = samePath(b.url, here) ? 0 : 1;
1892
+ if (aHere !== bHere) return aHere - bHere;
1893
+ if (tab === 'open') {
1894
+ const ao = OPEN_ORDER[statusKind(a.status)] ?? 9;
1895
+ const bo = OPEN_ORDER[statusKind(b.status)] ?? 9;
1896
+ if (ao !== bo) return ao - bo;
1897
+ }
1898
+ return Number(ticketNumber(b.id) || 0) - Number(ticketNumber(a.id) || 0);
1899
+ });
1900
+ return rows;
1901
+ }
1902
+
1903
+ function buildDetail(ticket, placement) {
1904
+ const detail = mkEl('div', 'pin-row__detail');
1905
+ detail.hidden = ticket.id !== expandedId;
1906
+ if (detail.hidden) return detail;
1907
+
1908
+ if (ticket.shot) {
1909
+ const img = mkEl('img', 'pin-row__full');
1910
+ img.src = ticket.shot;
1911
+ img.alt = `Screenshot taken with ${ticket.id}`;
1912
+ img.loading = 'lazy';
1913
+ img.decoding = 'async';
1914
+ detail.append(img);
1915
+ }
1916
+
1917
+ const comment = mkEl('div', 'pin-row__comment');
1918
+ // Already escaped by the board (board.mjs `esc`), which is the same HTML its own detail
1919
+ // dialog shows. Paragraphs and code blocks, never one folded string.
1920
+ comment.innerHTML = ticket.commentHtml || '';
1921
+ detail.append(comment);
1922
+
1923
+ if (placement.kind !== 'placed' && placement.kind !== 'elsewhere' && placement.kind !== 'archived') {
1924
+ detail.append(mkEl('p', 'pin-row__warn', `No marker on this page. ${placement.why}`));
1925
+ }
1926
+
1927
+ const list = mkEl('dl', 'pin-row__meta-list');
1928
+ metaRow(list, 'Status', statusLabel(ticket.status) + (statusLabel(ticket.status) !== String(ticket.status ?? '') ? ` (on disk: ${ticket.status})` : ''));
1929
+ metaRow(list, 'Page', ticket.url);
1930
+ metaRow(list, 'Source', ticket.source || 'unresolved');
1931
+ metaRow(list, 'Selector', ticket.selector);
1932
+ metaRow(list, 'At capture', recordedSelectorNote(ticket));
1933
+ metaRow(
1934
+ list,
1935
+ 'On this page',
1936
+ placement.kind === 'placed'
1937
+ ? 'found — the marker is on it'
1938
+ : placement.kind === 'elsewhere'
1939
+ ? 'not this page'
1940
+ : placement.why,
1941
+ );
1942
+ metaRow(list, 'Viewport', ticket.viewport ? `${ticket.viewport.width}x${ticket.viewport.height}` : '');
1943
+ metaRow(list, 'Dispatch', ticket.dispatch);
1944
+ metaRow(list, 'Priority', ticket.priority);
1945
+ metaRow(list, 'Created', ticket.created);
1946
+ metaRow(list, 'Updated', ticket.updated);
1947
+ metaRow(list, 'File', ticket.file);
1948
+ if ((ticket.labels || []).length) metaRow(list, 'Labels', ticket.labels.join(', '));
1949
+ detail.append(list);
1950
+
1951
+ const actions = mkEl('div', 'pin-row__actions');
1952
+ const resolved = isResolvedStatus(ticket.status);
1953
+ const action = document.createElement('astro-dev-toolbar-button');
1954
+ action.setAttribute('size', 'small');
1955
+ action.setAttribute('button-style', resolved ? 'gray' : 'green');
1956
+ action.textContent = resolved ? 'Reopen' : 'Resolve';
1957
+ action.addEventListener('click', () => {
1958
+ setStatus(ticket, resolved ? reopenTarget() : STATUS_RESOLVED);
1959
+ });
1960
+ actions.append(action);
1961
+
1962
+ if (placement.kind === 'placed') {
1963
+ const show = document.createElement('astro-dev-toolbar-button');
1964
+ show.setAttribute('size', 'small');
1965
+ show.setAttribute('button-style', 'ghost');
1966
+ show.textContent = 'Show on page';
1967
+ show.addEventListener('click', () => {
1968
+ try {
1969
+ placement.el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'auto' });
1970
+ } catch {
1971
+ /* an element that refuses to scroll is not worth an error */
1972
+ }
1973
+ schedulePlaceMarkers();
1974
+ });
1975
+ actions.append(show);
1976
+ }
1977
+ detail.append(actions);
1978
+ return detail;
1979
+ }
1980
+
1981
+ function buildRow(ticket) {
1982
+ const placement = placementFor(ticket);
1983
+ const row = mkEl('article', 'pin-row');
1984
+ row.dataset.pRow = ticket.id;
1985
+ row.dataset.kind = statusKind(ticket.status);
1986
+ if (ticket.id === expandedId) row.setAttribute('data-current', '');
1987
+
1988
+ const summary = mkEl('button', 'pin-row__summary');
1989
+ summary.type = 'button';
1990
+ summary.setAttribute('aria-expanded', ticket.id === expandedId ? 'true' : 'false');
1991
+
1992
+ const thumb = mkEl('span', 'pin-row__thumb');
1993
+ if (ticket.shot) {
1994
+ const img = mkEl('img', 'pin-row__shot');
1995
+ img.src = ticket.shot;
1996
+ img.alt = '';
1997
+ img.loading = 'lazy';
1998
+ img.decoding = 'async';
1999
+ thumb.append(img);
2000
+ } else {
2001
+ thumb.append(mkEl('span', null, 'no shot'));
2002
+ }
2003
+
2004
+ const main = mkEl('span', 'pin-row__main');
2005
+ main.append(mkEl('span', 'pin-row__title', ticket.title));
2006
+ const meta = mkEl('span', 'pin-row__meta');
2007
+ meta.append(mkEl('span', 'pin-row__id', ticket.id));
2008
+ meta.append(statusChip(statusKind(ticket.status), statusLabel(ticket.status)));
2009
+ if (placement.kind === 'placed') meta.append(statusChip('here', 'pinned'));
2010
+ // The failure is said on the ROW, not only inside the detail: a ticket that belongs to
2011
+ // this page and has no marker on it is exactly the case a reader would otherwise read
2012
+ // as "there is no pin here", and go looking for the element by hand.
2013
+ else if (placement.kind !== 'elsewhere' && placement.kind !== 'archived') {
2014
+ meta.append(statusChip('nomarker', 'no marker'));
2015
+ }
2016
+ const age = ageLabel(ticket.updated || ticket.created);
2017
+ if (age) meta.append(mkEl('span', 'pin-row__age', age));
2018
+ main.append(meta);
2019
+ const where =
2020
+ ticket.source && ticket.source !== 'unresolved' ? ticket.source : ticket.url || '';
2021
+ main.append(mkEl('span', 'pin-row__src', where));
2022
+ summary.append(thumb, main);
2023
+ summary.addEventListener('click', () => {
2024
+ expandedId = expandedId === ticket.id ? null : ticket.id;
2025
+ renderPanel();
2026
+ syncMarkers();
2027
+ handFocusBack();
2028
+ });
2029
+ row.append(summary, buildDetail(ticket, placement));
2030
+ return row;
2031
+ }
2032
+
2033
+ function renderPanel() {
2034
+ const open = panelTickets.filter((t) => isOpenStatus(t.status)).length;
2035
+ const resolved = panelTickets.filter((t) => isResolvedStatus(t.status)).length;
2036
+ pui.counts.open.textContent = String(open);
2037
+ pui.counts.resolved.textContent = String(resolved);
2038
+ for (const tab of pui.tabs) {
2039
+ tab.setAttribute('aria-selected', tab.dataset.pTab === panelTab ? 'true' : 'false');
2040
+ }
2041
+ if (pui.showResolved.input) pui.showResolved.input.checked = showResolvedMarkers;
2042
+
2043
+ pui.list.innerHTML = '';
2044
+ const rows = ticketsForTab(panelTab);
2045
+ if (!rows.length) {
2046
+ // A read that FAILED is not an empty board, and must never be drawn as one — the
2047
+ // whole list going quiet is exactly the shape a missing ticket would take.
2048
+ const empty = panelLoadError
2049
+ ? `No tickets could be read. ${panelLoadError}`
2050
+ : !panelLoadedOnce
2051
+ ? 'Reading the board…'
2052
+ : panelTab === 'open'
2053
+ ? 'Nothing open. Pick an element to leave one.'
2054
+ : 'Nothing resolved yet.';
2055
+ pui.list.append(mkEl('p', 'pin-panel__empty', empty));
2056
+ return;
2057
+ }
2058
+ const here = currentPath();
2059
+ let group = null;
2060
+ for (const ticket of rows) {
2061
+ const onThisPage = samePath(ticket.url, here);
2062
+ const name = onThisPage ? 'This page' : 'Other pages';
2063
+ if (name !== group) {
2064
+ group = name;
2065
+ pui.list.append(mkEl('p', 'pin-panel__group', name));
2066
+ }
2067
+ pui.list.append(buildRow(ticket));
2068
+ }
2069
+ }
2070
+
2071
+ /* ---------- the one write ---------- */
2072
+
2073
+ /**
2074
+ * Where Reopen sends a ticket: the first status in the preference list the BOARD says it
2075
+ * knows. The write endpoint refuses anything outside `STATUSES` (tickets.mjs, a file this
2076
+ * panel does not own and which is being changed in parallel), so asking the board what it
2077
+ * renders is the only way this control cannot break under a vocabulary change.
2078
+ */
2079
+ function reopenTarget() {
2080
+ for (const wanted of REOPEN_PREFERENCE) {
2081
+ if (panelStatuses.includes(wanted)) return wanted;
2082
+ }
2083
+ return REOPEN_PREFERENCE[0];
2084
+ }
2085
+
2086
+ /**
2087
+ * The status change — `POST <board>/api/ticket`, the package's one write path, whose address
2088
+ * is read from the board's own `data-api` rather than assembled here.
2089
+ *
2090
+ * Two things are deliberate. `expect` carries the status EXACTLY as the board reported it,
2091
+ * never this panel's relabelled version, because the endpoint compares it against the file
2092
+ * and a legacy value on disk must compare as itself. And every refusal is shown verbatim
2093
+ * and changes nothing on screen: the endpoint refuses a stale `expect`, an unknown status
2094
+ * and a file that moved under the read, and each of those is a real answer about the disk.
2095
+ */
2096
+ async function setStatus(ticket, next) {
2097
+ if (!panelApi) {
2098
+ panelNote('error', `There is no write endpoint on ${BOARD_ROUTE}, so this panel cannot change a status.`);
2099
+ return;
2100
+ }
2101
+ panelNote('info', `${ticket.id} → ${next}…`);
2102
+ let response;
2103
+ let body;
2104
+ try {
2105
+ response = await fetch(panelApi, {
2106
+ method: 'POST',
2107
+ headers: { 'Content-Type': 'application/json' },
2108
+ credentials: 'same-origin',
2109
+ body: JSON.stringify({ id: ticket.id, status: next, expect: { status: ticket.status } }),
2110
+ });
2111
+ body = await response.json();
2112
+ } catch (error) {
2113
+ panelNote('error', `${ticket.id} was not changed: ${(error && error.message) || error}`);
2114
+ return;
2115
+ }
2116
+ if (!response.ok || !body || body.ok !== true) {
2117
+ panelNote('error', `${ticket.id} was not changed — ${(body && body.error) || `the server answered ${response.status}`}`);
2118
+ return;
2119
+ }
2120
+ panelNote('info', `${ticket.id} is now ${statusLabel(body.status)}.`);
2121
+ say('ok', `${ticket.id} → ${body.status}`);
2122
+ // Re-read rather than patch the array: the panel then shows what the file says, which
2123
+ // is the same discipline the endpoint itself uses before it answers.
2124
+ await loadBoard({ quiet: true });
2125
+ }
2126
+
2127
+ /* ---------- opening and closing ---------- */
2128
+
2129
+ /**
2130
+ * The panel and the picker are both allowed to be open, and that is a decision rather than
2131
+ * an oversight: the flow this is built for is read a ticket, look at the element, leave the
2132
+ * next comment, and a panel that closed the picker would throw away a held selection and
2133
+ * the words already typed into the comment box.
2134
+ *
2135
+ * What they do NOT share is clicks. The pick surface covers the viewport while picking, the
2136
+ * panel sits above it so its own controls stay usable, and the markers stop taking clicks
2137
+ * for as long as the picker is armed (`.pin-markers[data-picking]`) — so a click on the page
2138
+ * during a pick always pins the element underneath and never opens a ticket.
2139
+ *
2140
+ * And the panel never takes focus while a comment is being written. A voice dictation tool types
2141
+ * into whatever is focused, so stealing focus mid-sentence would put the next words somewhere
2142
+ * they cannot be seen.
2143
+ */
2144
+ function openPanel({ focus = true } = {}) {
2145
+ const first = !panelOpen;
2146
+ panelOpen = true;
2147
+ panel.hidden = false;
2148
+ updateTicketsButton();
2149
+ fitPanelAroundWindow();
2150
+ if (first || !panelLoadedOnce) loadBoard({ quiet: panelLoadedOnce });
2151
+ handFocusBack(() => {
2152
+ if (!focus) return;
2153
+ const tab = pui.tabs.find((t) => t.dataset.pTab === panelTab);
2154
+ if (tab) tab.focus();
2155
+ });
2156
+ }
2157
+
2158
+ /**
2159
+ * Where focus goes after a panel control is used.
2160
+ *
2161
+ * While a selection is held the comment box owns focus and gets it straight back, caret and
2162
+ * all — refocusing a textarea restores its own selection range, so a dictated sentence
2163
+ * carries on where it stopped. A voice dictation tool types into whatever is focused, so a panel
2164
+ * that kept focus after a click would put the next words into a button. This is the same move
2165
+ * `climbTo` already makes for the same reason, and the cost is deliberate: while a comment is
2166
+ * being written the tabs cannot be walked with the keyboard. The box wins.
2167
+ */
2168
+ function handFocusBack(otherwise) {
2169
+ if (mode === 'selected') {
2170
+ // Next frame, not this one. A real mouse click assigns focus to the control that was
2171
+ // clicked AFTER the click handler has run, so focusing here is undone a moment later
2172
+ // and the caret lands on nothing — measured 2026-09-22: a tab switch driven by a real
2173
+ // click left document.activeElement on <body> while the same switch driven by
2174
+ // .click() left it on the comment box. select() already defers for this reason.
2175
+ requestAnimationFrame(focusComment);
2176
+ return;
2177
+ }
2178
+ if (typeof otherwise === 'function') otherwise();
2179
+ }
2180
+
2181
+ function closePanel() {
2182
+ panelOpen = false;
2183
+ panel.hidden = true;
2184
+ updateTicketsButton();
2185
+ }
2186
+
2187
+ function togglePanel() {
2188
+ if (panelOpen) closePanel();
2189
+ else openPanel();
2190
+ }
2191
+
2192
+ function updateTicketsButton() {
2193
+ const open = panelTickets.filter((t) => isOpenStatus(t.status)).length;
2194
+ ui.tickets.textContent = panelLoadedOnce ? `Tickets · ${open}` : 'Tickets';
2195
+ ui.tickets.setAttribute('button-style', panelOpen ? 'purple' : 'ghost');
2196
+ }
2197
+
2198
+ /* ---------- wiring ---------- */
2199
+
2200
+ ui.tickets.addEventListener('click', togglePanel);
2201
+
2202
+ /**
2203
+ * Open the board in its own tab — a button in the Astro widget that opens the pin tab,
2204
+ * added 2026-09-22.
2205
+ *
2206
+ * The window is NAMED, so a second click focuses the tab that is already open instead of
2207
+ * stacking another copy of the board — "the pin tab", singular, which is the whole point
2208
+ * of it. The route is the resolved one (› BOARD_ROUTE), so this cannot open the wrong path on
2209
+ * a site that moved the board.
2210
+ *
2211
+ * Focus goes back to the comment box when one is being written: `window.open` takes focus
2212
+ * to the new tab, and returning to this one would otherwise leave the caret nowhere and
2213
+ * dictation typing into a button — the same reason `handFocusBack` exists for the panel.
2214
+ */
2215
+ ui.openBoard.title = `Open ${BOARD_ROUTE} in its own tab`;
2216
+ ui.openBoard.addEventListener('click', () => {
2217
+ window.open(BOARD_ROUTE, 'orbytes-pin-board');
2218
+ handFocusBack();
2219
+ });
2220
+ panel.querySelector('[data-p-close]').addEventListener('click', () => {
2221
+ closePanel();
2222
+ handFocusBack();
2223
+ });
2224
+ panel.querySelector('[data-p-refresh]').addEventListener('click', () => {
2225
+ loadBoard();
2226
+ handFocusBack();
2227
+ });
2228
+ for (const tab of pui.tabs) {
2229
+ tab.addEventListener('click', () => {
2230
+ panelTab = tab.dataset.pTab;
2231
+ // Not `expandedId = null`: an entry stays open across a tab switch, and nothing here
2232
+ // touches the comment box either. Switching tabs is a view change, not a reset.
2233
+ renderPanel();
2234
+ syncMarkers();
2235
+ handFocusBack();
2236
+ });
2237
+ }
2238
+ if (pui.showResolved.input) {
2239
+ pui.showResolved.input.checked = showResolvedMarkers;
2240
+ pui.showResolved.input.addEventListener('change', () => {
2241
+ showResolvedMarkers = Boolean(pui.showResolved.input.checked);
2242
+ writeShowResolved(showResolvedMarkers);
2243
+ syncMarkers();
2244
+ });
2245
+ }
2246
+ pui.showResolvedLabel.addEventListener('click', () => {
2247
+ if (!pui.showResolved.input) return;
2248
+ pui.showResolved.input.checked = !pui.showResolved.input.checked;
2249
+ pui.showResolved.input.dispatchEvent(new Event('change'));
2250
+ });
2251
+ // Hovering the panel is not hovering the page — drop the pick highlight so the crosshair
2252
+ // chrome is not left painted over whatever the cursor last crossed.
2253
+ panel.addEventListener('mouseenter', () => {
2254
+ if (mode !== 'picking') return;
2255
+ hoverEl = null;
2256
+ hoverFrames = [];
2257
+ hideChrome(hoverHighlight);
2258
+ hideChip();
2259
+ });
2260
+
2261
+ /* ---------- mode ---------- */
2262
+
2263
+ function setMode(next) {
2264
+ mode = next;
2265
+ ui.badge.textContent = next;
2266
+ ui.badge.setAttribute(
2267
+ 'badge-style',
2268
+ next === 'picking' ? 'blue' : next === 'selected' ? 'purple' : 'gray',
2269
+ );
2270
+ ui.form.hidden = next !== 'selected';
2271
+ ui.idle.hidden = next !== 'idle';
2272
+ // The window changes height between modes, so the panel re-measures against it.
2273
+ requestAnimationFrame(fitPanelAroundWindow);
2274
+ ui.hint.innerHTML =
2275
+ next === 'picking'
2276
+ ? 'Move the cursor over the page — click to pin that element. <span class="pin-kbd">Esc</span> cancels.'
2277
+ : next === 'selected'
2278
+ ? 'Comment goes in verbatim. <span class="pin-kbd">⌥ ↑</span> pins the parent instead, <span class="pin-kbd">⌥ ↓</span> comes back — or click the trail above. <span class="pin-kbd">⌘ Enter</span> creates the ticket, <span class="pin-kbd">Esc</span> keeps what you have written — Cancel discards it.'
2279
+ : 'Nothing selected.';
2280
+
2281
+ if (next === 'picking') {
2282
+ if (!surface.isConnected) canvas.append(surface);
2283
+ hideChrome(hoverHighlight);
2284
+ // The picker owns clicks while it is armed — a marker must not swallow one that
2285
+ // was meant to pin the element underneath it.
2286
+ markerLayer.setAttribute('data-picking', '');
2287
+ } else {
2288
+ markerLayer.removeAttribute('data-picking');
2289
+ surface.remove();
2290
+ hoverEl = null;
2291
+ hoverFrames = [];
2292
+ hideChrome(hoverHighlight);
2293
+ hideChip();
2294
+ }
2295
+ if (next !== 'selected') {
2296
+ selectedEl = null;
2297
+ selectedFrames = [];
2298
+ chain = [];
2299
+ climbIndex = 0;
2300
+ clearCrumbs();
2301
+ hideChrome(selectedHighlight);
2302
+ }
2303
+ }
2304
+
2305
+ function hideChrome(el) {
2306
+ el.style.display = 'none';
2307
+ }
2308
+
2309
+ function hideChip() {
2310
+ chip.style.display = 'none';
2311
+ }
2312
+
2313
+ /* ---------- highlight placement ---------- */
2314
+
2315
+ /**
2316
+ * Both highlights are painted in the TOP document, so an element inside a frame has its
2317
+ * box mapped back out through every scale in the chain before it is positioned.
2318
+ * Fixed-ness is judged on the OUTERMOST frame, not the element: `position: fixed` inside
2319
+ * a frame is fixed to that frame, and the frame scrolls with the page like anything else.
2320
+ */
2321
+ function place(highlight, el, frames) {
2322
+ if (!el || !el.isConnected) {
2323
+ hideChrome(highlight);
2324
+ return null;
2325
+ }
2326
+ const rect = viewportRect(el, frames);
2327
+ if (!rect || (rect.width === 0 && rect.height === 0)) {
2328
+ hideChrome(highlight);
2329
+ return null;
2330
+ }
2331
+ const { isFixed } = getElementsPositionInDocument(frames && frames.length ? frames[0] : el);
2332
+ highlight.style.position = isFixed ? 'fixed' : 'absolute';
2333
+ positionHighlight(highlight, rect);
2334
+ return { rect, isFixed };
2335
+ }
2336
+
2337
+ function placeChip(el, frames, text, unresolved) {
2338
+ const rect = viewportRect(el, frames);
2339
+ if (!rect) {
2340
+ hideChip();
2341
+ return;
2342
+ }
2343
+ const { isFixed } = getElementsPositionInDocument(frames && frames.length ? frames[0] : el);
2344
+ const scrollX = isFixed ? 0 : window.scrollX;
2345
+ const scrollY = isFixed ? 0 : window.scrollY;
2346
+ chip.style.position = isFixed ? 'fixed' : 'absolute';
2347
+ chip.style.display = 'block';
2348
+ chip.style.left = `${Math.max(rect.left + scrollX - 10, 0)}px`;
2349
+ // Sit above the box when there is room, otherwise tuck inside its top edge.
2350
+ const room = rect.top > 32;
2351
+ chip.style.top = `${Math.max(rect.top + scrollY - (room ? 14 : -14), 0)}px`;
2352
+ chip.style.transform = room ? 'translateY(-100%)' : 'none';
2353
+ chip.textContent = text;
2354
+ chip.style.borderColor = unresolved ? 'rgba(181, 138, 45, 1)' : 'rgba(113, 24, 226, 1)';
2355
+ chip.style.color = unresolved ? 'rgba(255, 236, 179, 1)' : '#fff';
2356
+ }
2357
+
2358
+ /** The chip's label: the source file, marked when the element is inside a story frame. */
2359
+ function chipText(source, frames) {
2360
+ const label = source || 'unresolved';
2361
+ return frames && frames.length ? `\u21b3 ${label}` : label;
2362
+ }
2363
+
2364
+ function reposition() {
2365
+ if (mode === 'picking' && hoverEl) {
2366
+ place(hoverHighlight, hoverEl, hoverFrames);
2367
+ const { source } = resolveSource(hoverEl);
2368
+ placeChip(hoverEl, hoverFrames, chipText(source, hoverFrames), !source);
2369
+ }
2370
+ if (selectedEl) place(selectedHighlight, selectedEl, selectedFrames);
2371
+ placeMarkers();
2372
+ fitPanelAroundWindow();
2373
+ }
2374
+
2375
+ /**
2376
+ * A frame scrolls and resizes independently of the page and neither event reaches the top
2377
+ * window, so without this the highlight would sit where the element used to be. Added the
2378
+ * first time the hit test enters a given frame window; the WeakSet keeps it to once.
2379
+ */
2380
+ const watchedWindows = new WeakSet();
2381
+ function watchFrameWindow(win) {
2382
+ if (!win || watchedWindows.has(win)) return;
2383
+ watchedWindows.add(win);
2384
+ for (const event of ['scroll', 'resize']) {
2385
+ try {
2386
+ win.addEventListener(event, reposition, { passive: true });
2387
+ } catch {
2388
+ /* the frame went away between the hit test and here */
2389
+ }
2390
+ }
2391
+ }
2392
+
2393
+ /* ---------- hit testing ---------- */
2394
+
2395
+ /** Chrome this app must never pick, in whichever document it turns up. */
2396
+ function isChrome(el) {
2397
+ if (ownHosts.includes(el)) return true;
2398
+ if (el.hasAttribute && el.hasAttribute('data-orbytes-pin-ui')) return true;
2399
+ // Every story frame is served by this same dev server, so each one carries its OWN
2400
+ // dev toolbar at the bottom of its viewport. Picking it would pin Astro's chrome.
2401
+ if (el.tagName === 'ASTRO-DEV-TOOLBAR') return true;
2402
+ return false;
2403
+ }
2404
+
2405
+ function pickIn(doc, x, y) {
2406
+ for (const el of doc.elementsFromPoint(x, y)) {
2407
+ if (!el || el.nodeType !== 1) continue;
2408
+ if (isChrome(el)) continue;
2409
+ if (el.tagName === 'HTML') continue;
2410
+ return el;
2411
+ }
2412
+ return null;
2413
+ }
2414
+
2415
+ /**
2416
+ * The deepest REAL element under a point, plus the chain of same-origin iframes it lives
2417
+ * inside (outermost first) and a plain sentence when a frame could not be entered.
2418
+ *
2419
+ * `x`/`y` arrive in the top document's viewport coordinates and are re-expressed at every
2420
+ * hop. A frame that cannot be entered — cross-origin, rotated, or zero-sized — is
2421
+ * returned as the pick itself with the reason attached, so the ticket still describes
2422
+ * something real and the panel says why it is not the thing underneath.
2423
+ *
2424
+ * @returns {{ el: Element|null, frames: Element[], note: string|null }}
2425
+ */
2426
+ function hitTest(x, y) {
2427
+ let doc = document;
2428
+ let px = x;
2429
+ let py = y;
2430
+ const frames = [];
2431
+ const upOne = (note) => ({ el: frames[frames.length - 1], frames: frames.slice(0, -1), note });
2432
+
2433
+ for (let depth = 0; depth <= MAX_FRAME_DEPTH; depth++) {
2434
+ const el = pickIn(doc, px, py);
2435
+ if (!el) {
2436
+ // Nothing in this document answers to the point. Inside a frame that means
2437
+ // the frame's own padding or scrollbar, so the frame is the honest pick.
2438
+ return frames.length ? upOne(null) : { el: null, frames: [], note: null };
2439
+ }
2440
+
2441
+ const frame = frameUnder(el, px, py);
2442
+ if (!frame) return { el, frames: frames.slice(), note: null };
2443
+
2444
+ const inner = frameDocument(frame);
2445
+ if (!inner) {
2446
+ return {
2447
+ el: frame,
2448
+ frames: frames.slice(),
2449
+ note: 'That frame is cross-origin, so nothing inside it can be read — the ticket will describe the frame itself.',
2450
+ };
2451
+ }
2452
+ const geo = frameGeometry(frame);
2453
+ if (!geo) {
2454
+ return {
2455
+ el: frame,
2456
+ frames: frames.slice(),
2457
+ note: 'That frame is rotated, mirrored or has no size, so a point cannot be translated into it — the ticket will describe the frame itself.',
2458
+ };
2459
+ }
2460
+ const win = inner.defaultView;
2461
+ const cx = (px - geo.left) / geo.scaleX;
2462
+ const cy = (py - geo.top) / geo.scaleY;
2463
+ if (!win || cx < 0 || cy < 0 || cx > win.innerWidth || cy > win.innerHeight) {
2464
+ return { el: frame, frames: frames.slice(), note: null };
2465
+ }
2466
+
2467
+ watchFrameWindow(win);
2468
+ frames.push(frame);
2469
+ doc = inner;
2470
+ px = cx;
2471
+ py = cy;
2472
+ }
2473
+ return upOne(`Frames are nested more than ${MAX_FRAME_DEPTH} deep here, so the hit test stopped climbing in.`);
2474
+ }
2475
+
2476
+ function onPointerMove(event) {
2477
+ lastPointer = { x: event.clientX, y: event.clientY };
2478
+ if (rafPending) return;
2479
+ rafPending = true;
2480
+ requestAnimationFrame(() => {
2481
+ rafPending = false;
2482
+ if (mode !== 'picking') return;
2483
+ const hit = hitTest(lastPointer.x, lastPointer.y);
2484
+ if (!hit.el) {
2485
+ hoverEl = null;
2486
+ hoverFrames = [];
2487
+ hideChrome(hoverHighlight);
2488
+ hideChip();
2489
+ return;
2490
+ }
2491
+ hoverEl = hit.el;
2492
+ hoverFrames = hit.frames;
2493
+ // sayOnce, not say: a hover over an unreadable frame fires every frame otherwise.
2494
+ if (hit.note) sayOnce('warn', hit.note);
2495
+ place(hoverHighlight, hoverEl, hoverFrames);
2496
+ const { source, error } = resolveSource(hoverEl);
2497
+ if (error) say('error', error);
2498
+ placeChip(hoverEl, hoverFrames, chipText(source, hoverFrames), !source);
2499
+ });
2500
+ }
2501
+
2502
+ function onSurfaceClick(event) {
2503
+ // The surface exists so a click never reaches the page underneath — a pin on a
2504
+ // link must not navigate away from the thing being pinned.
2505
+ event.preventDefault();
2506
+ event.stopPropagation();
2507
+ if (mode !== 'picking') return;
2508
+ const hit = hitTest(event.clientX, event.clientY);
2509
+ if (!hit.el) {
2510
+ // info, not warn: nothing failed, so this must not be one of the lines that stays
2511
+ // on screen until it is dismissed.
2512
+ say('info', 'Nothing under the cursor there.');
2513
+ return;
2514
+ }
2515
+ if (hit.note) say('warn', hit.note);
2516
+ select(hit.el, hit.frames);
2517
+ }
2518
+
2519
+ surface.addEventListener('mousemove', onPointerMove, { passive: true });
2520
+ surface.addEventListener('click', onSurfaceClick, true);
2521
+ surface.addEventListener('contextmenu', (event) => event.preventDefault());
2522
+
2523
+ /* ---------- selection ---------- */
2524
+
2525
+ /**
2526
+ * Where the climb stops: an element that IS the page rather than something on it.
2527
+ *
2528
+ * Two tests, because a page has two ways of being its own wrapper.
2529
+ * · **By tag** — `<body>`, `<html>`, and `<main>`, which is the content region by
2530
+ * definition. A ticket against one of them names no component and says nothing a
2531
+ * screenshot of the page would not.
2532
+ * · **By size** — a `div` that happens to wrap everything is the page in a different
2533
+ * costume, so anything covering essentially the whole scroll height is out too.
2534
+ * Measured on `/` before this test existed: climbing three levels off a text block
2535
+ * reached `#main` and produced a real ticket whose rect was 1440 × 7477 and whose
2536
+ * screenshot was a 2.2 MB photograph of the entire home page.
2537
+ *
2538
+ * The size test only applies on a page meaningfully taller than the viewport. On a short
2539
+ * page — a lab story, which is one section and nothing else — the outermost section IS
2540
+ * most of the document, and climbing to it is exactly what the story's owner wants; there
2541
+ * the whole-page rect it would produce is a viewport-sized picture and costs nothing.
2542
+ */
2543
+ function isThePageItself(node) {
2544
+ const tag = node.tagName;
2545
+ if (tag === 'BODY' || tag === 'HTML' || tag === 'MAIN') return true;
2546
+ const doc = node.ownerDocument;
2547
+ const view = doc.defaultView;
2548
+ const pageHeight = doc.documentElement ? doc.documentElement.scrollHeight : 0;
2549
+ if (!view || !pageHeight || pageHeight < view.innerHeight * 1.5) return false;
2550
+ return node.getBoundingClientRect().height >= pageHeight * 0.95;
2551
+ }
2552
+
2553
+ /**
2554
+ * What was clicked, then every ancestor worth pinning, innermost first.
2555
+ *
2556
+ * The walk stays inside the element's OWN document, so a pick inside a lab story climbs
2557
+ * to that story's outermost section and no further — never out through the iframe into
2558
+ * the shell around it, which is a different page with a different url and rect.
2559
+ */
2560
+ function ancestorChain(el) {
2561
+ const out = [el];
2562
+ for (let node = el.parentElement; node; node = node.parentElement) {
2563
+ if (isThePageItself(node)) break;
2564
+ out.push(node);
2565
+ }
2566
+ return out;
2567
+ }
2568
+
2569
+ /** A crumb's label: enough to recognise the element, short enough to keep one row. */
2570
+ function crumbLabel(node) {
2571
+ let label = node.tagName.toLowerCase();
2572
+ const [first] = stableClasses(node);
2573
+ if (first) label += `.${first}`;
2574
+ return label;
2575
+ }
2576
+
2577
+ function clearCrumbs() {
2578
+ if (!ui.crumbs) return;
2579
+ ui.crumbs.innerHTML = '';
2580
+ ui.crumbs.hidden = true;
2581
+ }
2582
+
2583
+ /** The trail, outermost first so it reads in the same direction as the selector. */
2584
+ function renderCrumbs() {
2585
+ ui.crumbs.innerHTML = '';
2586
+ if (chain.length < 2) {
2587
+ ui.crumbs.hidden = true;
2588
+ return;
2589
+ }
2590
+ ui.crumbs.hidden = false;
2591
+ for (let index = chain.length - 1; index >= 0; index--) {
2592
+ const node = chain[index];
2593
+ const button = document.createElement('button');
2594
+ button.type = 'button';
2595
+ button.textContent = crumbLabel(node);
2596
+ button.title = `${cssPathOf(node)}\n${resolveSource(node).source || 'unresolved'}`;
2597
+ if (index === climbIndex) button.setAttribute('data-current', '');
2598
+ button.addEventListener('click', () => climbTo(index));
2599
+ // Hovering a crumb paints the blue hover box over that element, so the parent can
2600
+ // be SEEN before it is committed to. Nothing else uses that highlight while a
2601
+ // selection is held, so it is free.
2602
+ button.addEventListener('mouseenter', () => {
2603
+ if (mode === 'selected') place(hoverHighlight, node, selectedFrames);
2604
+ });
2605
+ button.addEventListener('mouseleave', () => hideChrome(hoverHighlight));
2606
+ ui.crumbs.append(button);
2607
+ if (index > 0) {
2608
+ const sep = document.createElement('span');
2609
+ sep.className = 'sep';
2610
+ sep.textContent = '›';
2611
+ ui.crumbs.append(sep);
2612
+ }
2613
+ }
2614
+ const current = ui.crumbs.querySelector('[data-current]');
2615
+ if (current && current.scrollIntoView) {
2616
+ current.scrollIntoView({ block: 'nearest', inline: 'nearest' });
2617
+ }
2618
+ }
2619
+
2620
+ /**
2621
+ * Paint whatever level the climb is on. Everything visible about the target moves
2622
+ * together — the purple box, the source file, the selector, the url and the trail — and
2623
+ * the comment box is deliberately not among them: the words in it are yours, they survive
2624
+ * every climb, and the only control that throws them away is still Cancel.
2625
+ */
2626
+ function applySelection() {
2627
+ const el = chain[climbIndex];
2628
+ if (!el) return;
2629
+ selectedEl = el;
2630
+ hideChrome(hoverHighlight);
2631
+ place(selectedHighlight, el, selectedFrames);
2632
+
2633
+ const { source, error } = resolveSource(el);
2634
+ if (error) say('error', error);
2635
+ ui.src.textContent = source || 'unresolved';
2636
+ if (source) ui.src.removeAttribute('data-unresolved');
2637
+ else ui.src.setAttribute('data-unresolved', '');
2638
+ ui.sel.textContent = cssPathOf(el);
2639
+ ui.url.textContent = documentPath(el);
2640
+ renderCrumbs();
2641
+ }
2642
+
2643
+ /**
2644
+ * Move the pin up or down the chain.
2645
+ *
2646
+ * The keys are ⌥↑ / ⌥↓ rather than the bare arrows devtools uses, for one reason: the
2647
+ * comment box is focused the whole time and the bare arrows belong to it — taking them
2648
+ * would break moving the caret through dictated text, which is the flow this panel is
2649
+ * built around. Focus is handed straight back to the box afterwards, so a climb never
2650
+ * interrupts dictation.
2651
+ */
2652
+ function climbTo(index) {
2653
+ if (mode !== 'selected' || !chain.length) return;
2654
+ const next = Math.max(0, Math.min(chain.length - 1, index));
2655
+ if (next === climbIndex) {
2656
+ if (index > climbIndex) {
2657
+ sayOnce('info', 'That is as far out as this goes — the page itself is not a pin target.');
2658
+ } else if (index < climbIndex) {
2659
+ sayOnce('info', 'Already on the element you clicked.');
2660
+ }
2661
+ focusComment();
2662
+ return;
2663
+ }
2664
+ climbIndex = next;
2665
+ // A parent often lives in a DIFFERENT component — this is how a pin on a button
2666
+ // becomes a pin on the section that owns it. applySelection() rewrites #pin-src with
2667
+ // the new file, so the change is already on screen; the line that used to narrate it
2668
+ // here was the log repeating the target box.
2669
+ applySelection();
2670
+ focusComment();
2671
+ }
2672
+
2673
+ function select(el, frames) {
2674
+ selectedFrames = frames || [];
2675
+ chain = ancestorChain(el);
2676
+ climbIndex = 0;
2677
+ setMode('selected');
2678
+ applySelection();
2679
+ if (selectedFrames.length) {
2680
+ say('info', `Inside a frame — this ticket describes ${documentPath(el)}, not the page around it.`);
2681
+ }
2682
+
2683
+ // A voice dictation tool types into whatever is focused — the box has to be live
2684
+ // the instant the panel opens, with no extra click.
2685
+ requestAnimationFrame(focusComment);
2686
+ }
2687
+
2688
+ /** preventScroll is not universal; losing the box is worse than a jump, so fall back. */
2689
+ function focusComment() {
2690
+ try {
2691
+ ui.comment.focus({ preventScroll: true });
2692
+ } catch {
2693
+ ui.comment.focus();
2694
+ }
2695
+ }
2696
+
2697
+ function clearSelection() {
2698
+ selectedEl = null;
2699
+ selectedFrames = [];
2700
+ chain = [];
2701
+ climbIndex = 0;
2702
+ clearCrumbs();
2703
+ hideChrome(selectedHighlight);
2704
+ hideChrome(hoverHighlight);
2705
+ ui.comment.value = '';
2706
+ }
2707
+
2708
+ /* ---------- payload + send ---------- */
2709
+
2710
+ /**
2711
+ * Every field below is read from the document the element ACTUALLY lives in — its own
2712
+ * window — never from the page this toolbar is running in. On the lab that is the story
2713
+ * route inside the frame, and the four that travel together have to agree or the ticket
2714
+ * is worse than useless:
2715
+ *
2716
+ * url the page shot.mjs re-loads to take the picture;
2717
+ * rect document coordinates OF THAT PAGE, which the screenshot clips against;
2718
+ * viewport the width the page is re-loaded at, so it lays out as it was seen;
2719
+ * scroll where that page was scrolled to, so scroll-reveals have fired.
2720
+ *
2721
+ * This is also why the parent's scale needs no correction here. A CSS transform on a
2722
+ * frame scales what is drawn, not what is laid out: the content window's `innerWidth`
2723
+ * and a rect measured inside it are in unscaled CSS px whatever the parent did to them.
2724
+ */
2725
+ function buildPayload(el, comment) {
2726
+ const { source } = resolveSource(el);
2727
+ const win = el.ownerDocument.defaultView;
2728
+ return {
2729
+ comment,
2730
+ dispatch: ui.dispatch.input && ui.dispatch.input.checked ? 'now' : 'queue',
2731
+ source,
2732
+ selector: cssPathOf(el),
2733
+ url: win.location.pathname + win.location.search,
2734
+ viewport: {
2735
+ width: win.innerWidth,
2736
+ height: win.innerHeight,
2737
+ dpr: win.devicePixelRatio,
2738
+ },
2739
+ scroll: { x: Math.round(win.scrollX), y: Math.round(win.scrollY) },
2740
+ rect: docRect(el, win.scrollX, win.scrollY),
2741
+ outerHTML: truncate(el.outerHTML, OUTER_HTML_LIMIT),
2742
+ };
2743
+ }
2744
+
2745
+ function setBusy(next) {
2746
+ busy = next;
2747
+ if (next) ui.form.setAttribute('data-busy', '');
2748
+ else ui.form.removeAttribute('data-busy');
2749
+ }
2750
+
2751
+ function submit() {
2752
+ if (busy) return;
2753
+ const comment = ui.comment.value;
2754
+ if (!comment.trim()) {
2755
+ say('error', 'Nothing to send — the comment is empty.');
2756
+ ui.comment.focus();
2757
+ return;
2758
+ }
2759
+ if (!selectedEl || !selectedEl.isConnected) {
2760
+ say('error', 'That element has left the page. Pick it again.');
2761
+ setMode('picking');
2762
+ return;
2763
+ }
2764
+ if (selectedFrames.length && !chainGeometry(selectedFrames)) {
2765
+ say('error', 'The frame that element was in has reloaded or moved. Pick it again.');
2766
+ setMode('picking');
2767
+ return;
2768
+ }
2769
+
2770
+ let payload;
2771
+ try {
2772
+ payload = buildPayload(selectedEl, comment);
2773
+ } catch (error) {
2774
+ say('error', `Could not describe that element: ${error && error.message}`);
2775
+ return;
2776
+ }
2777
+
2778
+ // No "Sending to the dev server…" here. The form goes [data-busy] on the same tick and
2779
+ // the reply lands in under 30ms — a line saying a thing is happening would be replaced
2780
+ // by the line saying it happened before it could be read.
2781
+ setBusy(true);
2782
+ rememberPin(payload.selector, selectedFrames);
2783
+ server.send('orbytes-pin:create', payload);
2784
+
2785
+ clearTimeout(replyTimer);
2786
+ replyTimer = setTimeout(() => {
2787
+ if (!busy) return;
2788
+ setBusy(false);
2789
+ say(
2790
+ 'error',
2791
+ 'No reply from the dev server after 8 seconds. Is the orbytes-pin integration loaded?',
2792
+ );
2793
+ }, REPLY_TIMEOUT_MS);
2794
+ }
2795
+
2796
+ server.on('orbytes-pin:created', (data) => {
2797
+ clearTimeout(replyTimer);
2798
+ setBusy(false);
2799
+ if (!data) {
2800
+ say('error', 'The dev server replied with nothing at all.');
2801
+ return;
2802
+ }
2803
+ if (data.error) {
2804
+ say('error', `Ticket refused: ${data.error}`);
2805
+ return;
2806
+ }
2807
+ say('ok', `${data.id} written to ${data.file} — screenshot pending.`);
2808
+ clearSelection();
2809
+ setMode('picking');
2810
+ loadBoard({ quiet: true });
2811
+ });
2812
+
2813
+ /**
2814
+ * The screenshot reply, which also carries the one thing this page cannot work out for
2815
+ * itself: how many elements the selector finds on a GENUINELY fresh load. Here, the
2816
+ * selector was just built against this very DOM and was verified unique before it was
2817
+ * sent, so it always looks healthy — that is exactly how a carousel pin used to ship a
2818
+ * selector that resolved to one node when written and none ever again, with the source
2819
+ * file, the screenshot and the outerHTML all perfectly correct and nothing saying so.
2820
+ *
2821
+ * `selectorMatches` is `1` when healthy, `0` when the selector died on reload, more than
2822
+ * one when it names several elements, and `null` when the question could not be asked
2823
+ * (no screenshot, or shots turned off). Anything but `1` is said out loud, because the
2824
+ * ticket is still workable by hand and you should know which kind you have.
2825
+ */
2826
+ server.on('orbytes-pin:shot', (data) => {
2827
+ if (!data) return;
2828
+ if (data.error) say('warn', `${data.id || 'Screenshot'} has no image yet: ${data.error}`);
2829
+ else say('ok', `${data.id} screenshot landed at ${data.shot}`);
2830
+ loadBoard({ quiet: true });
2831
+
2832
+ const matches = data.selectorMatches;
2833
+ if (matches === 0) {
2834
+ say(
2835
+ 'warn',
2836
+ `${data.id || 'That pin'}: the selector finds nothing when the page is reloaded — this element is generated, so the ticket has to be worked from its source file, screenshot and HTML snippet instead.`,
2837
+ );
2838
+ } else if (typeof matches === 'number' && matches > 1) {
2839
+ say(
2840
+ 'warn',
2841
+ `${data.id || 'That pin'}: the selector finds ${matches} elements on a reload, not one — the ticket says which, but it does not point at a single element.`,
2842
+ );
2843
+ }
2844
+ });
2845
+
2846
+ /* ---------- controls ---------- */
2847
+
2848
+ ui.submit.addEventListener('click', submit);
2849
+ ui.start.addEventListener('click', () => setMode('picking'));
2850
+ ui.again.addEventListener('click', () => {
2851
+ clearSelection();
2852
+ setMode('picking');
2853
+ });
2854
+ ui.cancel.addEventListener('click', () => {
2855
+ clearSelection();
2856
+ setMode('idle');
2857
+ });
2858
+ ui.comment.addEventListener('keydown', (event) => {
2859
+ if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
2860
+ event.preventDefault();
2861
+ submit();
2862
+ }
2863
+ });
2864
+ // `change` does not cross a shadow boundary, so listen on the toggle's own input
2865
+ // (ui-library/toggle.js creates it in the constructor and appends it on connect).
2866
+ if (ui.dispatch.input) {
2867
+ ui.dispatch.input.addEventListener('change', () => {
2868
+ ui.dispatchLabel.textContent = ui.dispatch.input.checked ? 'now' : 'queue';
2869
+ });
2870
+ }
2871
+
2872
+ /* ---------- escape ---------- */
2873
+
2874
+ // The toolbar itself closes the active app on a document `keyup` for Escape
2875
+ // (toolbar.js:326-335). While picking or holding a selection that would throw away
2876
+ // dictated text, so consume the key here in the capture phase and swallow the
2877
+ // matching keyup. When neither applies, Escape still closes the app as usual.
2878
+ //
2879
+ // Stopping the close was only half of it. Escape is the key a person reaches for to
2880
+ // dismiss a dictation overlay or a stray autocomplete, so it lands on a FULL comment
2881
+ // box constantly — and a dictated paragraph cannot be retyped from memory. So Escape
2882
+ // never discards text: with anything in the box it holds the selection, the words and
2883
+ // the caret, and points at Cancel, which is the one control that discards on purpose.
2884
+ function onKeydown(event) {
2885
+ // The climb. Capture phase and preventDefault, because ⌥↑ in a textarea is
2886
+ // "move the caret to the start of the paragraph" on macOS and would otherwise
2887
+ // scramble the dictated comment while the selection moved.
2888
+ if (
2889
+ mode === 'selected' &&
2890
+ event.altKey &&
2891
+ !event.metaKey &&
2892
+ !event.ctrlKey &&
2893
+ (event.key === 'ArrowUp' || event.key === 'ArrowDown')
2894
+ ) {
2895
+ event.preventDefault();
2896
+ event.stopPropagation();
2897
+ climbTo(climbIndex + (event.key === 'ArrowUp' ? 1 : -1));
2898
+ return;
2899
+ }
2900
+ if (event.key !== 'Escape') return;
2901
+ if (mode === 'picking') {
2902
+ event.preventDefault();
2903
+ event.stopPropagation();
2904
+ swallowEscapeKeyup = true;
2905
+ // The badge and the hint line both change on setMode — saying it again in words
2906
+ // was the log talking to itself.
2907
+ setMode(selectedEl ? 'selected' : 'idle');
2908
+ } else if (mode === 'selected') {
2909
+ event.preventDefault();
2910
+ event.stopPropagation();
2911
+ swallowEscapeKeyup = true;
2912
+ if (ui.comment.value.trim()) {
2913
+ focusComment();
2914
+ sayOnce('info', 'Your comment is still here — click Cancel to discard it.');
2915
+ return;
2916
+ }
2917
+ clearSelection();
2918
+ setMode('idle');
2919
+ } else if (panelOpen) {
2920
+ // Idle, with the panel open. Escape peels one layer at a time — the expanded entry,
2921
+ // then the panel — and only once both are shut does it reach Astro's own
2922
+ // close-the-app handler, which is the behaviour from before the panel existed.
2923
+ //
2924
+ // This branch is deliberately LAST. While something is being picked or written the
2925
+ // two branches above own the key, because the comment box is the thing a stray
2926
+ // Escape must never cost: a dictated paragraph cannot be retyped from memory.
2927
+ event.preventDefault();
2928
+ event.stopPropagation();
2929
+ swallowEscapeKeyup = true;
2930
+ // Each branch is visible the instant it runs — the entry folds, or the rail goes.
2931
+ // Narrating it was the log describing the screen to the person looking at it.
2932
+ if (expandedId) {
2933
+ expandedId = null;
2934
+ renderPanel();
2935
+ syncMarkers();
2936
+ } else {
2937
+ closePanel();
2938
+ }
2939
+ }
2940
+ }
2941
+
2942
+ function onKeyup(event) {
2943
+ if (event.key !== 'Escape' || !swallowEscapeKeyup) return;
2944
+ swallowEscapeKeyup = false;
2945
+ event.stopImmediatePropagation();
2946
+ }
2947
+
2948
+ document.addEventListener('keydown', onKeydown, true);
2949
+ document.addEventListener('keyup', onKeyup, true);
2950
+
2951
+ /* ---------- lifecycle ---------- */
2952
+
2953
+ app.onToggled(({ state }) => {
2954
+ if (state) {
2955
+ setMode(selectedEl ? 'selected' : 'picking');
2956
+ // Astro hides an app's whole canvas when the app is toggled off, so the markers go
2957
+ // with it. Coming back is therefore the moment to re-read: tickets may have been
2958
+ // resolved on the board, and the page may have been edited underneath them.
2959
+ loadBoard({ quiet: true });
2960
+ } else {
2961
+ setMode('idle');
2962
+ }
2963
+ });
2964
+
2965
+ for (const event of ['scroll', 'resize']) {
2966
+ window.addEventListener(event, reposition, { passive: true });
2967
+ }
2968
+ document.addEventListener('astro:after-swap', () => {
2969
+ clearSelection();
2970
+ setMode(mode === 'picking' ? 'picking' : 'idle');
2971
+ // Every marker was pointing into the document that has just been replaced.
2972
+ for (const record of markers.values()) record.el.remove();
2973
+ markers.clear();
2974
+ loadBoard({ quiet: true });
2975
+ });
2976
+
2977
+ /* ---------- scroll restore + flash ---------- */
2978
+
2979
+ function currentPath() {
2980
+ return location.pathname + location.search;
2981
+ }
2982
+
2983
+ /**
2984
+ * `frames` is the chain of iframes the element lives inside, stored as one selector per
2985
+ * hop so the restore can walk back in after a reload. A pin taken inside a lab story
2986
+ * would otherwise be looked for in the shell document, where it has never existed.
2987
+ */
2988
+ function rememberPin(selector, frames) {
2989
+ if (!selector) return;
2990
+ try {
2991
+ sessionStorage.setItem(
2992
+ RESTORE_KEY,
2993
+ JSON.stringify({
2994
+ selector,
2995
+ frames: (frames || []).map((frame) => cssPathOf(frame)),
2996
+ path: currentPath(),
2997
+ ts: Date.now(),
2998
+ }),
2999
+ );
3000
+ } catch {
3001
+ /* private mode, storage disabled — the restore is a convenience, not a contract */
3002
+ }
3003
+ }
3004
+
3005
+ /** Walk the stored frame chain back in and find the element. null if any hop is gone. */
3006
+ function resolveRestore(entry) {
3007
+ let doc = document;
3008
+ const frames = [];
3009
+ for (const selector of entry.frames || []) {
3010
+ let frame = null;
3011
+ try {
3012
+ frame = doc.querySelector(selector);
3013
+ } catch {
3014
+ return null; // no longer parses — nothing to say, nothing to do
3015
+ }
3016
+ if (!frame) return null;
3017
+ const inner = frameDocument(frame);
3018
+ if (!inner) return null;
3019
+ frames.push(frame);
3020
+ doc = inner;
3021
+ }
3022
+ let el = null;
3023
+ try {
3024
+ el = doc.querySelector(entry.selector);
3025
+ } catch {
3026
+ return null;
3027
+ }
3028
+ return el ? { el, frames } : null;
3029
+ }
3030
+
3031
+ function readRestore() {
3032
+ try {
3033
+ const raw = sessionStorage.getItem(RESTORE_KEY);
3034
+ if (!raw) return null;
3035
+ const entry = JSON.parse(raw);
3036
+ if (!entry || typeof entry.selector !== 'string') return null;
3037
+ if (entry.frames && !Array.isArray(entry.frames)) return null;
3038
+ if (entry.path !== currentPath()) return null;
3039
+ if (!(Date.now() - Number(entry.ts) < RESTORE_TTL_MS)) return null;
3040
+ return entry;
3041
+ } catch {
3042
+ return null;
3043
+ }
3044
+ }
3045
+
3046
+ function flash(el, frames) {
3047
+ const box = document.createElement('div');
3048
+ box.setAttribute('data-orbytes-pin-ui', 'flash');
3049
+ Object.assign(box.style, {
3050
+ position: 'fixed',
3051
+ pointerEvents: 'none',
3052
+ zIndex: String(Z.flash),
3053
+ borderRadius: '6px',
3054
+ border: '2px solid rgba(113, 24, 226, 1)',
3055
+ background:
3056
+ 'linear-gradient(180deg, rgba(224, 204, 250, 0.33) 0%, rgba(224, 204, 250, 0.08) 100%)',
3057
+ });
3058
+ const put = () => {
3059
+ const r = viewportRect(el, frames);
3060
+ if (!r) return;
3061
+ box.style.left = `${r.left - 4}px`;
3062
+ box.style.top = `${r.top - 4}px`;
3063
+ box.style.width = `${r.width + 8}px`;
3064
+ box.style.height = `${r.height + 8}px`;
3065
+ };
3066
+ put();
3067
+ document.body.append(box);
3068
+ window.addEventListener('scroll', put, { passive: true });
3069
+ const done = () => {
3070
+ window.removeEventListener('scroll', put);
3071
+ box.remove();
3072
+ };
3073
+ if (typeof box.animate === 'function') {
3074
+ const animation = box.animate(
3075
+ [{ opacity: 0 }, { opacity: 1 }, { opacity: 1 }, { opacity: 0 }],
3076
+ { duration: FLASH_MS, easing: 'ease-out' },
3077
+ );
3078
+ animation.addEventListener('finish', done);
3079
+ animation.addEventListener('cancel', done);
3080
+ } else {
3081
+ setTimeout(done, FLASH_MS);
3082
+ }
3083
+ }
3084
+
3085
+ /**
3086
+ * Any `.astro` edit is a full page reload in Astro 7 with no scroll restoration, so a
3087
+ * fix pass would throw you back to the top of the page on every ticket. Scroll to the
3088
+ * ELEMENT, not to a saved pixel offset — the edit just moved the layout.
3089
+ */
3090
+ function restoreAfterReload() {
3091
+ const entry = readRestore();
3092
+ if (!entry) return;
3093
+ const run = () => {
3094
+ // Resolves through the stored frame chain, so a pin taken inside a lab story is
3095
+ // found inside that story and not looked for in the shell.
3096
+ const found = resolveRestore(entry);
3097
+ if (!found) return; // element edited away, or its frame is gone — degrade silently
3098
+ try {
3099
+ // The frame first, so the element's own scroll has somewhere visible to land.
3100
+ if (found.frames.length) {
3101
+ found.frames[0].scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'auto' });
3102
+ }
3103
+ found.el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'auto' });
3104
+ flash(found.el, found.frames);
3105
+ rememberPin(entry.selector, found.frames); // keep the anchor alive across the pass
3106
+ // The page scrolls and the element flashes. Saying so in words said nothing.
3107
+ } catch {
3108
+ /* nothing here is worth breaking the page for */
3109
+ }
3110
+ };
3111
+ if (document.readyState === 'complete') requestAnimationFrame(run);
3112
+ else window.addEventListener('load', () => requestAnimationFrame(run), { once: true });
3113
+ }
3114
+
3115
+ window.addEventListener('pagehide', () => {
3116
+ // A selection made but not sent yet is still the thing being looked at.
3117
+ if (selectedEl && selectedEl.isConnected) rememberPin(cssPathOf(selectedEl), selectedFrames);
3118
+ else {
3119
+ // Re-store the existing entry verbatim to refresh its timestamp. Rebuilding it
3120
+ // would drop the frame chain, which is stored as selectors, not elements.
3121
+ const entry = readRestore();
3122
+ if (entry) {
3123
+ try {
3124
+ sessionStorage.setItem(RESTORE_KEY, JSON.stringify({ ...entry, ts: Date.now() }));
3125
+ } catch {
3126
+ /* storage disabled — the restore is a convenience, not a contract */
3127
+ }
3128
+ }
3129
+ }
3130
+ });
3131
+
3132
+ setMode('idle');
3133
+ restoreAfterReload();
3134
+ // Read the board once at start-up so the markers are on the page the moment the app is
3135
+ // opened, without having to open the panel first.
3136
+ loadBoard({ quiet: true });
3137
+ },
3138
+
3139
+ beforeTogglingOff(canvas) {
3140
+ // Nothing this app owns should outlive the canvas being hidden.
3141
+ for (const el of canvas.querySelectorAll('[data-orbytes-pin-ui="surface"]')) el.remove();
3142
+ return true;
3143
+ },
3144
+ });
3145
+
3146
+ /** Exported for headless verification only — not part of the app's runtime surface. */
3147
+ export const __test = {
3148
+ ageLabel,
3149
+ isCancelledStatus,
3150
+ isOpenStatus,
3151
+ isResolvedStatus,
3152
+ normalisePath,
3153
+ samePath,
3154
+ statusKind,
3155
+ statusLabel,
3156
+ ticketNumber,
3157
+ ticketsFromBoardHtml,
3158
+ chainGeometry,
3159
+ countMatches,
3160
+ cssPathOf,
3161
+ docRect,
3162
+ documentPath,
3163
+ frameDocument,
3164
+ frameGeometry,
3165
+ frameUnder,
3166
+ isAxisAligned,
3167
+ outward,
3168
+ extendsSiblingClass,
3169
+ mostSpecific,
3170
+ nthChildPath,
3171
+ nthOfType,
3172
+ partCandidates,
3173
+ selectorCombos,
3174
+ stableClasses,
3175
+ isNoiseClass,
3176
+ isTransientClass,
3177
+ isTrustworthyId,
3178
+ resolveSource,
3179
+ truncate,
3180
+ looksAbsolute,
3181
+ };