@half-built/astro 0.10.0 → 0.11.1

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,10 +1,15 @@
1
- /* The rAF loop with dt clamp (step 10), one home for the pattern
2
- henry-loose.ts and path-player.ts each carried. now is the rAF
1
+ /* The rAF loop with dt clamp (step 10), one home for a pattern that
2
+ used to be scattered across the site's own scripts and path-player.ts.
3
+ now is the rAF
3
4
  timestamp: monotonic in a real browser, unlike Date.now(), which a
4
5
  wall-clock adjustment can move backward; the max(0, ...) floor is
5
6
  cheap belt-and-suspenders against that case feeding an integrator a
6
7
  negative dt. stop() resets the clock so a stop/start gap (a hidden
7
- tab, a closed dialog) never arrives as one giant dt. */
8
+ tab, a closed dialog) never arrives as one giant dt. A stop, a
9
+ start, or both called reentrantly from inside cb keep exactly one
10
+ scheduled chain: each generation is stamped at schedule time, and a
11
+ tick from a stale generation is dropped instead of rescheduling
12
+ itself alongside the newer chain. */
8
13
  export interface FrameLoop {
9
14
  start(): void;
10
15
  stop(): void;
@@ -20,21 +25,25 @@ export function createFrameLoop(
20
25
  let handle = 0;
21
26
  let last = 0;
22
27
  let live = false;
28
+ let gen = 0;
23
29
 
24
- function tick(now: number): void {
25
- if (!live) return;
30
+ function tick(now: number, mine: number): void {
31
+ if (!live || mine !== gen) return;
26
32
 
27
33
  const elapsed = Math.max(0, (now - last) / 1000);
28
34
  const dt = last === 0 ? firstDt : Math.min(clamp, elapsed);
29
35
 
30
36
  last = now;
31
37
  cb(dt);
32
- // cb may have called stop() reentrantly; a fresh function body reads live without stale narrowing.
33
- scheduleNext();
34
- }
35
38
 
36
- function scheduleNext(): void {
37
- if (live) handle = win.requestAnimationFrame(tick);
39
+ // cb may have called stop() and/or start() reentrantly; gen moves
40
+ // on either call, so mine still matching gen here already means
41
+ // this chain is still the live one.
42
+ if (mine === gen) {
43
+ handle = win.requestAnimationFrame((now2) => {
44
+ tick(now2, mine);
45
+ });
46
+ }
38
47
  }
39
48
 
40
49
  return {
@@ -42,11 +51,17 @@ export function createFrameLoop(
42
51
  if (live) return;
43
52
  live = true;
44
53
  last = 0;
45
- handle = win.requestAnimationFrame(tick);
54
+ gen++;
55
+ const mine = gen;
56
+
57
+ handle = win.requestAnimationFrame((now) => {
58
+ tick(now, mine);
59
+ });
46
60
  },
47
61
  stop(): void {
48
62
  if (!live) return;
49
63
  live = false;
64
+ gen++;
50
65
  win.cancelAnimationFrame(handle);
51
66
  last = 0;
52
67
  },
@@ -1,7 +1,7 @@
1
1
  /* Inline icon markup, vendored from Lucide (https://lucide.dev, ISC
2
2
  license), replacing the Unicode glyphs whose rendering varied by
3
3
  platform font. Same line style as the site's existing inline SVGs
4
- (the to-top chevron in Base.astro). Buttons carry their own
4
+ (the to-top chevron in the consumer's layout). Buttons carry their own
5
5
  aria-labels; the svg itself is decorative and aria-hidden.
6
6
  Home: the js package's core (owner decision 1, 2026-08-31); Lucide,
7
7
  ISC license, attribution retained. */
@@ -98,7 +98,6 @@ export function iconMarkup(
98
98
 
99
99
  export const ICON_X = iconMarkup("x");
100
100
  export const ICON_PLAY = iconMarkup("play");
101
- export const ICON_SPARKLES = iconMarkup("sparkles");
102
101
  export const ICON_PAUSE = iconMarkup("pause");
103
102
  export const ICON_ROTATE_CCW = iconMarkup("rotate-ccw");
104
103
  export const ICON_CHEVRON_LEFT = iconMarkup("chevron-left");
@@ -0,0 +1,67 @@
1
+ /* Pure placement shared by the measured islands (link-tip, popout;
2
+ spec docs/superpowers/specs/2026-09-26-popout-design.md moved it
3
+ here from link-tip.ts). Measure then place, never predict. */
4
+
5
+ const GAP = 1; /* px between anchor and tip; the old bottom: calc(100% + 1px) */
6
+ const INSET = 2; /* px of horizontal lead-out past the anchor edge */
7
+
8
+ export const EDGE = 12; /* px of breathing room against either viewport edge */
9
+
10
+ export type TipPlace = "above" | "below";
11
+ export type TipAlign = "start" | "end";
12
+ export interface AnchorRect {
13
+ left: number;
14
+ right: number;
15
+ top: number;
16
+ bottom: number;
17
+ }
18
+ export interface TipSize {
19
+ width: number;
20
+ height: number;
21
+ }
22
+ export interface ViewportSize {
23
+ width: number;
24
+ height: number;
25
+ }
26
+ export interface TipPosition {
27
+ x: number;
28
+ y: number;
29
+ place: TipPlace;
30
+ }
31
+
32
+ /* Pure placement for the singleton tip: start/end alignment against
33
+ the anchor, horizontal clamp into [edge, vw - edge] with the left
34
+ edge winning when both bind, and a vertical flip when the preferred
35
+ side would leave the viewport. All measured values; no prediction. */
36
+ export function placeTip(
37
+ anchor: AnchorRect,
38
+ tip: TipSize,
39
+ viewport: ViewportSize,
40
+ place: TipPlace = "above",
41
+ align: TipAlign = "start",
42
+ edge: number = EDGE,
43
+ ): TipPosition {
44
+ const startX = anchor.left - INSET;
45
+ const endX = anchor.right + INSET - tip.width;
46
+ let x = align === "start" ? startX : endX;
47
+
48
+ const overRight = x + tip.width - (viewport.width - edge);
49
+ if (overRight > 0) x -= overRight;
50
+ if (x < edge) x = edge;
51
+ let finalPlace = place;
52
+
53
+ if (place === "above" && anchor.top - GAP - tip.height < 0) {
54
+ finalPlace = "below";
55
+ }
56
+
57
+ if (place === "below" && anchor.bottom + GAP + tip.height > viewport.height) {
58
+ finalPlace = "above";
59
+ }
60
+
61
+ const y =
62
+ finalPlace === "above"
63
+ ? anchor.top - GAP - tip.height
64
+ : anchor.bottom + GAP;
65
+
66
+ return { x: Math.round(x), y: Math.round(y), place: finalPlace };
67
+ }
@@ -1,5 +1,6 @@
1
1
  /* The localStorage read/validate/write codec (step 10), one home for
2
- the pattern henry-loose.ts and stasis-state.ts each carried: parse is
2
+ a pattern that used to be scattered across the site's own scripts:
3
+ parse is
3
4
  the validator (junk parses to null and read() then removes the key),
4
5
  and every storage touch sits inside try/catch so private mode or
5
6
  disabled storage degrades to in-page-only state, never a thrown
@@ -68,7 +68,12 @@ export function validateDocument(raw: unknown): EcosystemDocument | null {
68
68
 
69
69
  /** The self entry's family first, each group by ascending priority and
70
70
  then label, capped at limit. Null when selfKey is absent, which is
71
- the refusal that keeps a site out of a list missing itself. */
71
+ the refusal that keeps a site out of a list missing itself.
72
+
73
+ The self entry always survives the cap: a footer that would render
74
+ the cap without the site's own entry is worse than one that swaps
75
+ out its lowest-priority stranger, so the self entry takes the last
76
+ slot when sorting alone would have dropped it past the limit. */
72
77
  export function sortEntries(
73
78
  entries: EcosystemDocEntry[],
74
79
  selfKey: string,
@@ -77,15 +82,18 @@ export function sortEntries(
77
82
  const self = entries.find((entry) => entry.key === selfKey);
78
83
  if (!self) return null;
79
84
  const own = self.family;
80
- return [...entries]
81
- .sort((a, b) => {
82
- const aOwn = a.family === own ? 0 : 1;
83
- const bOwn = b.family === own ? 0 : 1;
84
- if (aOwn !== bOwn) return aOwn - bOwn;
85
- if (a.priority !== b.priority) return a.priority - b.priority;
86
- return a.label.localeCompare(b.label);
87
- })
88
- .slice(0, limit);
85
+
86
+ const sorted = [...entries].sort((a, b) => {
87
+ const aOwn = a.family === own ? 0 : 1;
88
+ const bOwn = b.family === own ? 0 : 1;
89
+ if (aOwn !== bOwn) return aOwn - bOwn;
90
+ if (a.priority !== b.priority) return a.priority - b.priority;
91
+ return a.label.localeCompare(b.label);
92
+ });
93
+
94
+ const capped = sorted.slice(0, limit);
95
+ if (limit > 0 && !capped.includes(self)) capped[capped.length - 1] = self;
96
+ return capped;
89
97
  }
90
98
 
91
99
  const CACHE_KEY = "half-built-ecosystem";
@@ -15,11 +15,41 @@ export interface FocusModeOptions {
15
15
  target?: EventTarget;
16
16
  }
17
17
 
18
+ /* Text entry is not navigation: a reader who clicked into a field and
19
+ types keeps the click highlight. Tab always means keyboard. */
20
+ function isTextEntry(t: EventTarget | null): boolean {
21
+ if (!(t instanceof HTMLElement)) return false;
22
+
23
+ if (
24
+ t.isContentEditable ||
25
+ t instanceof HTMLTextAreaElement ||
26
+ t instanceof HTMLSelectElement
27
+ ) {
28
+ return true;
29
+ }
30
+
31
+ if (!(t instanceof HTMLInputElement)) return false;
32
+
33
+ return ![
34
+ "checkbox",
35
+ "radio",
36
+ "button",
37
+ "submit",
38
+ "reset",
39
+ "range",
40
+ "color",
41
+ "file",
42
+ "image",
43
+ ].includes(t.type);
44
+ }
45
+
18
46
  export const mountFocusMode: Island<FocusModeOptions> = (
19
47
  root,
20
48
  options = {},
21
49
  ): IslandHandle => {
22
- const el = root as HTMLElement;
50
+ const el =
51
+ root instanceof Document ? root.documentElement : (root as HTMLElement);
52
+
23
53
  const target = options.target ?? window;
24
54
 
25
55
  if (!claim(el, "focus-mode")) {
@@ -31,7 +61,9 @@ export const mountFocusMode: Island<FocusModeOptions> = (
31
61
  };
32
62
  }
33
63
 
34
- const onKeydown = (): void => {
64
+ const onKeydown = (ev: Event): void => {
65
+ if (!(ev instanceof KeyboardEvent)) return;
66
+ if (ev.key !== "Tab" && isTextEntry(ev.target)) return;
35
67
  el.dataset.focus = "keyboard";
36
68
  };
37
69
 
@@ -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 = (