@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.
- package/LICENSE +37 -0
- package/README.md +410 -0
- package/bin/lab-cull.mjs +401 -0
- package/bin/pin-gallery.mjs +121 -0
- package/defaults.mjs +120 -0
- package/dist/core/astro-integration.js +130 -0
- package/dist/core/index.js +6 -0
- package/dist/core/lib-paths.js +29 -0
- package/dist/core/options.js +173 -0
- package/dist/core/utils/get-exports.js +52 -0
- package/dist/core/utils/invariant.js +12 -0
- package/dist/core/utils/kebab-case.js +15 -0
- package/dist/core/utils/path-builder.js +22 -0
- package/dist/core/utils/path.js +53 -0
- package/dist/core/virtual-module/get-story-modules.js +60 -0
- package/dist/core/virtual-module/story-modules.js +8 -0
- package/dist/core/virtual-module/virtual-module-ids.js +22 -0
- package/dist/core/virtual-module/virtual-routes.js +83 -0
- package/dist/core/virtual-module/vite-plugin.js +98 -0
- package/docs/PIN-CONTRACT.md +263 -0
- package/docs/PIN.md +429 -0
- package/index.d.ts +279 -0
- package/index.mjs +347 -0
- package/package.json +93 -0
- package/src/Empty.astro +4 -0
- package/src/Home.astro +298 -0
- package/src/LabHead.astro +1102 -0
- package/src/core/LICENSE-astrobook +166 -0
- package/src/core/astro-integration.ts +166 -0
- package/src/core/client.ts +89 -0
- package/src/core/index.ts +7 -0
- package/src/core/lib/components/empty.astro +1 -0
- package/src/core/lib/components/head.astro +1 -0
- package/src/core/lib/components/home.astro +8 -0
- package/src/core/lib/components/with-decorators.astro +22 -0
- package/src/core/lib/pages/app.astro +19 -0
- package/src/core/lib/pages/preview.astro +17 -0
- package/src/core/lib/pages/story.astro +16 -0
- package/src/core/lib-paths.ts +72 -0
- package/src/core/options.ts +262 -0
- package/src/core/utils/get-exports.ts +59 -0
- package/src/core/utils/invariant.ts +13 -0
- package/src/core/utils/kebab-case.ts +30 -0
- package/src/core/utils/path-builder.ts +45 -0
- package/src/core/utils/path.ts +80 -0
- package/src/core/virtual-module/get-story-modules.ts +110 -0
- package/src/core/virtual-module/story-modules.ts +9 -0
- package/src/core/virtual-module/virtual-module-ids.ts +17 -0
- package/src/core/virtual-module/virtual-routes.ts +130 -0
- package/src/core/virtual-module/vite-plugin.ts +125 -0
- package/src/pin/board.mjs +1521 -0
- package/src/pin/index.mjs +666 -0
- package/src/pin/shot.mjs +427 -0
- package/src/pin/source-stamp.mjs +159 -0
- package/src/pin/tickets.mjs +697 -0
- package/src/pin/toolbar.js +3181 -0
- package/src/shell/Browse.astro +371 -0
- package/src/shell/CardGrid.astro +297 -0
- package/src/shell/Viewport.astro +1330 -0
- package/src/shell/index.json.ts +12 -0
- package/src/shell/lab-index.ts +344 -0
- package/src/shell/lab-params.ts +245 -0
- package/src/shell/live-files.mjs +164 -0
- package/src/shell/marks.mjs +136 -0
- package/src/types/index.ts +6 -0
- package/src/types/types.ts +239 -0
- package/src/types/virtual.d.ts +29 -0
- package/src/ui/components/app.astro +13 -0
- package/src/ui/components/build-path.ts +13 -0
- package/src/ui/components/build-tree.ts +108 -0
- package/src/ui/components/collapse-duration.ts +28 -0
- package/src/ui/components/compress-terms.ts +10 -0
- package/src/ui/components/dashboard-layout.astro +39 -0
- package/src/ui/components/home.astro +65 -0
- package/src/ui/components/layout.astro +110 -0
- package/src/ui/components/preview-layout.astro +109 -0
- package/src/ui/components/sidebar-button-fullscreen.astro +38 -0
- package/src/ui/components/sidebar-button-search.astro +23 -0
- package/src/ui/components/sidebar-button-theme.astro +9 -0
- package/src/ui/components/sidebar-button.astro +24 -0
- package/src/ui/components/sidebar-resize-handle.astro +74 -0
- package/src/ui/components/sidebar-search-panel.astro +41 -0
- package/src/ui/components/sidebar-search-script.ts +103 -0
- package/src/ui/components/sidebar-title.astro +17 -0
- package/src/ui/components/sidebar-tree-node.astro +143 -0
- package/src/ui/components/sidebar-tree.astro +84 -0
- package/src/ui/components/sidebar.astro +29 -0
- package/src/ui/components/theme-message.ts +26 -0
- package/src/ui/components/theme-script.astro +71 -0
- package/src/ui/components/theme-toggle.astro +63 -0
- package/src/ui/components/theme.ts +32 -0
- package/src/ui/index.ts +4 -0
- package/src/ui/lab.css +549 -0
- 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">↻</button>
|
|
1494
|
+
<button type="button" class="pin-panel__icon" data-p-close title="Close the panel" aria-label="Close the panel">×</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
|
+
};
|