@half-built/astro 0.9.0 → 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.
@@ -1,8 +1,8 @@
1
- /* Image lightbox (spec docs/superpowers/specs/2026-07-30-image-lightbox-design.md).
2
- Exported pure math plus mountLightbox, on the island contract (step 9):
3
- mount(root, options?) returns a destroy handle; claim() makes a second
4
- mount over an already-wired link a no-op. Called from Base.astro's
5
- script block and testable under jsdom. */
1
+ /* Image lightbox. Exported pure math plus mountLightbox, on the island
2
+ contract (step 9): mount(root, options?) returns a destroy handle;
3
+ claim() makes a second mount over an already-wired link a no-op.
4
+ Called from the consumer's layout script block and testable under
5
+ jsdom. */
6
6
 
7
7
  import { claim, release, type Island, type IslandHandle } from "./core/island";
8
8
  import { iconButton, docOf } from "./core/dom";
@@ -116,6 +116,26 @@ export function outOfWhack(
116
116
  return overlap < WHACK_FRACTION * Math.min(w * h, boxW * boxH);
117
117
  }
118
118
 
119
+ /* One mouse notch (100px) is about the old fixed 1.2x step; a
120
+ trackpad's many small deltas add up to the same total instead of
121
+ stepping 1.2x each. deltaMode 1 is lines, 2 is pages. */
122
+ const WHEEL_K = Math.log(1.2) / 100;
123
+ const LINE_PX = 16;
124
+
125
+ export function wheelZoomFactor(
126
+ deltaY: number,
127
+ deltaMode: number,
128
+ pageHeight: number,
129
+ ): number {
130
+ if (deltaY === 0) return 1;
131
+
132
+ let px = deltaY;
133
+ if (deltaMode === 1) px = deltaY * LINE_PX;
134
+ else if (deltaMode === 2) px = deltaY * pageHeight;
135
+
136
+ return Math.exp(-px * WHEEL_K);
137
+ }
138
+
119
139
  export function readout(view: ZoomView, fit: number, prefix = "FIT"): string {
120
140
  const sign = (n: number): string => {
121
141
  const r = Math.round(n);
@@ -359,6 +379,10 @@ export const mountLightbox: Island<LightboxOptions> = (
359
379
  let view: ZoomView = { zoom: 1, x: 0, y: 0 };
360
380
  let boxW = FALLBACK_BOX.w;
361
381
  let boxH = FALLBACK_BOX.h;
382
+ /* Registered while the dialog is open, one at a time: refit on
383
+ resize, torn down on close so a reopen registers a fresh one
384
+ rather than piling up. */
385
+ let onResize: (() => void) | null = null;
362
386
 
363
387
  const applyView = (r: Refs): void => {
364
388
  const item = items[index];
@@ -369,6 +393,17 @@ export const mountLightbox: Island<LightboxOptions> = (
369
393
  r.homeBtn.hidden = !outOfWhack(view, item.w, item.h, boxW, boxH);
370
394
  };
371
395
 
396
+ /* fit and view must move together: readout's FIT/percent judgment is
397
+ view.zoom === fit, so any path that resets the view to fit (a new
398
+ item, HOME, 0, the double-click reset, or a resize) recomputes
399
+ both here rather than setting view alone and leaving fit stale. */
400
+ const resetToFit = (r: Refs): void => {
401
+ const item = items[index];
402
+ fit = fitZoom(item.w, item.h, boxW, boxH);
403
+ view = initialView(item.w, item.h, boxW, boxH);
404
+ applyView(r);
405
+ };
406
+
372
407
  const goTo = (r: Refs, i: number): void => {
373
408
  const n = items.length;
374
409
  show(r, ((i % n) + n) % n);
@@ -412,15 +447,13 @@ export const mountLightbox: Island<LightboxOptions> = (
412
447
  const show = (r: Refs, i: number): void => {
413
448
  index = i;
414
449
  const item = items[index];
415
- fit = fitZoom(item.w, item.h, boxW, boxH);
416
- view = initialView(item.w, item.h, boxW, boxH);
417
450
  r.caption.textContent = item.caption;
418
451
  r.img.alt = item.caption;
419
452
  r.dims.textContent = `${String(item.w)} × ${String(item.h)}`;
420
453
  r.counter.textContent = `${String(index + 1).padStart(2, "0")} / ${String(items.length).padStart(2, "0")}`;
421
454
  r.img.src = item.thumb;
422
455
  swapToFull(r.img, item.href, doc);
423
- applyView(r);
456
+ resetToFit(r);
424
457
  };
425
458
 
426
459
  const ensureRefs = (): Refs => {
@@ -465,23 +498,21 @@ export const mountLightbox: Island<LightboxOptions> = (
465
498
  if (ev.key === "+" || ev.key === "=") rezoom(ZOOM_STEP, 0, 0);
466
499
  if (ev.key === "-") rezoom(1 / ZOOM_STEP, 0, 0);
467
500
 
468
- if (ev.key === "0") {
469
- view = initialView(item().w, item().h, boxW, boxH);
470
- applyView(r);
471
- }
501
+ if (ev.key === "0") resetToFit(r);
472
502
  });
473
503
 
474
504
  r.homeBtn.addEventListener("click", () => {
475
- view = initialView(item().w, item().h, boxW, boxH);
476
- applyView(r);
505
+ resetToFit(r);
477
506
  });
478
507
 
479
508
  r.viewbox.addEventListener(
480
509
  "wheel",
481
510
  (ev) => {
482
511
  ev.preventDefault();
512
+ const factor = wheelZoomFactor(ev.deltaY, ev.deltaMode, boxH);
513
+ if (factor === 1) return;
483
514
  const { cx, cy } = rel(ev);
484
- rezoom(ev.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP, cx, cy);
515
+ rezoom(factor, cx, cy);
485
516
  },
486
517
  { passive: false },
487
518
  );
@@ -502,8 +533,7 @@ export const mountLightbox: Island<LightboxOptions> = (
502
533
  const { cx, cy } = rel(ev);
503
534
  rezoom(1 / view.zoom, cx, cy);
504
535
  } else {
505
- view = init;
506
- applyView(r);
536
+ resetToFit(r);
507
537
  }
508
538
  });
509
539
 
@@ -569,6 +599,16 @@ export const mountLightbox: Island<LightboxOptions> = (
569
599
  r.viewbox.addEventListener("pointerup", lift);
570
600
  r.viewbox.addEventListener("pointercancel", lift);
571
601
 
602
+ /* Torn down here rather than in destroy(): every close (veil,
603
+ Escape, close box, or a consumer's own close()) fires this, so
604
+ the listener never outlives the open dialog. */
605
+ r.dialog.addEventListener("close", () => {
606
+ if (onResize) {
607
+ window.removeEventListener("resize", onResize);
608
+ onResize = null;
609
+ }
610
+ });
611
+
572
612
  refs = r;
573
613
  return r;
574
614
  };
@@ -588,6 +628,17 @@ export const mountLightbox: Island<LightboxOptions> = (
588
628
  boxW = rect.width || FALLBACK_BOX.w;
589
629
  boxH = rect.height || FALLBACK_BOX.h;
590
630
  goTo(r, Math.max(0, set.indexOf(link)));
631
+
632
+ if (!onResize) {
633
+ onResize = (): void => {
634
+ const rr = r.viewbox.getBoundingClientRect();
635
+ boxW = rr.width || FALLBACK_BOX.w;
636
+ boxH = rr.height || FALLBACK_BOX.h;
637
+ resetToFit(r);
638
+ };
639
+
640
+ window.addEventListener("resize", onResize);
641
+ }
591
642
  };
592
643
 
593
644
  const mounted: {
@@ -624,6 +675,7 @@ export const mountLightbox: Island<LightboxOptions> = (
624
675
  }
625
676
 
626
677
  if (refs) {
678
+ if (refs.dialog.open) refs.dialog.close();
627
679
  refs.dialog.remove();
628
680
  refs = null;
629
681
  }
@@ -1,68 +1,6 @@
1
1
  import { claim, release, type Island, type IslandHandle } from "./core/island";
2
2
  import { docOf } from "./core/dom";
3
-
4
- const GAP = 1; /* px between anchor and tip; the old bottom: calc(100% + 1px) */
5
- const INSET = 2; /* px of horizontal lead-out past the anchor edge */
6
- const EDGE = 12; /* px of breathing room against either viewport edge */
7
-
8
- export type TipPlace = "above" | "below";
9
- export type TipAlign = "start" | "end";
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
- }
29
-
30
- /* Pure placement for the singleton tip: start/end alignment against
31
- the anchor, horizontal clamp into [edge, vw - edge] with the left
32
- edge winning when both bind, and a vertical flip when the preferred
33
- side would leave the viewport. All measured values; no prediction. */
34
- export function placeTip(
35
- anchor: AnchorRect,
36
- tip: TipSize,
37
- viewport: ViewportSize,
38
- place: TipPlace = "above",
39
- align: TipAlign = "start",
40
- edge: number = EDGE,
41
- ): TipPosition {
42
- const startX = anchor.left - INSET;
43
- const endX = anchor.right + INSET - tip.width;
44
- let x = align === "start" ? startX : endX;
45
-
46
- const overRight = x + tip.width - (viewport.width - edge);
47
- if (overRight > 0) x -= overRight;
48
- if (x < edge) x = edge;
49
- let finalPlace = place;
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
-
64
- return { x: Math.round(x), y: Math.round(y), place: finalPlace };
65
- }
3
+ import { placeTip, EDGE } from "./core/placement";
66
4
 
67
5
  export interface LinkTipOptions {
68
6
  selector?: string;
@@ -97,9 +35,10 @@ export const mountLinkTips: Island<LinkTipOptions> = (
97
35
  const win = doc.defaultView;
98
36
  /* Touch: claims (so double mounts stay no-ops) but attaches nothing;
99
37
  mobile browsers already show the URL themselves. jsdom has no
100
- matchMedia at all (same widened-type guard as henry-loose.ts and
101
- path-player.ts): the plain Window type always declares the method,
102
- so an unwidened optional chain reads as always-true to the lint. */
38
+ matchMedia at all (same widened-type guard used elsewhere in the
39
+ package, path-player.ts included): the plain Window type always
40
+ declares the method, so an unwidened optional chain reads as
41
+ always-true to the lint. */
103
42
  const mmWin = win as { matchMedia?: typeof window.matchMedia } | null;
104
43
 
105
44
  if (mmWin?.matchMedia?.("(hover: none)").matches) {
@@ -6,6 +6,7 @@
6
6
  2026-08-22); flip SCRUB to re-enable. */
7
7
  import { buildPlateModal } from "./plate-modal";
8
8
  import { createFrameLoop } from "./core/frame-loop";
9
+ import { BP_PHONE_MAX } from "./core/breakpoints";
9
10
  import { ICON_PLAY, ICON_PAUSE, ICON_ROTATE_CCW } from "./core/icons";
10
11
  import { playheadX, formatReadout, columnIndex } from "./path-player-math";
11
12
  import {
@@ -21,8 +22,8 @@ import {
21
22
  which no-inferrable-types rejects on a literal) so flipping this to
22
23
  re-enable scrubbing is a one-line change: a literal type here would
23
24
  make the `if (SCRUB)` below statically always-false and trip the
24
- no-unnecessary-condition lint rule. Same widening idea as
25
- src/scripts/henry-loose.ts's matchMedia guard. */
25
+ no-unnecessary-condition lint rule. Same widening idea as this
26
+ file's own matchMedia guard below. */
26
27
  const SCRUB = false as boolean;
27
28
  const AXIS_H = 14;
28
29
  const TRACK_GAP = 5;
@@ -161,6 +162,8 @@ export function createPathPlayer<S>(
161
162
  restartBtn.className = "pp-restart icon-box press-box";
162
163
  restartBtn.setAttribute("aria-label", restart);
163
164
  restartBtn.innerHTML = ICON_ROTATE_CCW;
165
+ playBtn.innerHTML = ICON_PLAY;
166
+ playBtn.setAttribute("aria-pressed", "false");
164
167
 
165
168
  if (!stageReady) {
166
169
  playBtn.disabled = true;
@@ -192,10 +195,10 @@ export function createPathPlayer<S>(
192
195
  captionToggle.textContent = about;
193
196
  captionToggle.setAttribute("aria-controls", caption.id);
194
197
 
195
- /* Same widening as henry-loose.ts: jsdom has no matchMedia, and a
196
- bare typeof check reads as always-true to the lint. The query list
197
- type is widened too so a test stub without addEventListener is
198
- still valid. */
198
+ /* Same widening as the package's other matchMedia guards: jsdom has
199
+ no matchMedia, and a bare typeof check reads as always-true to the
200
+ lint. The query list type is widened too so a test stub without
201
+ addEventListener is still valid. */
199
202
  interface PhoneQuery {
200
203
  matches: boolean;
201
204
  addEventListener?: (
@@ -211,7 +214,7 @@ export function createPathPlayer<S>(
211
214
 
212
215
  if (!mediaQuery) return null;
213
216
 
214
- return mediaQuery.call(globalThis, "(max-width: 768px)");
217
+ return mediaQuery.call(globalThis, `(max-width: ${BP_PHONE_MAX}px)`);
215
218
  };
216
219
 
217
220
  const phone = (): boolean => phoneQuery()?.matches ?? false;
@@ -305,6 +308,15 @@ export function createPathPlayer<S>(
305
308
  staticTracks = paintStatic(w, scale);
306
309
  }
307
310
 
311
+ /* The timeline canvas is CSS-width-driven, and its offscreen static
312
+ layer is baked at a fixed pixel width; a viewport resize while the
313
+ modal is open leaves it stale until the next close/open round trip
314
+ unless it re-lays here. */
315
+ const onResize = (): void => {
316
+ layoutTracks();
317
+ drawFrame();
318
+ };
319
+
308
320
  function drawFrame(): void {
309
321
  const ctx = tracksCanvas.getContext("2d");
310
322
  if (!ctx || !staticTracks) return;
@@ -404,6 +416,7 @@ export function createPathPlayer<S>(
404
416
  setPlaying(false);
405
417
  loop.stop();
406
418
  config.sink.pause?.();
419
+ win.removeEventListener("resize", onResize);
407
420
  });
408
421
 
409
422
  if (SCRUB) {
@@ -416,13 +429,14 @@ export function createPathPlayer<S>(
416
429
  pm.open(opener);
417
430
  setCaptionShown(!phone());
418
431
  layoutTracks();
432
+ win.addEventListener("resize", onResize);
419
433
  readout.textContent = formatReadout(state.t, config.duration);
420
434
  config.sink.resume?.();
421
435
 
422
436
  /* Reduced motion: open paused; the transport still plays on demand.
423
- jsdom has no matchMedia, hence the widened type (same guard as
424
- henry-loose.ts). Kept separate from the frame loop's `win`,
425
- which is cast non-null: this one must stay optional so a
437
+ jsdom has no matchMedia, hence the widened type (same guard used
438
+ elsewhere in this file). Kept separate from the frame loop's
439
+ `win`, which is cast non-null: this one must stay optional so a
426
440
  matchMedia-less jsdom does not throw. */
427
441
  const mmWin = doc.defaultView as {
428
442
  matchMedia?: typeof window.matchMedia;
@@ -1,8 +1,8 @@
1
- /* Shared plate-window chrome (spec
2
- docs/superpowers/specs/2026-08-22-fluid-path-player-design.md):
3
- dialog + veil, corner-stroked zone, ink plate, boxed labels, close.
4
- Consumers: the image lightbox (legacyPrefix "lb" keeps its old class
5
- names alive) and the path player. */
1
+ /* Shared plate-window chrome, built for the fluid path player and
2
+ generalized for shared use: dialog + veil, corner-stroked zone, ink
3
+ plate, boxed labels, close. Consumers: the image lightbox
4
+ (legacyPrefix "lb" keeps its old class names alive) and the path
5
+ player. */
6
6
 
7
7
  import { ICON_X } from "./core/icons";
8
8
 
@@ -86,8 +86,19 @@ export function buildPlateModal(
86
86
  button's innerHTML detaches the click's target mid-bubble, and a
87
87
  detached node is contained by nothing, which read as a veil click
88
88
  and closed the dialog under the pause button (found 2026-08-23). */
89
+ /* A click targets the nearest common ancestor of press and release,
90
+ so a drag from inside the plate that ends on the veil arrives here
91
+ targeted at the dialog. Only a press that also began on the veil
92
+ closes. */
93
+ let pressOnVeil = false;
94
+
95
+ dialog.addEventListener("pointerdown", (ev) => {
96
+ pressOnVeil = ev.target === dialog;
97
+ });
98
+
89
99
  dialog.addEventListener("click", (ev) => {
90
- if (ev.target === dialog) dialog.close();
100
+ if (ev.target === dialog && pressOnVeil) dialog.close();
101
+ pressOnVeil = false;
91
102
  });
92
103
 
93
104
  const addLabel = (