@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.
- package/ICONS-LICENSE +43 -0
- package/LICENSE +21 -0
- package/README.md +16 -0
- package/package.json +18 -0
- package/src/components/CategoryCard.astro +31 -0
- package/src/components/CornerBadges.astro +40 -0
- package/src/components/Footer.astro +209 -0
- package/src/components/LightboxLink.astro +13 -0
- package/src/components/LinkListWidget.astro +19 -0
- package/src/components/Pagination.astro +72 -0
- package/src/components/PostCard.astro +166 -0
- package/src/components/PostNavigation.astro +61 -0
- package/src/components/Shell.astro +52 -0
- package/src/components/SiteHeader.astro +326 -0
- package/src/components/SmartImage.astro +34 -0
- package/src/components/Subscribe.astro +117 -0
- package/src/components/ThemeToggle.astro +41 -0
- package/src/components/TwoColumn.astro +12 -0
- package/src/components/Widget.astro +41 -0
- package/src/components/content/BlogImage.astro +39 -0
- package/src/components/content/Button.astro +57 -0
- package/src/components/content/Callout.astro +75 -0
- package/src/components/content/CodeBlock.astro +7 -0
- package/src/components/content/Gallery.astro +57 -0
- package/src/components/content/GalleryImage.astro +35 -0
- package/src/components/content/Group.astro +15 -0
- package/src/components/content/MediaText.astro +60 -0
- package/src/components/content/Palette.astro +42 -0
- package/src/components/content/Quote.astro +21 -0
- package/src/components/content/Spacer.astro +5 -0
- package/src/components/content/Step.astro +126 -0
- package/src/components/content/Walkthrough.astro +42 -0
- package/src/components/models.ts +77 -0
- package/src/lib/archive.ts +29 -0
- package/src/lib/drafts.ts +52 -0
- package/src/lib/format-date.ts +9 -0
- package/src/lib/header-date.ts +6 -0
- package/src/lib/ordering.ts +18 -0
- package/src/lib/paginate.ts +15 -0
- package/src/lib/reading-time.ts +4 -0
- package/src/lib/slug.ts +73 -0
- package/src/scripts/code-island.ts +75 -0
- package/src/scripts/core/breakpoints.ts +4 -0
- package/src/scripts/core/dom.ts +31 -0
- package/src/scripts/core/frame-loop.ts +54 -0
- package/src/scripts/core/icons.ts +30 -0
- package/src/scripts/core/island.ts +25 -0
- package/src/scripts/core/storage.ts +44 -0
- package/src/scripts/focus-mode.ts +41 -0
- package/src/scripts/lightbox.ts +446 -0
- package/src/scripts/link-tip.ts +154 -0
- package/src/scripts/path-player-math.ts +34 -0
- package/src/scripts/path-player-paint.ts +154 -0
- package/src/scripts/path-player.ts +341 -0
- package/src/scripts/plate-modal.ts +91 -0
- package/src/scripts/scroll-top.ts +32 -0
- package/src/scripts/site-header.ts +73 -0
- package/src/scripts/subscribe.ts +116 -0
- package/src/scripts/theme-toggle.ts +115 -0
- 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
|
+
};
|