@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
@@ -3,7 +3,12 @@
3
3
  handed, so tests drive them with a recording context (test/helpers.ts)
4
4
  and never need a real canvas. The transport in path-player.ts composes
5
5
  them once per layout onto an offscreen canvas. */
6
- import { normalizeSeries, brightChannel, columnIndex, seriesY } from "./path-player-math";
6
+ import {
7
+ normalizeSeries,
8
+ brightChannel,
9
+ columnIndex,
10
+ seriesY,
11
+ } from "./path-player-math";
7
12
  import type { BandTrack, LinesTrack } from "./path-player";
8
13
 
9
14
  export const AXIS_STEP_S = 2;
@@ -51,13 +56,29 @@ function frameTrack(p: Painter, top: number, height: number): void {
51
56
 
52
57
  /* Polyline of a series across the track, one point per pixel column,
53
58
  held inside the track by pad on both edges. */
54
- export function strokeSeries(p: Painter, values: number[], top: number, height: number, pad: number, style: string): void {
59
+ export function strokeSeries(
60
+ p: Painter,
61
+ values: number[],
62
+ top: number,
63
+ height: number,
64
+ pad: number,
65
+ style: string,
66
+ ): void {
55
67
  const n = normalizeSeries(values);
56
68
  p.ctx.beginPath();
69
+
57
70
  for (let px = 0; px < p.width; px++) {
58
- const y = seriesY(n.frac(values[columnIndex(px, p.width, values.length)]), top, height, pad);
59
- if (px === 0) p.ctx.moveTo(px, y); else p.ctx.lineTo(px, y);
71
+ const y = seriesY(
72
+ n.frac(values[columnIndex(px, p.width, values.length)]),
73
+ top,
74
+ height,
75
+ pad,
76
+ );
77
+
78
+ if (px === 0) p.ctx.moveTo(px, y);
79
+ else p.ctx.lineTo(px, y);
60
80
  }
81
+
61
82
  p.ctx.strokeStyle = style;
62
83
  p.ctx.lineWidth = 1.5;
63
84
  p.ctx.stroke();
@@ -65,13 +86,22 @@ export function strokeSeries(p: Painter, values: number[], top: number, height:
65
86
 
66
87
  /* A band track is a color strip, one sample per column, with the
67
88
  envelope drawn over it in white. */
68
- export function paintBand(p: Painter, track: BandTrack, top: number, sampleCount: number): void {
89
+ export function paintBand(
90
+ p: Painter,
91
+ track: BandTrack,
92
+ top: number,
93
+ sampleCount: number,
94
+ ): void {
69
95
  for (let px = 0; px < p.width; px++) {
70
96
  const c = track.colorAt(columnIndex(px, p.width, sampleCount));
71
97
  p.ctx.fillStyle = `rgb(${brightChannel(c.r)}, ${brightChannel(c.g)}, ${brightChannel(c.b)})`;
72
98
  p.ctx.fillRect(px, top, 1.5, track.height);
73
99
  }
74
- const env = Array.from({ length: sampleCount }, (_, i) => track.envelopeAt(i));
100
+
101
+ const env = Array.from({ length: sampleCount }, (_, i) =>
102
+ track.envelopeAt(i),
103
+ );
104
+
75
105
  strokeSeries(p, env, top, track.height, 3, "rgba(255, 255, 255, 0.9)");
76
106
  frameTrack(p, top, track.height);
77
107
  }
@@ -84,14 +114,17 @@ export function paintLines(p: Painter, track: LinesTrack, top: number): void {
84
114
  ctx.fillStyle = paperOf(p);
85
115
  ctx.fillRect(0, top, width, track.height);
86
116
  ctx.fillStyle = inkOf(p, 0.07);
117
+
87
118
  for (const r of track.regions) {
88
119
  ctx.fillRect(xAt(r.t0), top, xAt(r.t1) - xAt(r.t0), track.height);
89
120
  }
121
+
90
122
  ctx.strokeStyle = inkOf(p, 0.35);
91
123
  ctx.fillStyle = inkOf(p, 0.55);
92
124
  ctx.lineWidth = 1;
93
125
  ctx.font = "8px sans-serif";
94
126
  ctx.textAlign = "center";
127
+
95
128
  for (const tick of track.ticks) {
96
129
  ctx.beginPath();
97
130
  ctx.moveTo(xAt(tick.t), top);
@@ -99,19 +132,28 @@ export function paintLines(p: Painter, track: LinesTrack, top: number): void {
99
132
  ctx.stroke();
100
133
  ctx.fillText(tick.label, xAt(tick.t), top + 8);
101
134
  }
135
+
102
136
  for (const s of track.series) {
103
137
  strokeSeries(p, s.values, top, track.height, 5, p.cssColor(s.cssVar));
104
138
  }
139
+
105
140
  /* Series labels in series order, left to right, ending at the right
106
141
  edge (X Y Z, owner call 2026-08-26; stacking from the edge read
107
142
  backwards). */
108
143
  ctx.font = "bold 9px sans-serif";
109
144
  ctx.textAlign = "right";
110
145
  const last = track.series.length - 1;
146
+
111
147
  track.series.forEach((s, i) => {
112
148
  ctx.fillStyle = p.cssColor(s.cssVar);
113
- ctx.fillText(s.label.toUpperCase(), width - 4 - (last - i) * 14, top + track.height - 4);
149
+
150
+ ctx.fillText(
151
+ s.label.toUpperCase(),
152
+ width - 4 - (last - i) * 14,
153
+ top + track.height - 4,
154
+ );
114
155
  });
156
+
115
157
  frameTrack(p, top, track.height);
116
158
  }
117
159
 
@@ -142,6 +184,7 @@ export function paintAxis(p: Painter, top: number, duration: number): void {
142
184
  ctx.strokeStyle = inkOf(p, 0.3);
143
185
  ctx.font = "8px sans-serif";
144
186
  ctx.textAlign = "left";
187
+
145
188
  for (let t = 0; t < duration; t += AXIS_STEP_S) {
146
189
  const x = xAt(t);
147
190
  ctx.beginPath();
@@ -149,6 +192,9 @@ export function paintAxis(p: Painter, top: number, duration: number): void {
149
192
  ctx.lineTo(x, top + 6);
150
193
  ctx.stroke();
151
194
  const label = `${t}s`;
152
- if (x + 2 + ctx.measureText(label).width <= width) ctx.fillText(label, x + 2, top + 11);
195
+
196
+ if (x + 2 + ctx.measureText(label).width <= width) {
197
+ ctx.fillText(label, x + 2, top + 11);
198
+ }
153
199
  }
154
200
  }
@@ -9,7 +9,12 @@ import { createFrameLoop } from "./core/frame-loop";
9
9
  import { ICON_PLAY, ICON_PAUSE, ICON_ROTATE_CCW } from "./core/icons";
10
10
  import { playheadX, formatReadout, columnIndex } from "./path-player-math";
11
11
  import {
12
- type Painter, paintBand, paintLines, paintLabelChip, paintAxis, INK,
12
+ type Painter,
13
+ paintBand,
14
+ paintLines,
15
+ paintLabelChip,
16
+ paintAxis,
17
+ INK,
13
18
  } from "./path-player-paint";
14
19
 
15
20
  /* Widened to `boolean` via assertion (not a `: boolean` annotation,
@@ -24,6 +29,7 @@ const TRACK_GAP = 5;
24
29
  /* Caption ids: one player per page today, but aria-controls needs a
25
30
  unique target if a second ever lands. */
26
31
  let captionSeq = 0;
32
+
27
33
  export interface BandTrack {
28
34
  kind: "band";
29
35
  height: number;
@@ -52,8 +58,11 @@ export interface PathPlayerConfig<S> {
52
58
  tracks: TrackSpec[];
53
59
  buildStage: (viewbox: HTMLElement) => boolean | undefined;
54
60
  sink: {
55
- start: (s: S) => void; move: (s: S) => void; stop: () => void;
56
- pause?: () => void; resume?: () => void;
61
+ start: (s: S) => void;
62
+ move: (s: S) => void;
63
+ stop: () => void;
64
+ pause?: () => void;
65
+ resume?: () => void;
57
66
  };
58
67
  /* Transport and caption-toggle text, defaulting to the English copy
59
68
  this player shipped with. A caller with its own site voice (or a
@@ -68,22 +77,36 @@ export interface PathPlayerHandle {
68
77
  }
69
78
 
70
79
  /* Transport state shared with the optional scrub handlers. */
71
- interface TransportState { t: number; playing: boolean; lastIdx: number }
80
+ interface TransportState {
81
+ t: number;
82
+ playing: boolean;
83
+ lastIdx: number;
84
+ }
72
85
 
73
86
  /* Read-only timeline shipped (owner call 2026-08-22). When enabled:
74
87
  pointer drag pauses the clock and paints the emitter along the path;
75
88
  the fluid itself is never seekable, only the input. */
76
89
  function attachScrub(
77
- timeline: HTMLElement, state: TransportState, duration: number,
78
- applyCurrent: (useStart: boolean) => void, setPlaying: (playing: boolean) => void,
90
+ timeline: HTMLElement,
91
+ state: TransportState,
92
+ duration: number,
93
+ applyCurrent: (useStart: boolean) => void,
94
+ setPlaying: (playing: boolean) => void,
79
95
  ): void {
80
96
  let active = false;
81
97
  let wasPlaying = false;
98
+
82
99
  const seek = (ev: PointerEvent): void => {
83
100
  const rect = timeline.getBoundingClientRect();
84
- const frac = Math.max(0, Math.min(1, (ev.clientX - rect.left) / (rect.width || 1)));
101
+
102
+ const frac = Math.max(
103
+ 0,
104
+ Math.min(1, (ev.clientX - rect.left) / (rect.width || 1)),
105
+ );
106
+
85
107
  state.t = frac * duration;
86
108
  };
109
+
87
110
  timeline.addEventListener("pointerdown", (ev) => {
88
111
  active = true;
89
112
  wasPlaying = state.playing;
@@ -92,19 +115,29 @@ function attachScrub(
92
115
  seek(ev);
93
116
  applyCurrent(true);
94
117
  });
118
+
95
119
  timeline.addEventListener("pointermove", (ev) => {
96
120
  if (!active) return;
97
121
  seek(ev);
98
122
  applyCurrent(false);
99
123
  });
124
+
100
125
  timeline.addEventListener("pointerup", () => {
101
126
  active = false;
102
127
  setPlaying(wasPlaying);
103
128
  });
104
129
  }
105
130
 
106
- export function createPathPlayer<S>(doc: Document, config: PathPlayerConfig<S>): PathPlayerHandle {
107
- const { play = "Play or pause", restart = "Restart", about = "ABOUT" } = config.labels ?? {};
131
+ export function createPathPlayer<S>(
132
+ doc: Document,
133
+ config: PathPlayerConfig<S>,
134
+ ): PathPlayerHandle {
135
+ const {
136
+ play = "Play or pause",
137
+ restart = "Restart",
138
+ about = "ABOUT",
139
+ } = config.labels ?? {};
140
+
108
141
  const pm = buildPlateModal(doc, { ariaLabel: config.title });
109
142
  pm.addLabel("topLeft", "pp-title").textContent = config.title;
110
143
  pm.addLabel("bottomLeft", "pp-source").textContent = config.source;
@@ -128,7 +161,12 @@ export function createPathPlayer<S>(doc: Document, config: PathPlayerConfig<S>):
128
161
  restartBtn.className = "pp-restart icon-box press-box";
129
162
  restartBtn.setAttribute("aria-label", restart);
130
163
  restartBtn.innerHTML = ICON_ROTATE_CCW;
131
- if (!stageReady) { playBtn.disabled = true; restartBtn.disabled = true; }
164
+
165
+ if (!stageReady) {
166
+ playBtn.disabled = true;
167
+ restartBtn.disabled = true;
168
+ }
169
+
132
170
  const timeline = doc.createElement("div");
133
171
  timeline.className = "pp-timeline";
134
172
  const tracksCanvas = doc.createElement("canvas");
@@ -147,28 +185,52 @@ export function createPathPlayer<S>(doc: Document, config: PathPlayerConfig<S>):
147
185
  toggle (path-player.css) and always see the caption. */
148
186
  const captionToggle = doc.createElement("button");
149
187
  captionToggle.type = "button";
150
- captionToggle.className = "pp-caption-toggle boxed-label micro-label press-box";
188
+
189
+ captionToggle.className =
190
+ "pp-caption-toggle boxed-label micro-label press-box";
191
+
151
192
  captionToggle.textContent = about;
152
193
  captionToggle.setAttribute("aria-controls", caption.id);
194
+
153
195
  /* Same widening as henry-loose.ts: jsdom has no matchMedia, and a
154
196
  bare typeof check reads as always-true to the lint. The query list
155
197
  type is widened too so a test stub without addEventListener is
156
198
  still valid. */
157
- interface PhoneQuery { matches: boolean; addEventListener?: (type: "change", cb: (ev: { matches: boolean }) => void) => void }
199
+ interface PhoneQuery {
200
+ matches: boolean;
201
+ addEventListener?: (
202
+ type: "change",
203
+ cb: (ev: { matches: boolean }) => void,
204
+ ) => void;
205
+ }
206
+
158
207
  const phoneQuery = (): PhoneQuery | null => {
159
- const mediaQuery = (globalThis as { matchMedia?: (q: string) => PhoneQuery }).matchMedia;
160
- return mediaQuery ? mediaQuery.call(globalThis, "(max-width: 768px)") : null;
208
+ const mediaQuery = (
209
+ globalThis as { matchMedia?: (q: string) => PhoneQuery }
210
+ ).matchMedia;
211
+
212
+ if (!mediaQuery) return null;
213
+
214
+ return mediaQuery.call(globalThis, "(max-width: 768px)");
161
215
  };
216
+
162
217
  const phone = (): boolean => phoneQuery()?.matches ?? false;
218
+
163
219
  const setCaptionShown = (shown: boolean): void => {
164
220
  caption.hidden = !shown;
165
221
  captionToggle.setAttribute("aria-expanded", String(shown));
166
222
  };
167
- captionToggle.addEventListener("click", () => { setCaptionShown(caption.hidden); });
223
+
224
+ captionToggle.addEventListener("click", () => {
225
+ setCaptionShown(caption.hidden);
226
+ });
227
+
168
228
  /* Rotating with the modal open crosses the breakpoint: follow it so
169
229
  a caption hidden in portrait is not stranded behind a toggle that
170
230
  landscape no longer shows (final review 2026-08-26). */
171
- phoneQuery()?.addEventListener?.("change", (ev) => { setCaptionShown(!ev.matches); });
231
+ phoneQuery()?.addEventListener?.("change", (ev) => {
232
+ setCaptionShown(!ev.matches);
233
+ });
172
234
 
173
235
  /* pp-plate scopes the player's narrow-viewport flex reflow without
174
236
  touching the lightbox's shared pm-plate rules. */
@@ -176,14 +238,25 @@ export function createPathPlayer<S>(doc: Document, config: PathPlayerConfig<S>):
176
238
  pm.plate.append(viewbox, timeline, captionToggle, caption, transport);
177
239
 
178
240
  const state: TransportState = { t: 0, playing: false, lastIdx: -1 };
241
+
179
242
  /* All tracks, their gaps, and the axis: the canvas's CSS height. */
180
- const timelineHeight = config.tracks.reduce((h, tr) => h + tr.height + TRACK_GAP, 0) + AXIS_H;
243
+ const timelineHeight =
244
+ config.tracks.reduce((h, tr) => h + tr.height + TRACK_GAP, 0) + AXIS_H;
245
+
181
246
  let staticTracks: HTMLCanvasElement | null = null;
182
247
 
183
248
  const dpr = (): number => doc.defaultView?.devicePixelRatio ?? 1;
184
- const sampleIndexAt = (t: number): number => columnIndex(t, config.duration, config.samples.length);
249
+
250
+ const sampleIndexAt = (t: number): number =>
251
+ columnIndex(t, config.duration, config.samples.length);
252
+
185
253
  const cssColor = (v: string): string =>
186
- (doc.defaultView?.getComputedStyle(doc.documentElement).getPropertyValue(v).trim() ?? "") || INK;
254
+ (doc.defaultView
255
+ ?.getComputedStyle(doc.documentElement)
256
+ .getPropertyValue(v)
257
+ .trim() ??
258
+ "") ||
259
+ INK;
187
260
 
188
261
  /* The tracks, axis, chips, and labels never change while the modal
189
262
  is open, so they paint once to an offscreen canvas at layout time;
@@ -195,20 +268,30 @@ export function createPathPlayer<S>(doc: Document, config: PathPlayerConfig<S>):
195
268
  const ctx = off.getContext("2d");
196
269
  if (!ctx) return null; // jsdom: geometry is tested; painting is not
197
270
  ctx.scale(scale, scale);
271
+
198
272
  /* The tracks follow the theme: paper is the field well, ink and rule
199
273
  the theme's own, so the strip reads as part of the plate by day
200
274
  and by night (owner call 2026-08-26). */
201
275
  const p: Painter = {
202
- ctx, width, xAt: (t) => playheadX(t, config.duration, width), cssColor,
203
- paper: cssColor("--well"), ink: cssColor("--ink"), rule: cssColor("--rule"),
276
+ ctx,
277
+ width,
278
+ xAt: (t) => playheadX(t, config.duration, width),
279
+ cssColor,
280
+ paper: cssColor("--well"),
281
+ ink: cssColor("--ink"),
282
+ rule: cssColor("--rule"),
204
283
  };
284
+
205
285
  let top = 0;
286
+
206
287
  for (const track of config.tracks) {
207
- if (track.kind === "band") paintBand(p, track, top, config.samples.length);
208
- else paintLines(p, track, top);
288
+ if (track.kind === "band") {
289
+ paintBand(p, track, top, config.samples.length);
290
+ } else paintLines(p, track, top);
209
291
  if (track.label) paintLabelChip(p, track.label, top);
210
292
  top += track.height + TRACK_GAP;
211
293
  }
294
+
212
295
  paintAxis(p, top, config.duration);
213
296
  return off;
214
297
  }
@@ -245,7 +328,8 @@ export function createPathPlayer<S>(doc: Document, config: PathPlayerConfig<S>):
245
328
  if (idx === state.lastIdx && !useStart) return;
246
329
  state.lastIdx = idx;
247
330
  const s = config.samples[idx];
248
- if (useStart) config.sink.start(s); else config.sink.move(s);
331
+ if (useStart) config.sink.start(s);
332
+ else config.sink.move(s);
249
333
  }
250
334
 
251
335
  /* Old shape of this function was tick(now): computed dt itself from
@@ -257,6 +341,7 @@ export function createPathPlayer<S>(doc: Document, config: PathPlayerConfig<S>):
257
341
  function frame(dt: number): void {
258
342
  if (state.playing) {
259
343
  state.t += dt;
344
+
260
345
  if (state.t >= config.duration) {
261
346
  state.t -= config.duration;
262
347
  applyCurrent(true); // teleport at the wrap, no smear
@@ -264,6 +349,7 @@ export function createPathPlayer<S>(doc: Document, config: PathPlayerConfig<S>):
264
349
  applyCurrent(false);
265
350
  }
266
351
  }
352
+
267
353
  drawFrame();
268
354
  readout.textContent = formatReadout(state.t, config.duration);
269
355
  }
@@ -285,20 +371,26 @@ export function createPathPlayer<S>(doc: Document, config: PathPlayerConfig<S>):
285
371
  if (doc.hidden) loop.stop();
286
372
  else if (pm.dialog.hasAttribute("open")) loop.start();
287
373
  }
374
+
288
375
  doc.addEventListener("visibilitychange", onVisibility);
289
376
 
290
377
  function setPlaying(playing: boolean): void {
291
378
  state.playing = playing;
292
379
  playBtn.innerHTML = playing ? ICON_PAUSE : ICON_PLAY;
293
380
  playBtn.setAttribute("aria-pressed", `${playing}`);
294
- if (playing) applyCurrent(true); else config.sink.stop();
381
+ if (playing) applyCurrent(true);
382
+ else config.sink.stop();
295
383
  }
296
384
 
297
- playBtn.addEventListener("click", () => { setPlaying(!state.playing); });
385
+ playBtn.addEventListener("click", () => {
386
+ setPlaying(!state.playing);
387
+ });
388
+
298
389
  restartBtn.addEventListener("click", () => {
299
390
  state.t = 0;
300
391
  applyCurrent(true);
301
392
  });
393
+
302
394
  pm.dialog.addEventListener("keydown", (ev) => {
303
395
  /* Space on a button is its native activation; handling it here too
304
396
  would toggle twice in one stroke. */
@@ -307,13 +399,16 @@ export function createPathPlayer<S>(doc: Document, config: PathPlayerConfig<S>):
307
399
  setPlaying(!state.playing);
308
400
  }
309
401
  });
402
+
310
403
  pm.dialog.addEventListener("close", () => {
311
404
  setPlaying(false);
312
405
  loop.stop();
313
406
  config.sink.pause?.();
314
407
  });
315
408
 
316
- if (SCRUB) attachScrub(timeline, state, config.duration, applyCurrent, setPlaying);
409
+ if (SCRUB) {
410
+ attachScrub(timeline, state, config.duration, applyCurrent, setPlaying);
411
+ }
317
412
 
318
413
  return {
319
414
  open: (opener) => {
@@ -323,19 +418,26 @@ export function createPathPlayer<S>(doc: Document, config: PathPlayerConfig<S>):
323
418
  layoutTracks();
324
419
  readout.textContent = formatReadout(state.t, config.duration);
325
420
  config.sink.resume?.();
421
+
326
422
  /* Reduced motion: open paused; the transport still plays on demand.
327
423
  jsdom has no matchMedia, hence the widened type (same guard as
328
424
  henry-loose.ts). Kept separate from the frame loop's `win`,
329
425
  which is cast non-null: this one must stay optional so a
330
426
  matchMedia-less jsdom does not throw. */
331
- const mmWin = doc.defaultView as { matchMedia?: typeof window.matchMedia } | null;
427
+ const mmWin = doc.defaultView as {
428
+ matchMedia?: typeof window.matchMedia;
429
+ } | null;
430
+
332
431
  const reduced = mmWin?.matchMedia
333
432
  ? mmWin.matchMedia("(prefers-reduced-motion: reduce)").matches
334
433
  : false;
434
+
335
435
  if (stageReady && !reduced) setPlaying(true);
336
436
  loop.start();
337
437
  },
338
- close: () => { pm.close(); },
438
+ close: () => {
439
+ pm.close();
440
+ },
339
441
  isPlaying: () => state.playing,
340
442
  };
341
443
  }
@@ -11,21 +11,31 @@ export interface PlateModalRefs {
11
11
  zone: HTMLDivElement;
12
12
  plate: HTMLDivElement;
13
13
  closeBtn: HTMLButtonElement;
14
- addLabel: (pos: "topLeft" | "bottomLeft" | "bottomRight", className?: string) => HTMLSpanElement;
14
+ addLabel: (
15
+ pos: "topLeft" | "bottomLeft" | "bottomRight",
16
+ className?: string,
17
+ ) => HTMLSpanElement;
15
18
  open: (opener: HTMLElement | null) => void;
16
19
  close: () => void;
17
20
  isOpen: () => boolean;
18
21
  }
19
22
 
20
- const POS_CLASS = { topLeft: "pm-label-tl", bottomLeft: "pm-label-bl", bottomRight: "pm-label-br" } as const;
23
+ const POS_CLASS = {
24
+ topLeft: "pm-label-tl",
25
+ bottomLeft: "pm-label-bl",
26
+ bottomRight: "pm-label-br",
27
+ } as const;
21
28
 
22
29
  export function buildPlateModal(
23
30
  doc: Document,
24
31
  opts: { ariaLabel: string; legacyPrefix?: string; closeLabel?: string },
25
32
  ): PlateModalRefs {
26
33
  const { closeLabel = "Close" } = opts;
34
+
27
35
  const legacy = (name: string): string[] =>
28
- opts.legacyPrefix ? [`pm-${name}`, `${opts.legacyPrefix}-${name}`] : [`pm-${name}`];
36
+ opts.legacyPrefix
37
+ ? [`pm-${name}`, `${opts.legacyPrefix}-${name}`]
38
+ : [`pm-${name}`];
29
39
 
30
40
  const dialog = doc.createElement("dialog");
31
41
  dialog.classList.add(...legacy("dialog"));
@@ -33,6 +43,7 @@ export function buildPlateModal(
33
43
 
34
44
  const zone = doc.createElement("div");
35
45
  zone.classList.add(...legacy("zone"));
46
+
36
47
  for (const c of ["tl", "tr", "bl", "br"]) {
37
48
  const corner = doc.createElement("div");
38
49
  corner.classList.add(...legacy("corner"), ...legacy(`c-${c}`));
@@ -59,11 +70,16 @@ export function buildPlateModal(
59
70
  path out (close box, veil click, Escape, a consumer's close())
60
71
  unlocks it. */
61
72
  const root = doc.documentElement;
62
- closeBtn.addEventListener("click", () => { dialog.close(); });
73
+
74
+ closeBtn.addEventListener("click", () => {
75
+ dialog.close();
76
+ });
77
+
63
78
  dialog.addEventListener("close", () => {
64
79
  root.classList.remove("pm-open");
65
80
  opener?.focus();
66
81
  });
82
+
67
83
  /* The dialog fills the viewport; a veil click targets the dialog
68
84
  element itself (everything inside the zone targets a descendant).
69
85
  Checked by identity, not zone.contains: a handler that swaps a
@@ -74,18 +90,38 @@ export function buildPlateModal(
74
90
  if (ev.target === dialog) dialog.close();
75
91
  });
76
92
 
77
- const addLabel = (pos: "topLeft" | "bottomLeft" | "bottomRight", className?: string): HTMLSpanElement => {
93
+ const addLabel = (
94
+ pos: "topLeft" | "bottomLeft" | "bottomRight",
95
+ className?: string,
96
+ ): HTMLSpanElement => {
78
97
  const span = doc.createElement("span");
79
- span.classList.add("pm-label", POS_CLASS[pos], "boxed-label", "micro-label");
98
+
99
+ span.classList.add(
100
+ "pm-label",
101
+ POS_CLASS[pos],
102
+ "boxed-label",
103
+ "micro-label",
104
+ );
105
+
80
106
  if (className) span.classList.add(...className.split(" "));
81
107
  plate.append(span);
82
108
  return span;
83
109
  };
84
110
 
85
111
  return {
86
- dialog, zone, plate, closeBtn, addLabel,
87
- open: (o) => { opener = o; dialog.showModal(); root.classList.add("pm-open"); },
88
- close: () => { dialog.close(); },
112
+ dialog,
113
+ zone,
114
+ plate,
115
+ closeBtn,
116
+ addLabel,
117
+ open: (o) => {
118
+ opener = o;
119
+ dialog.showModal();
120
+ root.classList.add("pm-open");
121
+ },
122
+ close: () => {
123
+ dialog.close();
124
+ },
89
125
  isOpen: () => dialog.open,
90
126
  };
91
127
  }
@@ -9,17 +9,22 @@ export interface ScrollTopOptions {
9
9
  watchId?: string;
10
10
  }
11
11
 
12
- export const mountScrollTop: Island<ScrollTopOptions> = (root, options = {}): IslandHandle => {
12
+ export const mountScrollTop: Island<ScrollTopOptions> = (
13
+ root,
14
+ options = {},
15
+ ): IslandHandle => {
13
16
  const { buttonId = "scroll-to-top", watchId = "masthead" } = options;
14
17
  const doc = docOf(root);
15
18
  // Scroll-to-top floater: shows once the header leaves the viewport.
16
19
  const toTop = doc.getElementById(buttonId);
17
20
  const masthead = doc.getElementById(watchId);
18
21
  let observer: IntersectionObserver | null = null;
22
+
19
23
  if (toTop && masthead && claim(toTop, "scroll-top")) {
20
24
  observer = new IntersectionObserver(([entry]) => {
21
25
  toTop.classList.toggle("show", !entry.isIntersecting);
22
26
  });
27
+
23
28
  observer.observe(masthead);
24
29
  }
25
30