@half-built/astro 0.1.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/ICONS-LICENSE +43 -0
  2. package/LICENSE +21 -0
  3. package/README.md +16 -0
  4. package/package.json +18 -0
  5. package/src/components/CategoryCard.astro +31 -0
  6. package/src/components/CornerBadges.astro +40 -0
  7. package/src/components/Footer.astro +209 -0
  8. package/src/components/LightboxLink.astro +13 -0
  9. package/src/components/LinkListWidget.astro +19 -0
  10. package/src/components/Pagination.astro +72 -0
  11. package/src/components/PostCard.astro +166 -0
  12. package/src/components/PostNavigation.astro +61 -0
  13. package/src/components/Shell.astro +52 -0
  14. package/src/components/SiteHeader.astro +326 -0
  15. package/src/components/SmartImage.astro +34 -0
  16. package/src/components/Subscribe.astro +117 -0
  17. package/src/components/ThemeToggle.astro +41 -0
  18. package/src/components/TwoColumn.astro +12 -0
  19. package/src/components/Widget.astro +41 -0
  20. package/src/components/content/BlogImage.astro +39 -0
  21. package/src/components/content/Button.astro +57 -0
  22. package/src/components/content/Callout.astro +75 -0
  23. package/src/components/content/CodeBlock.astro +7 -0
  24. package/src/components/content/Gallery.astro +57 -0
  25. package/src/components/content/GalleryImage.astro +35 -0
  26. package/src/components/content/Group.astro +15 -0
  27. package/src/components/content/MediaText.astro +60 -0
  28. package/src/components/content/Palette.astro +42 -0
  29. package/src/components/content/Quote.astro +21 -0
  30. package/src/components/content/Spacer.astro +5 -0
  31. package/src/components/content/Step.astro +126 -0
  32. package/src/components/content/Walkthrough.astro +42 -0
  33. package/src/components/models.ts +77 -0
  34. package/src/lib/archive.ts +29 -0
  35. package/src/lib/drafts.ts +52 -0
  36. package/src/lib/format-date.ts +9 -0
  37. package/src/lib/header-date.ts +6 -0
  38. package/src/lib/ordering.ts +18 -0
  39. package/src/lib/paginate.ts +15 -0
  40. package/src/lib/reading-time.ts +4 -0
  41. package/src/lib/slug.ts +73 -0
  42. package/src/scripts/code-island.ts +75 -0
  43. package/src/scripts/core/breakpoints.ts +4 -0
  44. package/src/scripts/core/dom.ts +31 -0
  45. package/src/scripts/core/frame-loop.ts +54 -0
  46. package/src/scripts/core/icons.ts +30 -0
  47. package/src/scripts/core/island.ts +25 -0
  48. package/src/scripts/core/storage.ts +44 -0
  49. package/src/scripts/focus-mode.ts +41 -0
  50. package/src/scripts/lightbox.ts +446 -0
  51. package/src/scripts/link-tip.ts +154 -0
  52. package/src/scripts/path-player-math.ts +34 -0
  53. package/src/scripts/path-player-paint.ts +154 -0
  54. package/src/scripts/path-player.ts +341 -0
  55. package/src/scripts/plate-modal.ts +91 -0
  56. package/src/scripts/scroll-top.ts +32 -0
  57. package/src/scripts/site-header.ts +73 -0
  58. package/src/scripts/subscribe.ts +116 -0
  59. package/src/scripts/theme-toggle.ts +115 -0
  60. package/src/shiki/code-theme.mjs +16 -0
@@ -0,0 +1,154 @@
1
+ /* Canvas painters for the path-player timeline; each takes a Painter
2
+ and paints one thing at a given top. Pure over the context they are
3
+ handed, so tests drive them with a recording context (test/helpers.ts)
4
+ and never need a real canvas. The transport in path-player.ts composes
5
+ them once per layout onto an offscreen canvas. */
6
+ import { normalizeSeries, brightChannel, columnIndex, seriesY } from "./path-player-math";
7
+ import type { BandTrack, LinesTrack } from "./path-player";
8
+
9
+ export const AXIS_STEP_S = 2;
10
+ /* Theme-less defaults for the timeline's paper, ink, and rule, used when
11
+ a Painter carries none (the pure painter tests). The transport passes
12
+ the theme's --well, --ink, and --rule so the tracks follow day and
13
+ night like the rest of the plate (owner call 2026-08-26). */
14
+ export const PAPER = "#ffffff";
15
+ export const INK = "#111111";
16
+ export const ink = (alpha: number): string => `rgba(17, 17, 17, ${alpha})`;
17
+
18
+ /* Everything the static painters share: the context, the plate width,
19
+ the time-to-x map, a CSS custom property reader for series colors,
20
+ and the theme's paper, ink, and rule. */
21
+ export interface Painter {
22
+ ctx: CanvasRenderingContext2D;
23
+ width: number;
24
+ xAt: (t: number) => number;
25
+ cssColor: (v: string) => string;
26
+ paper?: string;
27
+ ink?: string;
28
+ rule?: string;
29
+ }
30
+
31
+ const paperOf = (p: Painter): string => p.paper ?? PAPER;
32
+ const ruleOf = (p: Painter): string => p.rule ?? INK;
33
+
34
+ /* The ink at an alpha. Custom properties resolve to hex, which the
35
+ canvas cannot take with an alpha, so the hex is split here; any
36
+ other form falls back to the fixed ink. */
37
+ export function inkOf(p: Painter, alpha: number): string {
38
+ const m = /^#([0-9a-f]{6})$/i.exec(p.ink ?? "");
39
+ if (!m) return ink(alpha);
40
+ const n = parseInt(m[1], 16);
41
+ return `rgba(${n >> 16}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
42
+ }
43
+
44
+ /* The 2px rule every track wears, inside its own edge, so a band and a
45
+ lines track sit in the same frame the window does. */
46
+ function frameTrack(p: Painter, top: number, height: number): void {
47
+ p.ctx.strokeStyle = ruleOf(p);
48
+ p.ctx.lineWidth = 2;
49
+ p.ctx.strokeRect(1, top + 1, p.width - 2, height - 2);
50
+ }
51
+
52
+ /* Polyline of a series across the track, one point per pixel column,
53
+ 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 {
55
+ const n = normalizeSeries(values);
56
+ p.ctx.beginPath();
57
+ 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);
60
+ }
61
+ p.ctx.strokeStyle = style;
62
+ p.ctx.lineWidth = 1.5;
63
+ p.ctx.stroke();
64
+ }
65
+
66
+ /* A band track is a color strip, one sample per column, with the
67
+ envelope drawn over it in white. */
68
+ export function paintBand(p: Painter, track: BandTrack, top: number, sampleCount: number): void {
69
+ for (let px = 0; px < p.width; px++) {
70
+ const c = track.colorAt(columnIndex(px, p.width, sampleCount));
71
+ p.ctx.fillStyle = `rgb(${brightChannel(c.r)}, ${brightChannel(c.g)}, ${brightChannel(c.b)})`;
72
+ p.ctx.fillRect(px, top, 1.5, track.height);
73
+ }
74
+ const env = Array.from({ length: sampleCount }, (_, i) => track.envelopeAt(i));
75
+ strokeSeries(p, env, top, track.height, 3, "rgba(255, 255, 255, 0.9)");
76
+ frameTrack(p, top, track.height);
77
+ }
78
+
79
+ /* A lines track is a paper panel: shaded regions, labeled ticks, one
80
+ polyline per series in its CSS color, series labels stacked at the
81
+ right edge. */
82
+ export function paintLines(p: Painter, track: LinesTrack, top: number): void {
83
+ const { ctx, width, xAt } = p;
84
+ ctx.fillStyle = paperOf(p);
85
+ ctx.fillRect(0, top, width, track.height);
86
+ ctx.fillStyle = inkOf(p, 0.07);
87
+ for (const r of track.regions) {
88
+ ctx.fillRect(xAt(r.t0), top, xAt(r.t1) - xAt(r.t0), track.height);
89
+ }
90
+ ctx.strokeStyle = inkOf(p, 0.35);
91
+ ctx.fillStyle = inkOf(p, 0.55);
92
+ ctx.lineWidth = 1;
93
+ ctx.font = "8px sans-serif";
94
+ ctx.textAlign = "center";
95
+ for (const tick of track.ticks) {
96
+ ctx.beginPath();
97
+ ctx.moveTo(xAt(tick.t), top);
98
+ ctx.lineTo(xAt(tick.t), top + track.height);
99
+ ctx.stroke();
100
+ ctx.fillText(tick.label, xAt(tick.t), top + 8);
101
+ }
102
+ for (const s of track.series) {
103
+ strokeSeries(p, s.values, top, track.height, 5, p.cssColor(s.cssVar));
104
+ }
105
+ /* Series labels in series order, left to right, ending at the right
106
+ edge (X Y Z, owner call 2026-08-26; stacking from the edge read
107
+ backwards). */
108
+ ctx.font = "bold 9px sans-serif";
109
+ ctx.textAlign = "right";
110
+ const last = track.series.length - 1;
111
+ track.series.forEach((s, i) => {
112
+ ctx.fillStyle = p.cssColor(s.cssVar);
113
+ ctx.fillText(s.label.toUpperCase(), width - 4 - (last - i) * 14, top + track.height - 4);
114
+ });
115
+ frameTrack(p, top, track.height);
116
+ }
117
+
118
+ /* The chip names the track the way the caption does (FEED, PATH);
119
+ white ground so it reads over the band colors too. */
120
+ export function paintLabelChip(p: Painter, label: string, top: number): void {
121
+ const { ctx } = p;
122
+ ctx.font = "bold 9px sans-serif";
123
+ ctx.textAlign = "left";
124
+ const labelW = ctx.measureText(label).width;
125
+ ctx.fillStyle = paperOf(p);
126
+ ctx.fillRect(2, top + 2, labelW + 8, 12);
127
+ ctx.strokeStyle = inkOf(p, 0.35);
128
+ ctx.lineWidth = 1;
129
+ ctx.strokeRect(2.5, top + 2.5, labelW + 7, 11);
130
+ ctx.fillStyle = inkOf(p, 0.8);
131
+ ctx.fillText(label, 6, top + 11);
132
+ }
133
+
134
+ /* Time axis under the last track: a tick and "Ns" label every
135
+ AXIS_STEP_S seconds. A label that would run off the canvas is
136
+ dropped; its tick stays (phone report 2026-08-27: the demo's "18s"
137
+ clipped to "18" on narrow plates, and flipping it left of the tick
138
+ crowded the neighbor, so the owner chose the empty end). */
139
+ export function paintAxis(p: Painter, top: number, duration: number): void {
140
+ const { ctx, xAt, width } = p;
141
+ ctx.fillStyle = inkOf(p, 0.5);
142
+ ctx.strokeStyle = inkOf(p, 0.3);
143
+ ctx.font = "8px sans-serif";
144
+ ctx.textAlign = "left";
145
+ for (let t = 0; t < duration; t += AXIS_STEP_S) {
146
+ const x = xAt(t);
147
+ ctx.beginPath();
148
+ ctx.moveTo(x, top + 2);
149
+ ctx.lineTo(x, top + 6);
150
+ ctx.stroke();
151
+ const label = `${t}s`;
152
+ if (x + 2 + ctx.measureText(label).width <= width) ctx.fillText(label, x + 2, top + 11);
153
+ }
154
+ }
@@ -0,0 +1,341 @@
1
+ /* Generic transport-and-timeline player over a plate-modal (spec
2
+ section 3). Knows nothing about fluids: samples, tracks, and the
3
+ sink come from the caller, and the sample type S is the caller's
4
+ too; the player only indexes the array and hands samples to the
5
+ sink. Scrubbing is implemented but ships off (owner call
6
+ 2026-08-22); flip SCRUB to re-enable. */
7
+ import { buildPlateModal } from "./plate-modal";
8
+ import { createFrameLoop } from "./core/frame-loop";
9
+ import { ICON_PLAY, ICON_PAUSE, ICON_ROTATE_CCW } from "./core/icons";
10
+ import { playheadX, formatReadout, columnIndex } from "./path-player-math";
11
+ import {
12
+ type Painter, paintBand, paintLines, paintLabelChip, paintAxis, INK,
13
+ } from "./path-player-paint";
14
+
15
+ /* Widened to `boolean` via assertion (not a `: boolean` annotation,
16
+ which no-inferrable-types rejects on a literal) so flipping this to
17
+ re-enable scrubbing is a one-line change: a literal type here would
18
+ make the `if (SCRUB)` below statically always-false and trip the
19
+ no-unnecessary-condition lint rule. Same widening idea as
20
+ src/scripts/henry-loose.ts's matchMedia guard. */
21
+ const SCRUB = false as boolean;
22
+ const AXIS_H = 14;
23
+ const TRACK_GAP = 5;
24
+ /* Caption ids: one player per page today, but aria-controls needs a
25
+ unique target if a second ever lands. */
26
+ let captionSeq = 0;
27
+ export interface BandTrack {
28
+ kind: "band";
29
+ height: number;
30
+ /* Painted as a small chip at the track's top-left, naming the track
31
+ the way the modal caption refers to it (FEED, PATH). */
32
+ label?: string;
33
+ colorAt: (i: number) => { r: number; g: number; b: number };
34
+ envelopeAt: (i: number) => number;
35
+ }
36
+ export interface LinesTrack {
37
+ kind: "lines";
38
+ height: number;
39
+ label?: string;
40
+ series: { label: string; values: number[]; cssVar: string }[];
41
+ regions: { t0: number; t1: number }[];
42
+ ticks: { t: number; label: string }[];
43
+ }
44
+ export type TrackSpec = BandTrack | LinesTrack;
45
+
46
+ export interface PathPlayerConfig<S> {
47
+ title: string;
48
+ source: string;
49
+ caption: string;
50
+ duration: number;
51
+ samples: S[];
52
+ tracks: TrackSpec[];
53
+ buildStage: (viewbox: HTMLElement) => boolean | undefined;
54
+ sink: {
55
+ start: (s: S) => void; move: (s: S) => void; stop: () => void;
56
+ pause?: () => void; resume?: () => void;
57
+ };
58
+ /* Transport and caption-toggle text, defaulting to the English copy
59
+ this player shipped with. A caller with its own site voice (or a
60
+ future translation) overrides one, two, or all three. */
61
+ labels?: { play?: string; restart?: string; about?: string };
62
+ }
63
+
64
+ export interface PathPlayerHandle {
65
+ open: (opener: HTMLElement | null) => void;
66
+ close: () => void;
67
+ isPlaying: () => boolean;
68
+ }
69
+
70
+ /* Transport state shared with the optional scrub handlers. */
71
+ interface TransportState { t: number; playing: boolean; lastIdx: number }
72
+
73
+ /* Read-only timeline shipped (owner call 2026-08-22). When enabled:
74
+ pointer drag pauses the clock and paints the emitter along the path;
75
+ the fluid itself is never seekable, only the input. */
76
+ function attachScrub(
77
+ timeline: HTMLElement, state: TransportState, duration: number,
78
+ applyCurrent: (useStart: boolean) => void, setPlaying: (playing: boolean) => void,
79
+ ): void {
80
+ let active = false;
81
+ let wasPlaying = false;
82
+ const seek = (ev: PointerEvent): void => {
83
+ const rect = timeline.getBoundingClientRect();
84
+ const frac = Math.max(0, Math.min(1, (ev.clientX - rect.left) / (rect.width || 1)));
85
+ state.t = frac * duration;
86
+ };
87
+ timeline.addEventListener("pointerdown", (ev) => {
88
+ active = true;
89
+ wasPlaying = state.playing;
90
+ state.playing = false;
91
+ timeline.setPointerCapture(ev.pointerId);
92
+ seek(ev);
93
+ applyCurrent(true);
94
+ });
95
+ timeline.addEventListener("pointermove", (ev) => {
96
+ if (!active) return;
97
+ seek(ev);
98
+ applyCurrent(false);
99
+ });
100
+ timeline.addEventListener("pointerup", () => {
101
+ active = false;
102
+ setPlaying(wasPlaying);
103
+ });
104
+ }
105
+
106
+ export function createPathPlayer<S>(doc: Document, config: PathPlayerConfig<S>): PathPlayerHandle {
107
+ const { play = "Play or pause", restart = "Restart", about = "ABOUT" } = config.labels ?? {};
108
+ const pm = buildPlateModal(doc, { ariaLabel: config.title });
109
+ pm.addLabel("topLeft", "pp-title").textContent = config.title;
110
+ pm.addLabel("bottomLeft", "pp-source").textContent = config.source;
111
+ const readout = pm.addLabel("bottomRight", "pp-readout");
112
+
113
+ const viewbox = doc.createElement("div");
114
+ viewbox.className = "pp-viewbox";
115
+ const stageReady = config.buildStage(viewbox) !== false;
116
+
117
+ /* Transport straddles the plate's bottom edge in the manner of the
118
+ boxed labels (owner call 2026-08-23); the timeline gets the full
119
+ plate width. */
120
+ const transport = doc.createElement("div");
121
+ transport.className = "pp-transport";
122
+ const playBtn = doc.createElement("button");
123
+ playBtn.type = "button";
124
+ playBtn.className = "pp-play icon-box press-box";
125
+ playBtn.setAttribute("aria-label", play);
126
+ const restartBtn = doc.createElement("button");
127
+ restartBtn.type = "button";
128
+ restartBtn.className = "pp-restart icon-box press-box";
129
+ restartBtn.setAttribute("aria-label", restart);
130
+ restartBtn.innerHTML = ICON_ROTATE_CCW;
131
+ if (!stageReady) { playBtn.disabled = true; restartBtn.disabled = true; }
132
+ const timeline = doc.createElement("div");
133
+ timeline.className = "pp-timeline";
134
+ const tracksCanvas = doc.createElement("canvas");
135
+ tracksCanvas.className = "pp-tracks";
136
+ timeline.append(tracksCanvas);
137
+ transport.append(playBtn, restartBtn);
138
+
139
+ const caption = doc.createElement("p");
140
+ caption.className = "pp-caption";
141
+ caption.id = `pp-caption-${String(++captionSeq)}`;
142
+ caption.textContent = config.caption;
143
+
144
+ /* Phones: the caption is the biggest discretionary block in the
145
+ plate (nine lines at xs), so it hides behind a toggle until asked
146
+ for (spec 2026-08-25, item 1). Wider viewports never see the
147
+ toggle (path-player.css) and always see the caption. */
148
+ const captionToggle = doc.createElement("button");
149
+ captionToggle.type = "button";
150
+ captionToggle.className = "pp-caption-toggle boxed-label micro-label press-box";
151
+ captionToggle.textContent = about;
152
+ captionToggle.setAttribute("aria-controls", caption.id);
153
+ /* Same widening as henry-loose.ts: jsdom has no matchMedia, and a
154
+ bare typeof check reads as always-true to the lint. The query list
155
+ type is widened too so a test stub without addEventListener is
156
+ still valid. */
157
+ interface PhoneQuery { matches: boolean; addEventListener?: (type: "change", cb: (ev: { matches: boolean }) => void) => void }
158
+ const phoneQuery = (): PhoneQuery | null => {
159
+ const mediaQuery = (globalThis as { matchMedia?: (q: string) => PhoneQuery }).matchMedia;
160
+ return mediaQuery ? mediaQuery.call(globalThis, "(max-width: 768px)") : null;
161
+ };
162
+ const phone = (): boolean => phoneQuery()?.matches ?? false;
163
+ const setCaptionShown = (shown: boolean): void => {
164
+ caption.hidden = !shown;
165
+ captionToggle.setAttribute("aria-expanded", String(shown));
166
+ };
167
+ captionToggle.addEventListener("click", () => { setCaptionShown(caption.hidden); });
168
+ /* Rotating with the modal open crosses the breakpoint: follow it so
169
+ a caption hidden in portrait is not stranded behind a toggle that
170
+ landscape no longer shows (final review 2026-08-26). */
171
+ phoneQuery()?.addEventListener?.("change", (ev) => { setCaptionShown(!ev.matches); });
172
+
173
+ /* pp-plate scopes the player's narrow-viewport flex reflow without
174
+ touching the lightbox's shared pm-plate rules. */
175
+ pm.plate.classList.add("pp-plate");
176
+ pm.plate.append(viewbox, timeline, captionToggle, caption, transport);
177
+
178
+ const state: TransportState = { t: 0, playing: false, lastIdx: -1 };
179
+ /* 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;
181
+ let staticTracks: HTMLCanvasElement | null = null;
182
+
183
+ const dpr = (): number => doc.defaultView?.devicePixelRatio ?? 1;
184
+ const sampleIndexAt = (t: number): number => columnIndex(t, config.duration, config.samples.length);
185
+ const cssColor = (v: string): string =>
186
+ (doc.defaultView?.getComputedStyle(doc.documentElement).getPropertyValue(v).trim() ?? "") || INK;
187
+
188
+ /* The tracks, axis, chips, and labels never change while the modal
189
+ is open, so they paint once to an offscreen canvas at layout time;
190
+ drawFrame stamps it and draws the playhead over it. */
191
+ function paintStatic(width: number, scale: number): HTMLCanvasElement | null {
192
+ const off = doc.createElement("canvas");
193
+ off.width = Math.floor(width * scale);
194
+ off.height = Math.floor(timelineHeight * scale);
195
+ const ctx = off.getContext("2d");
196
+ if (!ctx) return null; // jsdom: geometry is tested; painting is not
197
+ ctx.scale(scale, scale);
198
+ /* The tracks follow the theme: paper is the field well, ink and rule
199
+ the theme's own, so the strip reads as part of the plate by day
200
+ and by night (owner call 2026-08-26). */
201
+ const p: Painter = {
202
+ ctx, width, xAt: (t) => playheadX(t, config.duration, width), cssColor,
203
+ paper: cssColor("--well"), ink: cssColor("--ink"), rule: cssColor("--rule"),
204
+ };
205
+ let top = 0;
206
+ for (const track of config.tracks) {
207
+ if (track.kind === "band") paintBand(p, track, top, config.samples.length);
208
+ else paintLines(p, track, top);
209
+ if (track.label) paintLabelChip(p, track.label, top);
210
+ top += track.height + TRACK_GAP;
211
+ }
212
+ paintAxis(p, top, config.duration);
213
+ return off;
214
+ }
215
+
216
+ function layoutTracks(): void {
217
+ const scale = dpr();
218
+ const w = timeline.clientWidth || 600;
219
+ tracksCanvas.style.height = `${timelineHeight}px`;
220
+ tracksCanvas.width = Math.floor(w * scale);
221
+ tracksCanvas.height = Math.floor(timelineHeight * scale);
222
+ staticTracks = paintStatic(w, scale);
223
+ }
224
+
225
+ function drawFrame(): void {
226
+ const ctx = tracksCanvas.getContext("2d");
227
+ if (!ctx || !staticTracks) return;
228
+ const scale = dpr();
229
+ ctx.clearRect(0, 0, tracksCanvas.width, tracksCanvas.height);
230
+ ctx.drawImage(staticTracks, 0, 0);
231
+ const px = playheadX(state.t, config.duration, tracksCanvas.width / scale);
232
+ ctx.save();
233
+ ctx.scale(scale, scale);
234
+ ctx.strokeStyle = cssColor("--ink");
235
+ ctx.lineWidth = 2;
236
+ ctx.beginPath();
237
+ ctx.moveTo(px, 0);
238
+ ctx.lineTo(px, timelineHeight - AXIS_H);
239
+ ctx.stroke();
240
+ ctx.restore();
241
+ }
242
+
243
+ function applyCurrent(useStart: boolean): void {
244
+ const idx = sampleIndexAt(state.t);
245
+ if (idx === state.lastIdx && !useStart) return;
246
+ state.lastIdx = idx;
247
+ const s = config.samples[idx];
248
+ if (useStart) config.sink.start(s); else config.sink.move(s);
249
+ }
250
+
251
+ /* Old shape of this function was tick(now): computed dt itself from
252
+ now/state.lastNow and re-armed its own requestAnimationFrame. The
253
+ frame loop now owns the clock and the scheduling; this is just the
254
+ per-frame body. dt is 0 on the first frame after any loop.start(),
255
+ which preserves the old skip-first-frame behavior without a
256
+ dedicated lastNow field. */
257
+ function frame(dt: number): void {
258
+ if (state.playing) {
259
+ state.t += dt;
260
+ if (state.t >= config.duration) {
261
+ state.t -= config.duration;
262
+ applyCurrent(true); // teleport at the wrap, no smear
263
+ } else {
264
+ applyCurrent(false);
265
+ }
266
+ }
267
+ drawFrame();
268
+ readout.textContent = formatReadout(state.t, config.duration);
269
+ }
270
+
271
+ /* Cast, not an optional fallback: this player is always built against
272
+ a live document (same idiom the reduced-motion check below uses for
273
+ doc.defaultView). Only the frame loop's own rAF/cAF calls need this;
274
+ jsdom's stub in tests supplies them on the same window object. */
275
+ const win = doc.defaultView as Window;
276
+ const loop = createFrameLoop(win, frame, { clamp: 0.1, firstDt: 0 });
277
+
278
+ /* Visibility pause (step 10): the loop halts while the tab is hidden
279
+ and resumes when it returns with the dialog still open. Playback
280
+ state is untouched; the frame loop's clock reset means the hidden
281
+ gap never arrives as one giant dt. Registered once at build and
282
+ never removed: the player has no destroy path and stays reopenable
283
+ for the life of the page. */
284
+ function onVisibility(): void {
285
+ if (doc.hidden) loop.stop();
286
+ else if (pm.dialog.hasAttribute("open")) loop.start();
287
+ }
288
+ doc.addEventListener("visibilitychange", onVisibility);
289
+
290
+ function setPlaying(playing: boolean): void {
291
+ state.playing = playing;
292
+ playBtn.innerHTML = playing ? ICON_PAUSE : ICON_PLAY;
293
+ playBtn.setAttribute("aria-pressed", `${playing}`);
294
+ if (playing) applyCurrent(true); else config.sink.stop();
295
+ }
296
+
297
+ playBtn.addEventListener("click", () => { setPlaying(!state.playing); });
298
+ restartBtn.addEventListener("click", () => {
299
+ state.t = 0;
300
+ applyCurrent(true);
301
+ });
302
+ pm.dialog.addEventListener("keydown", (ev) => {
303
+ /* Space on a button is its native activation; handling it here too
304
+ would toggle twice in one stroke. */
305
+ if (ev.key === " " && !(ev.target instanceof HTMLButtonElement)) {
306
+ ev.preventDefault();
307
+ setPlaying(!state.playing);
308
+ }
309
+ });
310
+ pm.dialog.addEventListener("close", () => {
311
+ setPlaying(false);
312
+ loop.stop();
313
+ config.sink.pause?.();
314
+ });
315
+
316
+ if (SCRUB) attachScrub(timeline, state, config.duration, applyCurrent, setPlaying);
317
+
318
+ return {
319
+ open: (opener) => {
320
+ loop.stop();
321
+ pm.open(opener);
322
+ setCaptionShown(!phone());
323
+ layoutTracks();
324
+ readout.textContent = formatReadout(state.t, config.duration);
325
+ config.sink.resume?.();
326
+ /* Reduced motion: open paused; the transport still plays on demand.
327
+ jsdom has no matchMedia, hence the widened type (same guard as
328
+ henry-loose.ts). Kept separate from the frame loop's `win`,
329
+ which is cast non-null: this one must stay optional so a
330
+ matchMedia-less jsdom does not throw. */
331
+ const mmWin = doc.defaultView as { matchMedia?: typeof window.matchMedia } | null;
332
+ const reduced = mmWin?.matchMedia
333
+ ? mmWin.matchMedia("(prefers-reduced-motion: reduce)").matches
334
+ : false;
335
+ if (stageReady && !reduced) setPlaying(true);
336
+ loop.start();
337
+ },
338
+ close: () => { pm.close(); },
339
+ isPlaying: () => state.playing,
340
+ };
341
+ }
@@ -0,0 +1,91 @@
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. */
6
+
7
+ import { ICON_X } from "./core/icons";
8
+
9
+ export interface PlateModalRefs {
10
+ dialog: HTMLDialogElement;
11
+ zone: HTMLDivElement;
12
+ plate: HTMLDivElement;
13
+ closeBtn: HTMLButtonElement;
14
+ addLabel: (pos: "topLeft" | "bottomLeft" | "bottomRight", className?: string) => HTMLSpanElement;
15
+ open: (opener: HTMLElement | null) => void;
16
+ close: () => void;
17
+ isOpen: () => boolean;
18
+ }
19
+
20
+ const POS_CLASS = { topLeft: "pm-label-tl", bottomLeft: "pm-label-bl", bottomRight: "pm-label-br" } as const;
21
+
22
+ export function buildPlateModal(
23
+ doc: Document,
24
+ opts: { ariaLabel: string; legacyPrefix?: string; closeLabel?: string },
25
+ ): PlateModalRefs {
26
+ const { closeLabel = "Close" } = opts;
27
+ const legacy = (name: string): string[] =>
28
+ opts.legacyPrefix ? [`pm-${name}`, `${opts.legacyPrefix}-${name}`] : [`pm-${name}`];
29
+
30
+ const dialog = doc.createElement("dialog");
31
+ dialog.classList.add(...legacy("dialog"));
32
+ dialog.setAttribute("aria-label", opts.ariaLabel);
33
+
34
+ const zone = doc.createElement("div");
35
+ zone.classList.add(...legacy("zone"));
36
+ for (const c of ["tl", "tr", "bl", "br"]) {
37
+ const corner = doc.createElement("div");
38
+ corner.classList.add(...legacy("corner"), ...legacy(`c-${c}`));
39
+ zone.append(corner);
40
+ }
41
+
42
+ const plate = doc.createElement("div");
43
+ plate.classList.add(...legacy("plate"), "bracket-frame");
44
+
45
+ const closeBtn = doc.createElement("button");
46
+ closeBtn.type = "button";
47
+ closeBtn.classList.add(...legacy("close"), "icon-box");
48
+ closeBtn.setAttribute("aria-label", closeLabel);
49
+ closeBtn.innerHTML = ICON_X;
50
+ plate.append(closeBtn);
51
+
52
+ zone.append(plate);
53
+ dialog.append(zone);
54
+ doc.body.append(dialog);
55
+
56
+ let opener: HTMLElement | null = null;
57
+ /* The page scroll lock (overflow: hidden via html.pm-open, see
58
+ plate-modal.css). Cleared on the dialog's close event so every
59
+ path out (close box, veil click, Escape, a consumer's close())
60
+ unlocks it. */
61
+ const root = doc.documentElement;
62
+ closeBtn.addEventListener("click", () => { dialog.close(); });
63
+ dialog.addEventListener("close", () => {
64
+ root.classList.remove("pm-open");
65
+ opener?.focus();
66
+ });
67
+ /* The dialog fills the viewport; a veil click targets the dialog
68
+ element itself (everything inside the zone targets a descendant).
69
+ Checked by identity, not zone.contains: a handler that swaps a
70
+ button's innerHTML detaches the click's target mid-bubble, and a
71
+ detached node is contained by nothing, which read as a veil click
72
+ and closed the dialog under the pause button (found 2026-08-23). */
73
+ dialog.addEventListener("click", (ev) => {
74
+ if (ev.target === dialog) dialog.close();
75
+ });
76
+
77
+ const addLabel = (pos: "topLeft" | "bottomLeft" | "bottomRight", className?: string): HTMLSpanElement => {
78
+ const span = doc.createElement("span");
79
+ span.classList.add("pm-label", POS_CLASS[pos], "boxed-label", "micro-label");
80
+ if (className) span.classList.add(...className.split(" "));
81
+ plate.append(span);
82
+ return span;
83
+ };
84
+
85
+ return {
86
+ dialog, zone, plate, closeBtn, addLabel,
87
+ open: (o) => { opener = o; dialog.showModal(); root.classList.add("pm-open"); },
88
+ close: () => { dialog.close(); },
89
+ isOpen: () => dialog.open,
90
+ };
91
+ }
@@ -0,0 +1,32 @@
1
+ import { claim, release, type Island, type IslandHandle } from "./core/island";
2
+ import { docOf } from "./core/dom";
3
+
4
+ /* Scroll-to-top floater island (step 9), born from Base.astro's inline
5
+ script: shows once the header leaves the viewport. */
6
+
7
+ export interface ScrollTopOptions {
8
+ buttonId?: string;
9
+ watchId?: string;
10
+ }
11
+
12
+ export const mountScrollTop: Island<ScrollTopOptions> = (root, options = {}): IslandHandle => {
13
+ const { buttonId = "scroll-to-top", watchId = "masthead" } = options;
14
+ const doc = docOf(root);
15
+ // Scroll-to-top floater: shows once the header leaves the viewport.
16
+ const toTop = doc.getElementById(buttonId);
17
+ const masthead = doc.getElementById(watchId);
18
+ let observer: IntersectionObserver | null = null;
19
+ if (toTop && masthead && claim(toTop, "scroll-top")) {
20
+ observer = new IntersectionObserver(([entry]) => {
21
+ toTop.classList.toggle("show", !entry.isIntersecting);
22
+ });
23
+ observer.observe(masthead);
24
+ }
25
+
26
+ return {
27
+ destroy(): void {
28
+ observer?.disconnect();
29
+ if (toTop && observer) release(toTop, "scroll-top");
30
+ },
31
+ };
32
+ };