@half-built/astro 0.4.2 → 0.6.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 (60) hide show
  1. package/README.md +50 -2
  2. package/package.json +11 -4
  3. package/src/components/CategoryCard.astro +9 -1
  4. package/src/components/CornerBadges.astro +14 -21
  5. package/src/components/Footer.astro +57 -29
  6. package/src/components/Icon.astro +16 -0
  7. package/src/components/LightboxLink.astro +19 -10
  8. package/src/components/LinkListWidget.astro +11 -9
  9. package/src/components/Pagination.astro +51 -28
  10. package/src/components/PostCard.astro +54 -22
  11. package/src/components/PostNavigation.astro +40 -29
  12. package/src/components/Shell.astro +26 -12
  13. package/src/components/SiteHeader.astro +139 -80
  14. package/src/components/SmartImage.astro +48 -17
  15. package/src/components/Subscribe.astro +60 -35
  16. package/src/components/ThemeToggle.astro +28 -15
  17. package/src/components/TwoColumn.astro +5 -5
  18. package/src/components/Widget.astro +10 -7
  19. package/src/components/content/BlogImage.astro +48 -13
  20. package/src/components/content/Button.astro +13 -13
  21. package/src/components/content/Callout.astro +17 -8
  22. package/src/components/content/CodeBlock.astro +6 -1
  23. package/src/components/content/EditorNote.astro +19 -21
  24. package/src/components/content/Gallery.astro +18 -5
  25. package/src/components/content/GalleryImage.astro +26 -7
  26. package/src/components/content/Group.astro +5 -6
  27. package/src/components/content/MediaText.astro +43 -9
  28. package/src/components/content/Palette.astro +37 -21
  29. package/src/components/content/PostLink.astro +15 -0
  30. package/src/components/content/Quote.astro +10 -9
  31. package/src/components/content/Spacer.astro +11 -2
  32. package/src/components/content/Step.astro +34 -15
  33. package/src/components/content/Walkthrough.astro +10 -6
  34. package/src/components/content/WhenPublished.astro +14 -0
  35. package/src/components/models.ts +2 -2
  36. package/src/lib/archive.ts +18 -4
  37. package/src/lib/drafts.ts +16 -5
  38. package/src/lib/format-date.ts +4 -1
  39. package/src/lib/ordering.ts +3 -1
  40. package/src/lib/paginate.ts +5 -1
  41. package/src/lib/slug.ts +30 -7
  42. package/src/scripts/code-island.ts +27 -4
  43. package/src/scripts/core/badges.ts +28 -0
  44. package/src/scripts/core/dom.ts +6 -1
  45. package/src/scripts/core/frame-loop.ts +4 -1
  46. package/src/scripts/core/icons.ts +92 -15
  47. package/src/scripts/ecosystem.ts +72 -12
  48. package/src/scripts/focus-mode.ts +14 -3
  49. package/src/scripts/lightbox.ts +227 -41
  50. package/src/scripts/link-tip.ts +55 -11
  51. package/src/scripts/path-player-math.ts +11 -2
  52. package/src/scripts/path-player-paint.ts +54 -8
  53. package/src/scripts/path-player.ts +131 -29
  54. package/src/scripts/plate-modal.ts +45 -9
  55. package/src/scripts/scroll-top.ts +6 -1
  56. package/src/scripts/site-header.ts +34 -6
  57. package/src/scripts/subscribe.ts +50 -9
  58. package/src/scripts/theme-toggle.ts +32 -5
  59. package/src/shiki/code-theme.mjs +25 -5
  60. package/src/shiki/code-vars.mjs +5 -1
@@ -9,19 +9,29 @@ import { iconButton, docOf } from "./core/dom";
9
9
  import { buildPlateModal } from "./plate-modal";
10
10
  import { ICON_CHEVRON_LEFT, ICON_CHEVRON_RIGHT } from "./core/icons";
11
11
 
12
- export interface ZoomView { zoom: number; x: number; y: number }
12
+ export interface ZoomView {
13
+ zoom: number;
14
+ x: number;
15
+ y: number;
16
+ }
13
17
 
14
18
  export const MIN_NATIVE_ZOOM = 0.1;
15
19
  export const MAX_NATIVE_ZOOM = 8;
16
20
  export const TALL_ASPECT = 3;
17
21
  export const FALLBACK_BOX = { w: 800, h: 600 };
22
+
18
23
  /* HOME appears once the visible image-window overlap drops below this
19
24
  fraction of whichever is smaller, the scaled image or the window. */
20
25
  const WHACK_FRACTION = 0.25;
21
26
 
22
27
  /* zoom is CSS px per image px (1 = 100%); fit contains the image, except
23
28
  tall schematics fit width, and small images never upscale past 100%. */
24
- export function fitZoom(imgW: number, imgH: number, boxW: number, boxH: number): number {
29
+ export function fitZoom(
30
+ imgW: number,
31
+ imgH: number,
32
+ boxW: number,
33
+ boxH: number,
34
+ ): number {
25
35
  if (imgH / imgW >= TALL_ASPECT) return boxW / imgW;
26
36
  return Math.min(boxW / imgW, boxH / imgH, 1);
27
37
  }
@@ -31,7 +41,10 @@ export function fitZoom(imgW: number, imgH: number, boxW: number, boxH: number):
31
41
  2026-08-01; originally 50% of fit, which read as arbitrary per-image
32
42
  percentages). Ceiling: 800% of native. */
33
43
  export function clampZoom(zoom: number, fit: number): number {
34
- return Math.min(Math.max(zoom, Math.min(MIN_NATIVE_ZOOM, fit)), MAX_NATIVE_ZOOM);
44
+ return Math.min(
45
+ Math.max(zoom, Math.min(MIN_NATIVE_ZOOM, fit)),
46
+ MAX_NATIVE_ZOOM,
47
+ );
35
48
  }
36
49
 
37
50
  /* x/y offset the image center from the viewbox center. Pan is unbounded
@@ -43,29 +56,62 @@ export function normalizePan(view: ZoomView): ZoomView {
43
56
  return { zoom: view.zoom, x: view.x + 0, y: view.y + 0 };
44
57
  }
45
58
 
46
- export function initialView(imgW: number, imgH: number, boxW: number, boxH: number): ZoomView {
59
+ export function initialView(
60
+ imgW: number,
61
+ imgH: number,
62
+ boxW: number,
63
+ boxH: number,
64
+ ): ZoomView {
47
65
  const zoom = fitZoom(imgW, imgH, boxW, boxH);
48
- const y = imgH / imgW >= TALL_ASPECT ? Math.max(0, (imgH * zoom - boxH) / 2) : 0;
66
+
67
+ const tall = imgH / imgW >= TALL_ASPECT;
68
+ const centered = Math.max(0, (imgH * zoom - boxH) / 2);
69
+ const y = tall ? centered : 0;
70
+
49
71
  return normalizePan({ zoom, x: 0, y });
50
72
  }
51
73
 
52
74
  /* Anchor-point zoom: the image point under (cx, cy) stays put. Cursor
53
75
  coordinates are relative to the viewbox center. */
54
- export function zoomAt(view: ZoomView, factor: number, cx: number, cy: number, imgW: number, imgH: number, boxW: number, boxH: number): ZoomView {
76
+ export function zoomAt(
77
+ view: ZoomView,
78
+ factor: number,
79
+ cx: number,
80
+ cy: number,
81
+ imgW: number,
82
+ imgH: number,
83
+ boxW: number,
84
+ boxH: number,
85
+ ): ZoomView {
55
86
  const fit = fitZoom(imgW, imgH, boxW, boxH);
56
87
  const zoom = clampZoom(view.zoom * factor, fit);
57
88
  const s = zoom / view.zoom;
58
- return normalizePan({ zoom, x: cx - (cx - view.x) * s, y: cy - (cy - view.y) * s });
89
+ return normalizePan({
90
+ zoom,
91
+ x: cx - (cx - view.x) * s,
92
+ y: cy - (cy - view.y) * s,
93
+ });
59
94
  }
60
95
 
61
96
  /* True when the view has wandered far enough that the user likely wants
62
97
  a way home: the image-window overlap is under WHACK_FRACTION of the
63
98
  smaller of the two areas (so a centered deep zoom is never "whack"). */
64
- export function outOfWhack(view: ZoomView, imgW: number, imgH: number, boxW: number, boxH: number): boolean {
99
+ export function outOfWhack(
100
+ view: ZoomView,
101
+ imgW: number,
102
+ imgH: number,
103
+ boxW: number,
104
+ boxH: number,
105
+ ): boolean {
65
106
  const w = imgW * view.zoom;
66
107
  const h = imgH * view.zoom;
67
- const overlapW = Math.min(view.x + w / 2, boxW / 2) - Math.max(view.x - w / 2, -boxW / 2);
68
- const overlapH = Math.min(view.y + h / 2, boxH / 2) - Math.max(view.y - h / 2, -boxH / 2);
108
+
109
+ const overlapW =
110
+ Math.min(view.x + w / 2, boxW / 2) - Math.max(view.x - w / 2, -boxW / 2);
111
+
112
+ const overlapH =
113
+ Math.min(view.y + h / 2, boxH / 2) - Math.max(view.y - h / 2, -boxH / 2);
114
+
69
115
  const overlap = Math.max(0, overlapW) * Math.max(0, overlapH);
70
116
  return overlap < WHACK_FRACTION * Math.min(w * h, boxW * boxH);
71
117
  }
@@ -75,7 +121,11 @@ export function readout(view: ZoomView, fit: number, prefix = "FIT"): string {
75
121
  const r = Math.round(n);
76
122
  return r >= 0 ? `+${String(r)}` : String(r);
77
123
  };
78
- if (view.zoom === fit && view.x === 0 && view.y === 0) return `${prefix} · 0,0`;
124
+
125
+ if (view.zoom === fit && view.x === 0 && view.y === 0) {
126
+ return `${prefix} · 0,0`;
127
+ }
128
+
79
129
  if (view.zoom === fit) return `${prefix} · ${sign(view.x)},${sign(view.y)}`;
80
130
  return `${String(Math.round(view.zoom * 100))}% · ${sign(view.x)},${sign(view.y)}`;
81
131
  }
@@ -111,7 +161,13 @@ const DEFAULT_LABELS: LightboxLabels = {
111
161
  image: (i, n) => `Image ${String(i)} of ${String(n)}`,
112
162
  };
113
163
 
114
- interface Item { href: string; w: number; h: number; caption: string; thumb: string }
164
+ interface Item {
165
+ href: string;
166
+ w: number;
167
+ h: number;
168
+ caption: string;
169
+ thumb: string;
170
+ }
115
171
 
116
172
  interface Refs {
117
173
  dialog: HTMLDialogElement;
@@ -137,14 +193,21 @@ function itemFor(link: HTMLAnchorElement): Item {
137
193
  w: Number(link.dataset.lbW ?? "0"),
138
194
  h: Number(link.dataset.lbH ?? "0"),
139
195
  caption: link.dataset.lbCaption ?? "",
140
- thumb: (img?.currentSrc ?? "") || (img?.getAttribute("src") ?? "") || (link.getAttribute("href") ?? ""),
196
+ thumb:
197
+ (img?.currentSrc ?? "") ||
198
+ (img?.getAttribute("src") ?? "") ||
199
+ (link.getAttribute("href") ?? ""),
141
200
  };
142
201
  }
143
202
 
144
203
  /* Gallery plates sharing a .gallery-plates ancestor form a set, and so do
145
204
  the photos of one Walkthrough (spec 2026-08-15-walkthrough-steps-design).
146
205
  Every other image opens solo (spec: no implicit chaining). */
147
- function setFor(link: HTMLAnchorElement, selector: string, groupSelector: string): HTMLAnchorElement[] {
206
+ function setFor(
207
+ link: HTMLAnchorElement,
208
+ selector: string,
209
+ groupSelector: string,
210
+ ): HTMLAnchorElement[] {
148
211
  const set = link.closest(groupSelector);
149
212
  if (!set) return [link];
150
213
  return [...set.querySelectorAll<HTMLAnchorElement>(selector)];
@@ -153,30 +216,52 @@ function setFor(link: HTMLAnchorElement, selector: string, groupSelector: string
153
216
  /* The in-page rendition shows instantly; the untransformed original swaps
154
217
  in when loaded. dataset.pending guards against stale loads after
155
218
  navigating within a set. Returns the probe for tests. */
156
- export function swapToFull(target: HTMLImageElement, href: string, doc: Document): HTMLImageElement | null {
219
+ export function swapToFull(
220
+ target: HTMLImageElement,
221
+ href: string,
222
+ doc: Document,
223
+ ): HTMLImageElement | null {
157
224
  if (target.getAttribute("src") === href) return null;
158
225
  target.dataset.pending = href;
159
226
  const probe = doc.createElement("img");
227
+
160
228
  probe.addEventListener("load", () => {
161
229
  if (target.dataset.pending === href) {
162
230
  target.src = href;
163
231
  delete target.dataset.pending;
164
232
  }
165
233
  });
234
+
166
235
  probe.src = href;
167
236
  return probe;
168
237
  }
169
238
 
170
239
  function buildDialog(doc: Document, labels: LightboxLabels): Refs {
171
- const pm = buildPlateModal(doc, { ariaLabel: labels.viewer, legacyPrefix: "lb" });
240
+ const pm = buildPlateModal(doc, {
241
+ ariaLabel: labels.viewer,
242
+ legacyPrefix: "lb",
243
+ });
244
+
172
245
  const { dialog, zone, plate } = pm;
173
246
  const closeBtn = pm.closeBtn;
174
247
  closeBtn.setAttribute("aria-label", labels.close);
175
248
 
176
249
  const main = doc.createElement("div");
177
250
  main.className = "lb-main";
178
- const prevBtn = iconButton(doc, "lb-arrow lb-prev icon-box", labels.prev, ICON_CHEVRON_LEFT);
179
- const nextBtn = iconButton(doc, "lb-arrow lb-next icon-box", labels.next, ICON_CHEVRON_RIGHT);
251
+
252
+ const prevBtn = iconButton(
253
+ doc,
254
+ "lb-arrow lb-prev icon-box",
255
+ labels.prev,
256
+ ICON_CHEVRON_LEFT,
257
+ );
258
+
259
+ const nextBtn = iconButton(
260
+ doc,
261
+ "lb-arrow lb-next icon-box",
262
+ labels.next,
263
+ ICON_CHEVRON_RIGHT,
264
+ );
180
265
 
181
266
  /* The bar owns counter + close so the mobile header-row layout keeps
182
267
  working; the close button is re-parented out of the plate root. */
@@ -194,7 +279,11 @@ function buildDialog(doc: Document, labels: LightboxLabels): Refs {
194
279
  drag (no-drop cursor) and fires pointercancel, killing pan tracking.
195
280
  Opt the pan surface out of it on both layers. */
196
281
  img.draggable = false;
197
- img.addEventListener("dragstart", (ev) => { ev.preventDefault(); });
282
+
283
+ img.addEventListener("dragstart", (ev) => {
284
+ ev.preventDefault();
285
+ });
286
+
198
287
  const homeBtn = doc.createElement("button");
199
288
  homeBtn.type = "button";
200
289
  homeBtn.className = "lb-home boxed-label micro-label";
@@ -219,14 +308,41 @@ function buildDialog(doc: Document, labels: LightboxLabels): Refs {
219
308
  thumbs.className = "lb-thumbs";
220
309
 
221
310
  zone.append(main, thumbs);
222
- return { dialog, zone, prevBtn, nextBtn, closeBtn, counter, viewbox, img, caption, dims, readoutEl, homeBtn, thumbs, pmOpen: pm.open };
311
+ return {
312
+ dialog,
313
+ zone,
314
+ prevBtn,
315
+ nextBtn,
316
+ closeBtn,
317
+ counter,
318
+ viewbox,
319
+ img,
320
+ caption,
321
+ dims,
322
+ readoutEl,
323
+ homeBtn,
324
+ thumbs,
325
+ pmOpen: pm.open,
326
+ };
223
327
  }
224
328
 
225
- export const mountLightbox: Island<LightboxOptions> = (root, options = {}): IslandHandle => {
226
- const { selector = "a.lightbox-link", groupSelector = ".gallery-plates, .walkthrough", labels: labelsOverride = {} } = options;
329
+ export const mountLightbox: Island<LightboxOptions> = (
330
+ root,
331
+ options = {},
332
+ ): IslandHandle => {
333
+ const {
334
+ selector = "a.lightbox-link",
335
+ groupSelector = ".gallery-plates, .walkthrough",
336
+ labels: labelsOverride = {},
337
+ } = options;
338
+
227
339
  const labels: LightboxLabels = { ...DEFAULT_LABELS, ...labelsOverride };
228
340
  const doc = docOf(root);
229
- const links = [...root.querySelectorAll<HTMLAnchorElement>(selector)].filter((link) => claim(link, "lightbox"));
341
+
342
+ const links = [...root.querySelectorAll<HTMLAnchorElement>(selector)].filter(
343
+ (link) => claim(link, "lightbox"),
344
+ );
345
+
230
346
  if (links.length === 0) {
231
347
  return {
232
348
  destroy(): void {
@@ -256,11 +372,17 @@ export const mountLightbox: Island<LightboxOptions> = (root, options = {}): Isla
256
372
  const goTo = (r: Refs, i: number): void => {
257
373
  const n = items.length;
258
374
  show(r, ((i % n) + n) % n);
375
+
259
376
  r.thumbs.querySelectorAll(".lb-thumb").forEach((t, ti) => {
260
377
  t.classList.toggle("active", ti === index);
378
+
261
379
  /* The strip scrolls rather than wraps; keep the active thumb in
262
380
  view (guarded: jsdom has no scrollIntoView). */
263
- if (ti === index && t instanceof HTMLElement && typeof t.scrollIntoView === "function") {
381
+ if (
382
+ ti === index &&
383
+ t instanceof HTMLElement &&
384
+ typeof t.scrollIntoView === "function"
385
+ ) {
264
386
  t.scrollIntoView({ block: "nearest", inline: "nearest" });
265
387
  }
266
388
  });
@@ -268,6 +390,7 @@ export const mountLightbox: Island<LightboxOptions> = (root, options = {}): Isla
268
390
 
269
391
  const buildThumbs = (r: Refs): void => {
270
392
  r.thumbs.replaceChildren();
393
+
271
394
  items.forEach((item, i) => {
272
395
  const btn = doc.createElement("button");
273
396
  btn.type = "button";
@@ -277,7 +400,11 @@ export const mountLightbox: Island<LightboxOptions> = (root, options = {}): Isla
277
400
  timg.src = item.thumb;
278
401
  timg.alt = "";
279
402
  btn.append(timg);
280
- btn.addEventListener("click", () => { goTo(r, i); });
403
+
404
+ btn.addEventListener("click", () => {
405
+ goTo(r, i);
406
+ });
407
+
281
408
  r.thumbs.append(btn);
282
409
  });
283
410
  };
@@ -299,22 +426,32 @@ export const mountLightbox: Island<LightboxOptions> = (root, options = {}): Isla
299
426
  const ensureRefs = (): Refs => {
300
427
  if (refs) return refs;
301
428
  const r = buildDialog(doc, labels);
302
- r.prevBtn.addEventListener("click", () => { goTo(r, index - 1); });
303
- r.nextBtn.addEventListener("click", () => { goTo(r, index + 1); });
429
+
430
+ r.prevBtn.addEventListener("click", () => {
431
+ goTo(r, index - 1);
432
+ });
433
+
434
+ r.nextBtn.addEventListener("click", () => {
435
+ goTo(r, index + 1);
436
+ });
437
+
304
438
  r.dialog.addEventListener("keydown", (ev) => {
305
439
  if (items.length < 2) return;
306
440
  if (ev.key === "ArrowRight") goTo(r, index + 1);
307
441
  if (ev.key === "ArrowLeft") goTo(r, index - 1);
308
442
  });
443
+
309
444
  /* Swipe navigation was removed with unbounded pan (owner adjustment
310
445
  2026-08-01): drag always pans, and set navigation is arrows, keys,
311
446
  and the scrollable thumb strip. */
312
447
  const ZOOM_STEP = 1.2;
313
448
  const item = (): Item => items[index];
449
+
314
450
  const rezoom = (factor: number, cx: number, cy: number): void => {
315
451
  view = zoomAt(view, factor, cx, cy, item().w, item().h, boxW, boxH);
316
452
  applyView(r);
317
453
  };
454
+
318
455
  /* Cursor position relative to the viewbox center. */
319
456
  const rel = (ev: MouseEvent): { cx: number; cy: number } => {
320
457
  const rect = r.viewbox.getBoundingClientRect();
@@ -327,6 +464,7 @@ export const mountLightbox: Island<LightboxOptions> = (root, options = {}): Isla
327
464
  r.dialog.addEventListener("keydown", (ev) => {
328
465
  if (ev.key === "+" || ev.key === "=") rezoom(ZOOM_STEP, 0, 0);
329
466
  if (ev.key === "-") rezoom(1 / ZOOM_STEP, 0, 0);
467
+
330
468
  if (ev.key === "0") {
331
469
  view = initialView(item().w, item().h, boxW, boxH);
332
470
  applyView(r);
@@ -338,11 +476,15 @@ export const mountLightbox: Island<LightboxOptions> = (root, options = {}): Isla
338
476
  applyView(r);
339
477
  });
340
478
 
341
- r.viewbox.addEventListener("wheel", (ev) => {
342
- ev.preventDefault();
343
- const { cx, cy } = rel(ev);
344
- rezoom(ev.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP, cx, cy);
345
- }, { passive: false });
479
+ r.viewbox.addEventListener(
480
+ "wheel",
481
+ (ev) => {
482
+ ev.preventDefault();
483
+ const { cx, cy } = rel(ev);
484
+ rezoom(ev.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP, cx, cy);
485
+ },
486
+ { passive: false },
487
+ );
346
488
 
347
489
  /* At the untouched initial view, double-click dives to 100% under the
348
490
  cursor; from any other zoom or pan state it returns to fit, centered
@@ -352,7 +494,10 @@ export const mountLightbox: Island<LightboxOptions> = (root, options = {}): Isla
352
494
  listener on the image never fires in real browsers. */
353
495
  r.viewbox.addEventListener("dblclick", (ev) => {
354
496
  const init = initialView(item().w, item().h, boxW, boxH);
355
- const atInit = view.zoom === init.zoom && view.x === init.x && view.y === init.y;
497
+
498
+ const atInit =
499
+ view.zoom === init.zoom && view.x === init.x && view.y === init.y;
500
+
356
501
  if (atInit && view.zoom < 1) {
357
502
  const { cx, cy } = rel(ev);
358
503
  rezoom(1 / view.zoom, cx, cy);
@@ -365,6 +510,7 @@ export const mountLightbox: Island<LightboxOptions> = (root, options = {}): Isla
365
510
  /* Drag pans while zoomed past fit; pinch zooms. Two live pointers
366
511
  mean pinch; one means pan (or, at fit, the Task 5 swipe). */
367
512
  const pointers = new Map<number, { x: number; y: number }>();
513
+
368
514
  r.viewbox.addEventListener("pointerdown", (ev) => {
369
515
  /* A press on HOME is a button press, not a drag. Capturing it
370
516
  would retarget the pointerup and click to the viewbox, so the
@@ -372,30 +518,54 @@ export const mountLightbox: Island<LightboxOptions> = (root, options = {}): Isla
372
518
  which is why only the browser showed this). */
373
519
  if (ev.target === r.homeBtn) return;
374
520
  pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
375
- if (typeof r.viewbox.setPointerCapture === "function") r.viewbox.setPointerCapture(ev.pointerId);
521
+
522
+ if (typeof r.viewbox.setPointerCapture === "function") {
523
+ r.viewbox.setPointerCapture(ev.pointerId);
524
+ }
376
525
  });
526
+
377
527
  r.viewbox.addEventListener("pointermove", (ev) => {
378
528
  const prev = pointers.get(ev.pointerId);
379
529
  if (!prev) return;
530
+
380
531
  if (pointers.size === 2) {
381
- const other = [...pointers.entries()].find(([id]) => id !== ev.pointerId)?.[1];
532
+ const other = [...pointers.entries()].find(
533
+ ([id]) => id !== ev.pointerId,
534
+ )?.[1];
535
+
382
536
  if (other) {
383
537
  const d0 = Math.hypot(prev.x - other.x, prev.y - other.y);
384
538
  const d1 = Math.hypot(ev.clientX - other.x, ev.clientY - other.y);
539
+
385
540
  if (d0 > 0) {
386
541
  const rect = r.viewbox.getBoundingClientRect();
387
- const midX = (ev.clientX + other.x) / 2 - rect.left - (rect.width || boxW) / 2;
388
- const midY = (ev.clientY + other.y) / 2 - rect.top - (rect.height || boxH) / 2;
542
+
543
+ const midX =
544
+ (ev.clientX + other.x) / 2 - rect.left - (rect.width || boxW) / 2;
545
+
546
+ const midY =
547
+ (ev.clientY + other.y) / 2 - rect.top - (rect.height || boxH) / 2;
548
+
389
549
  rezoom(d1 / d0, midX, midY);
390
550
  }
391
551
  }
392
552
  } else {
393
- view = normalizePan({ zoom: view.zoom, x: view.x + ev.clientX - prev.x, y: view.y + ev.clientY - prev.y });
553
+ view = normalizePan({
554
+ zoom: view.zoom,
555
+ x: view.x + ev.clientX - prev.x,
556
+ y: view.y + ev.clientY - prev.y,
557
+ });
558
+
394
559
  applyView(r);
395
560
  }
561
+
396
562
  pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
397
563
  });
398
- const lift = (ev: PointerEvent): void => { pointers.delete(ev.pointerId); };
564
+
565
+ const lift = (ev: PointerEvent): void => {
566
+ pointers.delete(ev.pointerId);
567
+ };
568
+
399
569
  r.viewbox.addEventListener("pointerup", lift);
400
570
  r.viewbox.addEventListener("pointercancel", lift);
401
571
 
@@ -420,13 +590,28 @@ export const mountLightbox: Island<LightboxOptions> = (root, options = {}): Isla
420
590
  goTo(r, Math.max(0, set.indexOf(link)));
421
591
  };
422
592
 
423
- const mounted: { link: HTMLAnchorElement; onClick: (ev: MouseEvent) => void }[] = [];
593
+ const mounted: {
594
+ link: HTMLAnchorElement;
595
+ onClick: (ev: MouseEvent) => void;
596
+ }[] = [];
597
+
424
598
  for (const link of links) {
425
599
  const onClick = (ev: MouseEvent): void => {
426
- if (ev.defaultPrevented || ev.button !== 0 || ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return;
600
+ if (
601
+ ev.defaultPrevented ||
602
+ ev.button !== 0 ||
603
+ ev.ctrlKey ||
604
+ ev.metaKey ||
605
+ ev.shiftKey ||
606
+ ev.altKey
607
+ ) {
608
+ return;
609
+ }
610
+
427
611
  ev.preventDefault();
428
612
  open(link);
429
613
  };
614
+
430
615
  link.addEventListener("click", onClick);
431
616
  mounted.push({ link, onClick });
432
617
  }
@@ -437,6 +622,7 @@ export const mountLightbox: Island<LightboxOptions> = (root, options = {}): Isla
437
622
  link.removeEventListener("click", onClick);
438
623
  release(link, "lightbox");
439
624
  }
625
+
440
626
  if (refs) {
441
627
  refs.dialog.remove();
442
628
  refs = null;
@@ -7,10 +7,25 @@ const EDGE = 12; /* px of breathing room against either viewport edge */
7
7
 
8
8
  export type TipPlace = "above" | "below";
9
9
  export type TipAlign = "start" | "end";
10
- export interface AnchorRect { left: number; right: number; top: number; bottom: number }
11
- export interface TipSize { width: number; height: number }
12
- export interface ViewportSize { width: number; height: number }
13
- export interface TipPosition { x: number; y: number; place: TipPlace }
10
+ export interface AnchorRect {
11
+ left: number;
12
+ right: number;
13
+ top: number;
14
+ bottom: number;
15
+ }
16
+ export interface TipSize {
17
+ width: number;
18
+ height: number;
19
+ }
20
+ export interface ViewportSize {
21
+ width: number;
22
+ height: number;
23
+ }
24
+ export interface TipPosition {
25
+ x: number;
26
+ y: number;
27
+ place: TipPlace;
28
+ }
14
29
 
15
30
  /* Pure placement for the singleton tip: start/end alignment against
16
31
  the anchor, horizontal clamp into [edge, vw - edge] with the left
@@ -24,14 +39,28 @@ export function placeTip(
24
39
  align: TipAlign = "start",
25
40
  edge: number = EDGE,
26
41
  ): TipPosition {
27
- let x = align === "start" ? anchor.left - INSET : anchor.right + INSET - tip.width;
42
+ const startX = anchor.left - INSET;
43
+ const endX = anchor.right + INSET - tip.width;
44
+ let x = align === "start" ? startX : endX;
45
+
28
46
  const overRight = x + tip.width - (viewport.width - edge);
29
47
  if (overRight > 0) x -= overRight;
30
48
  if (x < edge) x = edge;
31
49
  let finalPlace = place;
32
- if (place === "above" && anchor.top - GAP - tip.height < 0) finalPlace = "below";
33
- if (place === "below" && anchor.bottom + GAP + tip.height > viewport.height) finalPlace = "above";
34
- const y = finalPlace === "above" ? anchor.top - GAP - tip.height : anchor.bottom + GAP;
50
+
51
+ if (place === "above" && anchor.top - GAP - tip.height < 0) {
52
+ finalPlace = "below";
53
+ }
54
+
55
+ if (place === "below" && anchor.bottom + GAP + tip.height > viewport.height) {
56
+ finalPlace = "above";
57
+ }
58
+
59
+ const y =
60
+ finalPlace === "above"
61
+ ? anchor.top - GAP - tip.height
62
+ : anchor.bottom + GAP;
63
+
35
64
  return { x: Math.round(x), y: Math.round(y), place: finalPlace };
36
65
  }
37
66
 
@@ -47,9 +76,15 @@ export interface LinkTipOptions {
47
76
  point (spec 2026-09-02; three positioning defects shipped from
48
77
  pseudo-element prediction). Containers steer placement with
49
78
  data-tip-place="below" and data-tip-align="end" (the masthead). */
50
- export const mountLinkTips: Island<LinkTipOptions> = (root, options = {}): IslandHandle => {
51
- const { selector = 'a[href^="http"], a[data-tooltip]', edge = EDGE } = options;
79
+ export const mountLinkTips: Island<LinkTipOptions> = (
80
+ root,
81
+ options = {},
82
+ ): IslandHandle => {
83
+ const { selector = 'a[href^="http"], a[data-tooltip]', edge = EDGE } =
84
+ options;
85
+
52
86
  const doc = docOf(root);
87
+
53
88
  if (!claim(doc.documentElement, "link-tip")) {
54
89
  return {
55
90
  destroy(): void {
@@ -66,6 +101,7 @@ export const mountLinkTips: Island<LinkTipOptions> = (root, options = {}): Islan
66
101
  path-player.ts): the plain Window type always declares the method,
67
102
  so an unwidened optional chain reads as always-true to the lint. */
68
103
  const mmWin = win as { matchMedia?: typeof window.matchMedia } | null;
104
+
69
105
  if (mmWin?.matchMedia?.("(hover: none)").matches) {
70
106
  return {
71
107
  destroy(): void {
@@ -108,7 +144,12 @@ export const mountLinkTips: Island<LinkTipOptions> = (root, options = {}): Islan
108
144
  const box = tip.getBoundingClientRect();
109
145
  const place = a.closest('[data-tip-place="below"]') ? "below" : "above";
110
146
  const align = a.closest('[data-tip-align="end"]') ? "end" : "start";
111
- const viewport = { width: doc.documentElement.clientWidth, height: doc.documentElement.clientHeight };
147
+
148
+ const viewport = {
149
+ width: doc.documentElement.clientWidth,
150
+ height: doc.documentElement.clientHeight,
151
+ };
152
+
112
153
  const pos = placeTip(anchor, box, viewport, place, align, edge);
113
154
  tip.style.left = `${String(pos.x)}px`;
114
155
  tip.style.top = `${String(pos.y)}px`;
@@ -119,12 +160,15 @@ export const mountLinkTips: Island<LinkTipOptions> = (root, options = {}): Islan
119
160
  const a = linkFor(ev.target);
120
161
  if (a) show(a);
121
162
  };
163
+
122
164
  const onOut = (ev: Event): void => {
123
165
  if (linkFor(ev.target)) hide();
124
166
  };
167
+
125
168
  const onKey = (ev: KeyboardEvent): void => {
126
169
  if (ev.key === "Escape") hide();
127
170
  };
171
+
128
172
  const onHide = (): void => {
129
173
  hide();
130
174
  };
@@ -1,7 +1,11 @@
1
1
  /* Pure geometry for the path-player timeline; the canvas painter in
2
2
  path-player.ts consumes these, jsdom tests never need a 2D context. */
3
3
 
4
- export function normalizeSeries(values: number[]): { min: number; max: number; frac: (v: number) => number } {
4
+ export function normalizeSeries(values: number[]): {
5
+ min: number;
6
+ max: number;
7
+ frac: (v: number) => number;
8
+ } {
5
9
  const min = Math.min(...values);
6
10
  const max = Math.max(...values);
7
11
  return { min, max, frac: (v) => (max > min ? (v - min) / (max - min) : 0.5) };
@@ -29,6 +33,11 @@ export function columnIndex(px: number, width: number, length: number): number {
29
33
 
30
34
  /* Vertical position of a normalized value inside a track: frac 0 sits
31
35
  pad above the track's bottom edge, frac 1 sits pad below its top. */
32
- export function seriesY(frac: number, top: number, height: number, pad: number): number {
36
+ export function seriesY(
37
+ frac: number,
38
+ top: number,
39
+ height: number,
40
+ pad: number,
41
+ ): number {
33
42
  return top + height - pad - frac * (height - 2 * pad);
34
43
  }