astro-dev-edit 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +125 -0
  3. package/package.json +52 -0
  4. package/src/client/admin-bar.ts +622 -0
  5. package/src/client/api.ts +370 -0
  6. package/src/client/classify-cache.ts +61 -0
  7. package/src/client/css-inspect.ts +345 -0
  8. package/src/client/editors/asset-picker.ts +155 -0
  9. package/src/client/editors/body-editor.ts +419 -0
  10. package/src/client/editors/collections-panel.ts +1532 -0
  11. package/src/client/editors/copy-panel.ts +73 -0
  12. package/src/client/editors/drawer.ts +95 -0
  13. package/src/client/editors/entry.ts +433 -0
  14. package/src/client/editors/expression.ts +77 -0
  15. package/src/client/editors/fields.ts +309 -0
  16. package/src/client/editors/image.ts +268 -0
  17. package/src/client/editors/markup-insert.ts +73 -0
  18. package/src/client/editors/markup.ts +125 -0
  19. package/src/client/editors/media-grid.ts +326 -0
  20. package/src/client/editors/media-modal.ts +588 -0
  21. package/src/client/editors/notice.ts +160 -0
  22. package/src/client/editors/peek.ts +135 -0
  23. package/src/client/editors/settings-panel.ts +457 -0
  24. package/src/client/editors/source-popup.ts +166 -0
  25. package/src/client/editors/text.ts +105 -0
  26. package/src/client/editors/unsplash-pane.ts +317 -0
  27. package/src/client/element-context.ts +308 -0
  28. package/src/client/features.ts +81 -0
  29. package/src/client/focus.ts +166 -0
  30. package/src/client/group.ts +186 -0
  31. package/src/client/highlight.ts +146 -0
  32. package/src/client/hover.ts +485 -0
  33. package/src/client/icons.ts +160 -0
  34. package/src/client/markdown.ts +319 -0
  35. package/src/client/overlay.ts +466 -0
  36. package/src/client/page-source.ts +143 -0
  37. package/src/client/router.ts +198 -0
  38. package/src/client/shadow.ts +111 -0
  39. package/src/client/source-map.ts +150 -0
  40. package/src/client/state.ts +153 -0
  41. package/src/client/styles.ts +3485 -0
  42. package/src/client/tree-model.ts +45 -0
  43. package/src/client/tree.ts +366 -0
  44. package/src/client/ui.ts +987 -0
  45. package/src/client/unsplash-search.ts +250 -0
  46. package/src/index.ts +299 -0
  47. package/src/patcher/astro.ts +792 -0
  48. package/src/patcher/content-config.ts +1035 -0
  49. package/src/patcher/dotenv.ts +121 -0
  50. package/src/patcher/expression-trace.ts +326 -0
  51. package/src/patcher/frontmatter.ts +249 -0
  52. package/src/patcher/registry.ts +11 -0
  53. package/src/patcher/types.ts +32 -0
  54. package/src/server/annotate.ts +173 -0
  55. package/src/server/assets.ts +167 -0
  56. package/src/server/collection-entries.ts +91 -0
  57. package/src/server/content-config.ts +210 -0
  58. package/src/server/editor.ts +15 -0
  59. package/src/server/entry-detect.ts +110 -0
  60. package/src/server/entry-resolve-routes.ts +218 -0
  61. package/src/server/entry-routes.ts +304 -0
  62. package/src/server/inspect-locate.ts +81 -0
  63. package/src/server/inspect-routes.ts +94 -0
  64. package/src/server/middleware.ts +480 -0
  65. package/src/server/options.ts +778 -0
  66. package/src/server/page-source-routes.ts +71 -0
  67. package/src/server/paths.ts +219 -0
  68. package/src/server/private-files.ts +116 -0
  69. package/src/server/route-manifest.ts +200 -0
  70. package/src/server/router.ts +94 -0
  71. package/src/server/schema-introspect.ts +233 -0
  72. package/src/server/schema-routes.ts +808 -0
  73. package/src/server/settings-routes.ts +246 -0
  74. package/src/server/settings.ts +382 -0
  75. package/src/server/text-writes.ts +105 -0
  76. package/src/server/unsplash-routes.ts +515 -0
  77. package/src/server/zod-adapt.ts +239 -0
  78. package/src/shared/asset-path.ts +132 -0
  79. package/src/shared/protocol.ts +935 -0
  80. package/src/shared/slug.ts +17 -0
  81. package/src/shared/unsplash.ts +51 -0
@@ -0,0 +1,485 @@
1
+ import type { ClassifyResult, SourceLoc } from "../shared/protocol.ts";
2
+ import { classifyCached, peekClassification } from "./classify-cache.ts";
3
+ import { buildRulesCard, rulesForToken } from "./css-inspect.ts";
4
+ import { icon, setIcon } from "./icons.ts";
5
+ import { nearestSource, sourceFor } from "./source-map.ts";
6
+ import { isOwnUi, mount } from './shadow.ts';
7
+ import {
8
+ COLOR,
9
+ basename,
10
+ chromeInset,
11
+ hexToRgba,
12
+ outlineRect,
13
+ pillButton,
14
+ setPillLabel,
15
+ styled,
16
+ } from "./ui.ts";
17
+
18
+ /**
19
+ * Hover behaviour: the outline that tracks the hovered source-mapped element
20
+ * and the interactive tooltip pill (file:loc · verdict, plus "open ↗" and
21
+ * "copy ⧉" buttons). Clicking the file:loc label opens the in-browser source
22
+ * peek; "open ↗" jumps to the editor; "copy ⧉" puts the element's context
23
+ * (HTML, CSS, source) on the clipboard. Hover state is self-contained
24
+ * here — it never interacts with the editing slot in state.ts.
25
+ *
26
+ * The pill never guesses. It appears instantly in a neutral "checking" state
27
+ * (no editability claim), and once the pointer has dwelled on one element the
28
+ * server's AST-truth /classify upgrades it to the verdict a click would get:
29
+ * editable, image, or dynamic. Verdicts come through classify-cache.ts, so an
30
+ * element costs one round-trip per file version, not one per mouse pass, and
31
+ * a re-hover of a known element shows its verdict immediately. (spec §7.3)
32
+ */
33
+
34
+ /** What the pill claims about the highlighted element. */
35
+ interface Verdict {
36
+ word: string;
37
+ color: string;
38
+ }
39
+
40
+ /** Neutral pre-verification state — no editability claim yet. */
41
+ const CHECKING: Verdict = { word: "loading…", color: COLOR.mutedFg };
42
+
43
+ /** Collapse the server's classification onto the pill's word + color,
44
+ * mirroring how router.ts will route the eventual click. (spec §16.1) */
45
+ function verdictFor(result: ClassifyResult): Verdict {
46
+ if (result.kind === "text") return { word: "editable", color: COLOR.brandText };
47
+ if (result.kind === "image") {
48
+ const attrs = result.attrs ?? { src: "dynamic", alt: "dynamic" };
49
+ // Same rule as the click router: when neither src nor alt is patchable
50
+ // the click ends in a refusal notice, so the pill says dynamic.
51
+ if (attrs.src !== "static" && attrs.alt === "dynamic") {
52
+ return { word: "dynamic", color: COLOR.warning };
53
+ }
54
+ return { word: "image", color: COLOR.info };
55
+ }
56
+ // empty / dynamic / ambiguous / unresolved all route to the refusal notice.
57
+ return { word: "dynamic", color: COLOR.warning };
58
+ }
59
+
60
+ // --- Elements (appended to the body by the composition root at boot) --------
61
+
62
+ const outline = styled("div", "atx-outline", undefined, "atx-outline");
63
+
64
+ // The pill is interactive: hovering it keeps it open, and its "open source"
65
+ // button jumps to the element's source in the editor. (#3)
66
+ const tooltip = styled("div", "atx-tooltip", undefined, "atx-tooltip");
67
+
68
+ // Row 1: the file:loc · verdict label and the "open ↗" editor jump, kept on one
69
+ // line regardless of the chips row below.
70
+ const tooltipRow = styled("span", "atx-tooltip-row");
71
+
72
+ // The label is clickable too — the whole file:loc line opens the in-browser
73
+ // source peek (the "open ↗" button next to it is the editor jump). (#3)
74
+ const tooltipLabel = styled("span", "atx-tooltip-label");
75
+ // The label is two spans so the verdict can sit in a fixed-width slot — see
76
+ // .atx-tooltip-verdict in styles.ts for why that width exists.
77
+ const tooltipLoc = styled("span", "atx-tooltip-loc");
78
+ const tooltipVerdict = styled("span", "atx-tooltip-verdict");
79
+ tooltipLabel.append(tooltipLoc, tooltipVerdict);
80
+ tooltipLabel.title = "Peek at the source code";
81
+ const tooltipOpen = pillButton(
82
+ "atx-tooltip-open",
83
+ "open",
84
+ "Open this location in your editor",
85
+ {},
86
+ icon("external", 12),
87
+ );
88
+
89
+ // The context copy: everything we know about this element as one markdown
90
+ // block, for pasting into an AI assistant. Its label swaps through
91
+ // copying…/copied, so it holds a fixed width — the pill must not resize
92
+ // mid-interaction (the verdict slot next to it exists for the same reason).
93
+ //
94
+ // The width is the widest of the three labels, which is the idle one: 12
95
+ // characters of 11px/600 UI text (~68px) plus the pill's own 12px icon, 4px
96
+ // gap and 2×7px padding, rounded up for a font that measures wider.
97
+ const COPY_IDLE = "copy context";
98
+ const copyIcon = icon("copy", 12);
99
+ const tooltipCopy = pillButton(
100
+ "atx-tooltip-copy",
101
+ COPY_IDLE,
102
+ "Copy this element's source, HTML and CSS as context for an AI assistant",
103
+ { minWidth: "108px" },
104
+ copyIcon,
105
+ );
106
+ tooltipRow.append(tooltipLabel, tooltipOpen, tooltipCopy);
107
+
108
+ let copyResetTimer: number | null = null;
109
+
110
+ /** Back to the idle label, cancelling any pending flash. Runs on every
111
+ * highlight change too, so a "copied" never carries onto another element. */
112
+ function resetCopy(): void {
113
+ if (copyResetTimer !== null) {
114
+ clearTimeout(copyResetTimer);
115
+ copyResetTimer = null;
116
+ }
117
+ tooltipCopy.disabled = false;
118
+ setPillLabel(tooltipCopy, COPY_IDLE);
119
+ setIcon(copyIcon, "copy", 12);
120
+ }
121
+
122
+ function flashCopy(label: string): void {
123
+ setPillLabel(tooltipCopy, label);
124
+ setIcon(copyIcon, "check", 12);
125
+ copyResetTimer = window.setTimeout(resetCopy, 1200);
126
+ }
127
+
128
+ // Row 2: one chip per class + the id. Hovering a chip pops a rules card (see
129
+ // css-inspect.ts). Wraps within a cap; hidden when the element has neither.
130
+ const tooltipChips = styled("div", "atx-tooltip-chips");
131
+
132
+ tooltip.append(tooltipRow, tooltipChips);
133
+
134
+ // --- Hover state -------------------------------------------------------------
135
+
136
+ // Set once by initHover. Notifies an observer (the element-tree panel) whenever
137
+ // the highlighted page element changes, so it can mirror the highlight onto its
138
+ // matching row (page → tree sync). Lives at module scope because clearHighlight
139
+ // — which also changes the target — is exported and runs outside initHover.
140
+ let onTargetCb: ((el: HTMLElement | null) => void) | null = null;
141
+
142
+ let highlighted: HTMLElement | null = null;
143
+ // Source of the currently-highlighted element, so the pill's "open" button
144
+ // knows what to open.
145
+ let highlightedSrc: SourceLoc | null = null;
146
+ // Grace timer: when the mouse leaves an element we wait briefly before hiding,
147
+ // so the user can travel up to the pill and click it without it vanishing. (#3)
148
+ let hideTimer: number | null = null;
149
+ // Switch timer: travelling from an element up to its pill often crosses a
150
+ // *different* annotated element (the parent, or a sibling the pill overlaps).
151
+ // Retargeting instantly would yank the pill away mid-travel, so a switch to a
152
+ // new element only lands after a short dwell; reaching the pill (or wandering
153
+ // back) cancels it. The first highlight is never delayed.
154
+ const SWITCH_DELAY = 150;
155
+ let switchTimer: number | null = null;
156
+ let switchTarget: HTMLElement | null = null;
157
+ // Verify dwell: the neutral pill upgrades to a server verdict only after the
158
+ // pointer has rested on one element this long — sweeping the mouse across the
159
+ // page must not fire a /classify per element crossed.
160
+ const VERIFY_DELAY = 500;
161
+ let verifyTimer: number | null = null;
162
+ // Monotonic token, bumped whenever the highlight changes or clears, so a
163
+ // /classify response that lands late can tell its element is no longer
164
+ // highlighted and must not repaint the pill.
165
+ let highlightSeq = 0;
166
+
167
+ function cancelHide(): void {
168
+ if (hideTimer !== null) {
169
+ clearTimeout(hideTimer);
170
+ hideTimer = null;
171
+ }
172
+ }
173
+
174
+ function scheduleHide(): void {
175
+ cancelHide();
176
+ hideTimer = window.setTimeout(clearHighlight, 220);
177
+ }
178
+
179
+ function cancelSwitch(): void {
180
+ if (switchTimer !== null) {
181
+ clearTimeout(switchTimer);
182
+ switchTimer = null;
183
+ }
184
+ switchTarget = null;
185
+ }
186
+
187
+ function cancelVerify(): void {
188
+ if (verifyTimer !== null) {
189
+ clearTimeout(verifyTimer);
190
+ verifyTimer = null;
191
+ }
192
+ }
193
+
194
+ // The rules card popped from a chip. On-demand (like a toast), never in the boot
195
+ // append list: at most one exists, rebuilt per chip hover and removed on leave.
196
+ let rulesCard: HTMLElement | null = null;
197
+ let cardHideTimer: number | null = null;
198
+
199
+ function cancelCardHide(): void {
200
+ if (cardHideTimer !== null) {
201
+ clearTimeout(cardHideTimer);
202
+ cardHideTimer = null;
203
+ }
204
+ }
205
+
206
+ function removeCard(): void {
207
+ cancelCardHide();
208
+ rulesCard?.remove();
209
+ rulesCard = null;
210
+ }
211
+
212
+ function scheduleCardHide(): void {
213
+ cancelCardHide();
214
+ cardHideTimer = window.setTimeout(removeCard, 200);
215
+ }
216
+
217
+ export function clearHighlight(): void {
218
+ cancelHide();
219
+ cancelSwitch();
220
+ cancelVerify();
221
+ removeCard();
222
+ resetCopy();
223
+ highlightSeq++; // invalidate any /classify still in flight
224
+ const had = highlighted;
225
+ highlighted = null;
226
+ highlightedSrc = null;
227
+ outline.toggleAttribute("data-on", false);
228
+ tooltip.toggleAttribute("data-on", false);
229
+ if (had) onTargetCb?.(null);
230
+ }
231
+
232
+ // Keep the pill open while hovered. (#3)
233
+ tooltip.addEventListener("mouseenter", cancelHide);
234
+ tooltip.addEventListener("mouseleave", scheduleHide);
235
+
236
+ export interface HoverDeps {
237
+ isEditMode(): boolean;
238
+ openSource(src: SourceLoc): void;
239
+ /** Open the in-browser source-peek panel for a loc. */
240
+ openPeek(src: SourceLoc): void;
241
+ /** Whether the CSS class/ID inspector is on — the chips row renders only then. */
242
+ cssInspector(): boolean;
243
+ /** Open a CSS rule's source file at (near) the rule in the editor. */
244
+ openRule(file: string, selector: string): void;
245
+ /** Gather the element's context and put it on the clipboard. Resolves true
246
+ * only when it actually landed there — a fallback panel or a failure
247
+ * resolves false, and the button returns to its idle label. */
248
+ copyContext(el: HTMLElement, src: SourceLoc): Promise<boolean>;
249
+ /** Notified whenever the highlighted element changes (element-tree sync). */
250
+ onTarget?(el: HTMLElement | null): void;
251
+ }
252
+
253
+ /** What initHover hands back to the composition root: the DOM nodes to append,
254
+ * and a programmatic `highlight` so the element-tree panel can drive the same
255
+ * outline + verdict pill from a row hover (tree → page sync). */
256
+ export interface HoverHandle {
257
+ elements: HTMLElement[];
258
+ highlight(el: HTMLElement): void;
259
+ }
260
+
261
+ /** Wire the hover listeners; returns the elements for the boot code to append
262
+ * once the server health check passes, plus a programmatic highlight. */
263
+ export function initHover(deps: HoverDeps): HoverHandle {
264
+ onTargetCb = deps.onTarget ?? null;
265
+ const onHighlighted = (open: (src: SourceLoc) => void) => (e: MouseEvent): void => {
266
+ e.preventDefault();
267
+ e.stopPropagation();
268
+ if (highlightedSrc) open(highlightedSrc);
269
+ };
270
+ tooltipOpen.addEventListener("click", onHighlighted(deps.openSource));
271
+ tooltipLabel.addEventListener("click", onHighlighted(deps.openPeek));
272
+
273
+ tooltipCopy.addEventListener("click", (e) => {
274
+ e.preventDefault();
275
+ e.stopPropagation();
276
+ const el = highlighted;
277
+ const src = highlightedSrc;
278
+ if (!el || !src) return;
279
+ // Gathering takes a /peek round-trip; hold the pill open across it. The
280
+ // element and loc are captured up front, so the copy still completes if the
281
+ // pointer wanders off and the highlight clears meanwhile.
282
+ cancelHide();
283
+ resetCopy();
284
+ tooltipCopy.disabled = true;
285
+ setPillLabel(tooltipCopy, "copying…");
286
+ setIcon(copyIcon, "spinner", 12);
287
+ void deps.copyContext(el, src).then((copied) => {
288
+ tooltipCopy.disabled = false;
289
+ if (copied) flashCopy("copied");
290
+ else resetCopy();
291
+ });
292
+ });
293
+
294
+ /** Sit the pill fully above the element, measured by its actual height (so the
295
+ * taller chips-row variant never overlaps the element). Only when there's no
296
+ * room above does it drop just below. The admin bar's strip counts as "no
297
+ * room" — a pill hidden under it is worse than one below the element. Must run
298
+ * after the pill's content is in place — including the chips — for
299
+ * offsetHeight to be right. */
300
+ function positionPill(rect: DOMRect): void {
301
+ const gap = 6;
302
+ const inset = chromeInset();
303
+ const minTop = inset.top + 4;
304
+ const maxBottom = window.innerHeight - inset.bottom - 4;
305
+ const above = rect.top - tooltip.offsetHeight - gap;
306
+ const below = rect.bottom + gap;
307
+ tooltip.style.left = `${Math.max(4, rect.left)}px`;
308
+ const fitsBelow = below + tooltip.offsetHeight <= maxBottom;
309
+ tooltip.style.top = `${above >= minTop ? above : fitsBelow ? below : minTop}px`;
310
+ }
311
+
312
+ /** Paint the outline + pill for `el` with the given verdict. */
313
+ function render(el: HTMLElement, verdict: Verdict): void {
314
+ const rect = el.getBoundingClientRect();
315
+
316
+ outline.toggleAttribute("data-on", true);
317
+ Object.assign(outline.style, {
318
+ ...outlineRect(rect),
319
+ // The verdict picks the colour, so these two cannot live in the sheet.
320
+ borderColor: verdict.color,
321
+ background: hexToRgba(verdict.color, 0.08),
322
+ } as Partial<CSSStyleDeclaration>);
323
+
324
+ const file = highlightedSrc?.file ?? "";
325
+ const loc = highlightedSrc?.loc || "?";
326
+ tooltipLoc.textContent = `${basename(file)}:${loc} · `;
327
+ tooltipVerdict.textContent = verdict.word;
328
+ // The verdict's colour is the outline's alone. Repeating it as an edge on
329
+ // the pill made two marks claim the same one thing, and cost the pill its
330
+ // symmetry — it is a label, the box around the element is the highlight.
331
+ tooltip.toggleAttribute("data-on", true);
332
+ positionPill(rect);
333
+ }
334
+
335
+ /** Ask the server (through the cache) and, if the pointer is still on the
336
+ * same element, upgrade the neutral pill to the real verdict. */
337
+ async function verify(el: HTMLElement, src: SourceLoc, seq: number): Promise<void> {
338
+ let result: ClassifyResult;
339
+ try {
340
+ result = await classifyCached({ file: src.file, loc: src.loc, tag: el.tagName.toLowerCase() });
341
+ } catch {
342
+ return; // hover is passive — stay neutral; the click path surfaces errors
343
+ }
344
+ // The mouse may have moved on (or the highlight cleared) mid-round-trip.
345
+ if (seq !== highlightSeq || highlighted !== el) return;
346
+ // Chips stay put on a verdict upgrade — only the loc/verdict line repaints.
347
+ render(el, verdictFor(result));
348
+ }
349
+
350
+ // --- Class/ID chips + rules card ------------------------------------------
351
+
352
+ /** Place the card below its chip, flipping above / clamping to stay on-screen. */
353
+ function positionCard(card: HTMLElement, anchor: HTMLElement): void {
354
+ const r = anchor.getBoundingClientRect();
355
+ const cw = card.offsetWidth;
356
+ const ch = card.offsetHeight;
357
+ let left = Math.min(r.left, window.innerWidth - 8 - cw);
358
+ left = Math.max(8, left);
359
+ let top = r.bottom + 6;
360
+ if (top + ch > window.innerHeight - 8) top = Math.max(8, r.top - 6 - ch);
361
+ card.style.left = `${left}px`;
362
+ card.style.top = `${top}px`;
363
+ }
364
+
365
+ function showCard(anchor: HTMLElement, token: string, kind: "class" | "id", el: HTMLElement): void {
366
+ removeCard();
367
+ const selector = (kind === "class" ? "." : "#") + token;
368
+ const card = buildRulesCard(selector, rulesForToken(el, token, kind), deps.openRule);
369
+ card.addEventListener("mouseenter", () => {
370
+ cancelHide();
371
+ cancelCardHide();
372
+ });
373
+ card.addEventListener("mouseleave", () => {
374
+ scheduleCardHide();
375
+ scheduleHide();
376
+ });
377
+ mount(card);
378
+ positionCard(card, anchor);
379
+ rulesCard = card;
380
+ }
381
+
382
+ function makeChip(label: string, token: string, kind: "class" | "id", el: HTMLElement): HTMLElement {
383
+ const chip = styled("span", "atx-tooltip-chip");
384
+ chip.textContent = label;
385
+ chip.title = `Show CSS applied via ${label}`;
386
+ // The border on hover is CSS; these listeners are here for the card.
387
+ chip.addEventListener("mouseenter", () => {
388
+ cancelHide();
389
+ showCard(chip, token, kind, el);
390
+ });
391
+ chip.addEventListener("mouseleave", scheduleCardHide);
392
+ return chip;
393
+ }
394
+
395
+ /** Rebuild the chips row for `el` (classes, then id). Hidden when the
396
+ * inspector is off or the element has neither. Astro's scope class is noise. */
397
+ function renderChips(el: HTMLElement): void {
398
+ tooltipChips.replaceChildren();
399
+ if (!deps.cssInspector()) {
400
+ tooltipChips.toggleAttribute("data-on", false);
401
+ return;
402
+ }
403
+ const chips: HTMLElement[] = [];
404
+ el.classList.forEach((c) => {
405
+ if (/^astro-[\w-]+$/.test(c)) return; // Astro scoping class, not authored
406
+ chips.push(makeChip(`.${c}`, c, "class", el));
407
+ });
408
+ if (el.id) chips.push(makeChip(`#${el.id}`, el.id, "id", el));
409
+ if (chips.length === 0) {
410
+ tooltipChips.toggleAttribute("data-on", false);
411
+ return;
412
+ }
413
+ tooltipChips.toggleAttribute("data-on", true);
414
+ tooltipChips.append(...chips);
415
+ }
416
+
417
+ function highlight(el: HTMLElement): void {
418
+ cancelSwitch();
419
+ cancelVerify();
420
+ removeCard(); // drop any card left over from the previous element
421
+ resetCopy(); // a "copied ✓" belongs to the element it was clicked on
422
+ if (!el.isConnected) return; // HMR may have replaced it during the dwell
423
+ highlighted = el;
424
+ onTargetCb?.(el); // mirror onto the element-tree row (page → tree)
425
+ const src = sourceFor(el) ?? null;
426
+ highlightedSrc = src;
427
+ const seq = ++highlightSeq;
428
+
429
+ // An already-verified element shows its verdict immediately; otherwise
430
+ // render the neutral state and verify once the pointer has dwelled.
431
+ const cached = src ? peekClassification({ file: src.file, loc: src.loc, tag: el.tagName.toLowerCase() }) : undefined;
432
+ render(el, cached ? verdictFor(cached) : CHECKING);
433
+ renderChips(el);
434
+ // The chips row changes the pill's height — re-place it so it still clears
435
+ // the element (render's placement used the pre-chips height).
436
+ positionPill(el.getBoundingClientRect());
437
+ if (!src || cached) return;
438
+ verifyTimer = window.setTimeout(() => {
439
+ verifyTimer = null;
440
+ void verify(el, src, seq);
441
+ }, VERIFY_DELAY);
442
+ }
443
+
444
+ function onMouseMove(e: MouseEvent): void {
445
+ if (!deps.isEditMode()) return;
446
+ // Moving onto any of our own overlay UI — the pill, the rules card, or the
447
+ // element-tree panel driving highlights of its own — must NOT count as
448
+ // leaving the element, and wins over any pending retarget. (The tree hovers
449
+ // a row to highlight an element; that move must not then hide the pill.)
450
+ // composedPath, not e.target: this listener is on document, which sees a
451
+ // target retargeted to the shadow host for anything inside the overlay.
452
+ if (isOwnUi(e)) {
453
+ cancelHide();
454
+ cancelCardHide();
455
+ cancelSwitch();
456
+ return;
457
+ }
458
+ const el = nearestSource(e.target);
459
+ if (!el) {
460
+ cancelSwitch();
461
+ scheduleHide(); // grace period instead of instant hide
462
+ return;
463
+ }
464
+ cancelHide();
465
+ if (el === highlighted) {
466
+ cancelSwitch(); // wandered back onto the current element
467
+ return;
468
+ }
469
+ // First highlight lands instantly; a *switch* waits out the dwell so the
470
+ // pill doesn't jump to the parent while the mouse travels up to it.
471
+ if (highlighted === null) {
472
+ highlight(el);
473
+ return;
474
+ }
475
+ if (el === switchTarget) return; // countdown to this element already runs
476
+ cancelSwitch();
477
+ switchTarget = el;
478
+ switchTimer = window.setTimeout(() => highlight(el), SWITCH_DELAY);
479
+ }
480
+
481
+ document.addEventListener("mousemove", onMouseMove, { passive: true });
482
+ window.addEventListener("scroll", clearHighlight, { passive: true });
483
+
484
+ return { elements: [outline, tooltip], highlight };
485
+ }
@@ -0,0 +1,160 @@
1
+ import { styled } from "./ui.ts";
2
+
3
+ /**
4
+ * The overlay's icon set: stroke paths on a 24px grid, drawn with `currentColor`
5
+ * so every icon inherits the colour of the control it sits in.
6
+ *
7
+ * The paths are adapted from **Lucide** (https://lucide.dev), ISC licensed:
8
+ *
9
+ * Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as
10
+ * part of Feather (MIT). All other copyright (c) for Lucide are held by
11
+ * Lucide Contributors 2022. Licensed under the ISC License.
12
+ *
13
+ * Inlined as one table rather than installed as a package — the overlay has no
14
+ * runtime dependencies and ships no font file, and copying the paths keeps it
15
+ * that way. `svgMarkup` below writes Lucide's own wrapper (24-box, 2px stroke,
16
+ * round caps and joins), so a path pastes in unchanged; four glyphs deviate and
17
+ * each says why at its entry.
18
+ *
19
+ * `icon()` returns a `<span>` built through ui.ts::styled, so an icon is
20
+ * router-exempt (never treated as an editable page element) and carries the
21
+ * `atx-ico` theming hook like everything else in the overlay. Size is set by
22
+ * the caller; 14px is the bar/pill default, 12px suits dense rows.
23
+ */
24
+
25
+ export type IconName =
26
+ | "cursor"
27
+ | "pencil"
28
+ | "file"
29
+ | "pin"
30
+ | "pinOff"
31
+ | "panelTop"
32
+ | "panelBottom"
33
+ | "sidebar"
34
+ | "check"
35
+ | "dot"
36
+ | "spinner"
37
+ | "alert"
38
+ | "x"
39
+ | "plus"
40
+ | "trash"
41
+ | "quote"
42
+ | "link"
43
+ | "chevronRight"
44
+ | "chevronDown"
45
+ | "copy"
46
+ | "code"
47
+ | "external"
48
+ | "settings"
49
+ | "collections"
50
+ | "lock"
51
+ | "image"
52
+ | "search"
53
+ | "upload";
54
+
55
+ /** Path geometry only — the wrapper `<svg>` supplies stroke, width and caps. */
56
+ const PATHS: Record<IconName, string> = {
57
+ // The overlay's own mark: a click-to-edit pointer. Lucide's `mouse-pointer-2`
58
+ // filled — this one is an identity rather than a glyph in a row, and the
59
+ // solid arrow holds up at the 14px the toggle draws it at.
60
+ cursor:
61
+ '<path d="M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z" fill="currentColor"/>',
62
+ pencil:
63
+ '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>',
64
+ // file-text
65
+ file: '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>',
66
+ pin: '<path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/>',
67
+ pinOff:
68
+ '<path d="M12 17v5"/><path d="M15 9.34V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H7.89"/><path d="m2 2 20 20"/><path d="M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h11"/>',
69
+ // panel-top / panel-bottom / panel-left, with the band filled. Lucide's three
70
+ // differ only by where one divider line sits, which is not readable at the
71
+ // 14px the bar draws them at — the fill is what says which edge is docked.
72
+ panelTop:
73
+ '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M5 3h14a2 2 0 0 1 2 2v4H3V5a2 2 0 0 1 2-2z" fill="currentColor" stroke="none"/>',
74
+ panelBottom:
75
+ '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 15h18v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" fill="currentColor" stroke="none"/>',
76
+ sidebar:
77
+ '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M5 3h4v18H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z" fill="currentColor" stroke="none"/>',
78
+ check: '<path d="M20 6 9 17l-5-5"/>',
79
+ // Lucide has no filled dot: `circle-small` is a ring at r=6, and filling that
80
+ // gives a disc a third wider than the mark it replaces, out of weight with
81
+ // every other glyph here. This one stays hand-drawn.
82
+ dot: '<circle cx="12" cy="12" r="4.5" fill="currentColor" stroke="none"/>',
83
+ // Two arcs of one ring rather than Lucide's single `loader-circle` sweep: the
84
+ // dim half is what makes the rotation readable.
85
+ spinner:
86
+ '<path d="M12 3a9 9 0 1 0 9 9" opacity="0.9"/><path d="M12 3a9 9 0 0 1 9 9" opacity="0.25"/>',
87
+ // triangle-alert
88
+ alert:
89
+ '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
90
+ x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
91
+ plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
92
+ // trash-2
93
+ trash:
94
+ '<path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
95
+ quote:
96
+ '<path d="M16 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h1a2 2 0 0 1 2 2v1a2 2 0 0 1-2 2h-1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z"/><path d="M5 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h1a2 2 0 0 1 2 2v1a2 2 0 0 1-2 2H5a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z"/>',
97
+ // link-2
98
+ link: '<path d="M9 17H7A5 5 0 0 1 7 7h2"/><path d="M15 7h2a5 5 0 1 1 0 10h-2"/><line x1="8" x2="16" y1="12" y2="12"/>',
99
+ chevronRight: '<path d="m9 18 6-6-6-6"/>',
100
+ chevronDown: '<path d="m6 9 6 6 6-6"/>',
101
+ copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
102
+ // code-xml — the slash is what reads as *source*, not as a pair of brackets.
103
+ code: '<path d="m18 16 4-4-4-4"/><path d="m6 8-4 4 4 4"/><path d="m14.5 4-5 16"/>',
104
+ // external-link
105
+ external:
106
+ '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
107
+ settings:
108
+ '<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/>',
109
+ // database: a collection *is* the content store behind the pages, so the
110
+ // stacked-cylinder mark is the right one.
111
+ collections:
112
+ '<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/>',
113
+ // A closed padlock — marks a setting the project's own config owns.
114
+ lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
115
+ image:
116
+ '<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
117
+ search: '<path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/>',
118
+ upload:
119
+ '<path d="M12 3v12"/><path d="m17 8-5-5-5 5"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>',
120
+ };
121
+
122
+ const REDUCED_MOTION = "(prefers-reduced-motion: reduce)";
123
+
124
+ function svgMarkup(name: IconName, size: number): string {
125
+ return (
126
+ `<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" ` +
127
+ 'stroke="currentColor" stroke-width="2" stroke-linecap="round" ' +
128
+ `stroke-linejoin="round" aria-hidden="true" focusable="false">${PATHS[name]}</svg>`
129
+ );
130
+ }
131
+
132
+ /** An icon element, ready to append into a button, row or label. */
133
+ export function icon(name: IconName, size = 16): HTMLElement {
134
+ const host = styled("span", "atx-ico");
135
+ paint(host, name, size);
136
+ return host;
137
+ }
138
+
139
+ /** Swap an existing icon element's glyph in place (same node, same styling). */
140
+ export function setIcon(host: HTMLElement, name: IconName, size = 16): void {
141
+ if (host.dataset.icon === name) return;
142
+ paint(host, name, size);
143
+ }
144
+
145
+ function paint(host: HTMLElement, name: IconName, size: number): void {
146
+ host.dataset.icon = name;
147
+ // Static, hand-authored markup from the table above — no interpolation of
148
+ // anything the page or the server supplied.
149
+ host.innerHTML = svgMarkup(name, size);
150
+ for (const anim of host.getAnimations()) anim.cancel();
151
+ if (name === "spinner" && !window.matchMedia(REDUCED_MOTION).matches) {
152
+ host.animate(
153
+ [{ transform: "rotate(0deg)" }, { transform: "rotate(360deg)" }],
154
+ {
155
+ duration: 850,
156
+ iterations: Infinity,
157
+ },
158
+ );
159
+ }
160
+ }