@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.
- package/README.md +50 -2
- package/package.json +11 -4
- package/src/components/CategoryCard.astro +9 -1
- package/src/components/CornerBadges.astro +14 -21
- package/src/components/Footer.astro +57 -29
- package/src/components/Icon.astro +16 -0
- package/src/components/LightboxLink.astro +19 -10
- package/src/components/LinkListWidget.astro +11 -9
- package/src/components/Pagination.astro +51 -28
- package/src/components/PostCard.astro +54 -22
- package/src/components/PostNavigation.astro +40 -29
- package/src/components/Shell.astro +26 -12
- package/src/components/SiteHeader.astro +139 -80
- package/src/components/SmartImage.astro +48 -17
- package/src/components/Subscribe.astro +60 -35
- package/src/components/ThemeToggle.astro +28 -15
- package/src/components/TwoColumn.astro +5 -5
- package/src/components/Widget.astro +10 -7
- package/src/components/content/BlogImage.astro +48 -13
- package/src/components/content/Button.astro +13 -13
- package/src/components/content/Callout.astro +17 -8
- package/src/components/content/CodeBlock.astro +6 -1
- package/src/components/content/EditorNote.astro +19 -21
- package/src/components/content/Gallery.astro +18 -5
- package/src/components/content/GalleryImage.astro +26 -7
- package/src/components/content/Group.astro +5 -6
- package/src/components/content/MediaText.astro +43 -9
- package/src/components/content/Palette.astro +37 -21
- package/src/components/content/PostLink.astro +15 -0
- package/src/components/content/Quote.astro +10 -9
- package/src/components/content/Spacer.astro +11 -2
- package/src/components/content/Step.astro +34 -15
- package/src/components/content/Walkthrough.astro +10 -6
- package/src/components/content/WhenPublished.astro +14 -0
- package/src/components/models.ts +2 -2
- package/src/lib/archive.ts +18 -4
- package/src/lib/drafts.ts +16 -5
- package/src/lib/format-date.ts +4 -1
- package/src/lib/ordering.ts +3 -1
- package/src/lib/paginate.ts +5 -1
- package/src/lib/slug.ts +30 -7
- package/src/scripts/code-island.ts +27 -4
- package/src/scripts/core/badges.ts +28 -0
- package/src/scripts/core/dom.ts +6 -1
- package/src/scripts/core/frame-loop.ts +4 -1
- package/src/scripts/core/icons.ts +92 -15
- package/src/scripts/ecosystem.ts +72 -12
- package/src/scripts/focus-mode.ts +14 -3
- package/src/scripts/lightbox.ts +227 -41
- package/src/scripts/link-tip.ts +55 -11
- package/src/scripts/path-player-math.ts +11 -2
- package/src/scripts/path-player-paint.ts +54 -8
- package/src/scripts/path-player.ts +131 -29
- package/src/scripts/plate-modal.ts +45 -9
- package/src/scripts/scroll-top.ts +6 -1
- package/src/scripts/site-header.ts +34 -6
- package/src/scripts/subscribe.ts +50 -9
- package/src/scripts/theme-toggle.ts +32 -5
- package/src/shiki/code-theme.mjs +25 -5
- 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 {
|
|
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(
|
|
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(
|
|
59
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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;
|
|
56
|
-
|
|
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 {
|
|
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,
|
|
78
|
-
|
|
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
|
-
|
|
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>(
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 = (
|
|
160
|
-
|
|
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
|
-
|
|
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) => {
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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,
|
|
203
|
-
|
|
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")
|
|
208
|
-
|
|
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);
|
|
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);
|
|
381
|
+
if (playing) applyCurrent(true);
|
|
382
|
+
else config.sink.stop();
|
|
295
383
|
}
|
|
296
384
|
|
|
297
|
-
playBtn.addEventListener("click", () => {
|
|
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)
|
|
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 {
|
|
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: () => {
|
|
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: (
|
|
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 = {
|
|
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
|
|
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
|
-
|
|
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 = (
|
|
93
|
+
const addLabel = (
|
|
94
|
+
pos: "topLeft" | "bottomLeft" | "bottomRight",
|
|
95
|
+
className?: string,
|
|
96
|
+
): HTMLSpanElement => {
|
|
78
97
|
const span = doc.createElement("span");
|
|
79
|
-
|
|
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,
|
|
87
|
-
|
|
88
|
-
|
|
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> = (
|
|
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
|
|