@half-built/astro 0.10.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +100 -4
- package/package.json +1 -1
- package/src/components/Footer.astro +9 -0
- package/src/components/LightboxLink.astro +13 -2
- package/src/components/Popout.astro +33 -0
- package/src/components/PostCard.astro +49 -21
- package/src/components/Shell.astro +1 -1
- package/src/components/ThemeToggle.astro +0 -1
- package/src/components/TwoColumn.astro +4 -2
- package/src/components/content/BlogImage.astro +1 -2
- package/src/components/models.ts +11 -10
- package/src/lib/excerpt.ts +2 -2
- package/src/lib/format-date.ts +3 -3
- package/src/lib/header-date.ts +1 -1
- package/src/lib/ordering.ts +5 -4
- package/src/scripts/code-island.ts +49 -18
- package/src/scripts/core/frame-loop.ts +26 -11
- package/src/scripts/core/icons.ts +1 -2
- package/src/scripts/core/placement.ts +67 -0
- package/src/scripts/core/storage.ts +2 -1
- package/src/scripts/ecosystem.ts +18 -10
- package/src/scripts/focus-mode.ts +34 -2
- package/src/scripts/lightbox.ts +69 -17
- package/src/scripts/link-tip.ts +5 -66
- package/src/scripts/path-player.ts +24 -10
- package/src/scripts/plate-modal.ts +17 -6
- package/src/scripts/popout.ts +462 -0
- package/src/scripts/scroll-top.ts +2 -2
- package/src/scripts/site-header.ts +8 -6
- package/src/scripts/subscribe.ts +1 -2
- package/src/scripts/theme-toggle.ts +22 -10
- package/src/shiki/code-theme.mjs +2 -1
- package/src/shiki/code-vars.mjs +24 -2
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
/* Popout (spec docs/superpowers/specs/2026-09-26-popout-design.md):
|
|
2
|
+
secondary context for tables and data viz. One island, one real
|
|
3
|
+
<dialog> per page. A click on a trigger fills it from the template
|
|
4
|
+
right after that trigger and opens it anchored below the trigger
|
|
5
|
+
(desktop) or as a modal bottom sheet (phone width, decided at each
|
|
6
|
+
open). Chrome lives in @half-built/css popout.css.
|
|
7
|
+
|
|
8
|
+
A document-wide singleton, like link-tip: root only names the
|
|
9
|
+
document; triggers anywhere in it open the one surface. */
|
|
10
|
+
|
|
11
|
+
import { claim, release, type Island, type IslandHandle } from "./core/island";
|
|
12
|
+
import { docOf, el, iconButton } from "./core/dom";
|
|
13
|
+
import { ICON_X } from "./core/icons";
|
|
14
|
+
import { BP_PHONE_MAX } from "./core/breakpoints";
|
|
15
|
+
import {
|
|
16
|
+
placeTip,
|
|
17
|
+
EDGE,
|
|
18
|
+
type AnchorRect,
|
|
19
|
+
type ViewportSize,
|
|
20
|
+
} from "./core/placement";
|
|
21
|
+
|
|
22
|
+
export type PopoutMode = "anchored" | "sheet";
|
|
23
|
+
|
|
24
|
+
/* The tip edge plus the close box's 17px straddle, so the box never
|
|
25
|
+
lands with its close control off screen. */
|
|
26
|
+
const POPOUT_EDGE = EDGE + 18;
|
|
27
|
+
|
|
28
|
+
/* The label straddles the box's top rule by about 12px; this keeps it
|
|
29
|
+
off the trigger it belongs to. */
|
|
30
|
+
const LABEL_CLEAR = 16;
|
|
31
|
+
|
|
32
|
+
export const SWIPE_CLOSE = 60;
|
|
33
|
+
|
|
34
|
+
export interface PopoutOptions {
|
|
35
|
+
selector?: string;
|
|
36
|
+
edge?: number;
|
|
37
|
+
closeLabel?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface MediaHost {
|
|
41
|
+
matchMedia?: (q: string) => { matches: boolean };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* Widened like link-tip's guard: jsdom has no matchMedia, and the plain
|
|
45
|
+
Window type always declares it. */
|
|
46
|
+
export function popoutMode(win: MediaHost | null): PopoutMode {
|
|
47
|
+
return win?.matchMedia?.(`(max-width: ${BP_PHONE_MAX}px)`).matches
|
|
48
|
+
? "sheet"
|
|
49
|
+
: "anchored";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function offscreen(r: AnchorRect, vp: ViewportSize): boolean {
|
|
53
|
+
return (
|
|
54
|
+
r.bottom < 0 || r.top >= vp.height || r.right < 0 || r.left >= vp.width
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function isSwipeClose(
|
|
59
|
+
startY: number | null,
|
|
60
|
+
endY: number,
|
|
61
|
+
threshold: number = SWIPE_CLOSE,
|
|
62
|
+
): boolean {
|
|
63
|
+
return startY !== null && endY - startY >= threshold;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const FOCUSABLE =
|
|
67
|
+
'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
68
|
+
|
|
69
|
+
/** The first element after anchor, in document order, that sequential
|
|
70
|
+
focus navigation would reach, ignoring anything inside skip. */
|
|
71
|
+
export function nextFocusableAfter(
|
|
72
|
+
doc: Document,
|
|
73
|
+
anchor: Element,
|
|
74
|
+
skip: Element,
|
|
75
|
+
): HTMLElement | null {
|
|
76
|
+
for (const node of doc.querySelectorAll<HTMLElement>(FOCUSABLE)) {
|
|
77
|
+
if (skip.contains(node) || anchor.contains(node)) continue;
|
|
78
|
+
if (node.getAttribute("tabindex") === "-1") continue;
|
|
79
|
+
if (node.closest("[hidden], [inert]")) continue;
|
|
80
|
+
|
|
81
|
+
const after =
|
|
82
|
+
anchor.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING;
|
|
83
|
+
|
|
84
|
+
if (after) return node;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const mountPopouts: Island<PopoutOptions> = (
|
|
91
|
+
root,
|
|
92
|
+
options = {},
|
|
93
|
+
): IslandHandle => {
|
|
94
|
+
const {
|
|
95
|
+
selector = ".popout-trigger",
|
|
96
|
+
edge = POPOUT_EDGE,
|
|
97
|
+
closeLabel = "Close",
|
|
98
|
+
} = options;
|
|
99
|
+
|
|
100
|
+
const doc = docOf(root);
|
|
101
|
+
const html = doc.documentElement;
|
|
102
|
+
|
|
103
|
+
if (!claim(html, "popout")) {
|
|
104
|
+
return {
|
|
105
|
+
destroy(): void {
|
|
106
|
+
/* already claimed elsewhere: nothing here to tear down */
|
|
107
|
+
return;
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const win = doc.defaultView;
|
|
113
|
+
const media = win as MediaHost | null;
|
|
114
|
+
|
|
115
|
+
const surface = el(doc, "dialog", "popout");
|
|
116
|
+
surface.setAttribute("aria-labelledby", "popout-label");
|
|
117
|
+
surface.setAttribute("aria-describedby", "popout-body");
|
|
118
|
+
surface.tabIndex = -1;
|
|
119
|
+
const label = el(doc, "span", "popout-label boxed-label micro-label");
|
|
120
|
+
label.id = "popout-label";
|
|
121
|
+
const closeBtn = iconButton(doc, "popout-close icon-box", closeLabel, ICON_X);
|
|
122
|
+
const body = el(doc, "div", "popout-body");
|
|
123
|
+
body.id = "popout-body";
|
|
124
|
+
surface.append(label, closeBtn, body);
|
|
125
|
+
doc.body.append(surface);
|
|
126
|
+
|
|
127
|
+
let current: HTMLElement | null = null;
|
|
128
|
+
let mode: PopoutMode = "anchored";
|
|
129
|
+
let frame = 0;
|
|
130
|
+
let swipeStart: number | null = null;
|
|
131
|
+
/* A press is under way between pointerdown and its click (or the
|
|
132
|
+
next key). Focus moves during it, sometimes to nothing (Safari does
|
|
133
|
+
not focus a pressed button); the pointer handlers own that close. */
|
|
134
|
+
let pressing = false;
|
|
135
|
+
/* Sheet only: whether the pointerdown that started the current press
|
|
136
|
+
landed on the dialog element itself (the backdrop), so a press that
|
|
137
|
+
starts inside and is dragged onto the backdrop before release does
|
|
138
|
+
not read as a backdrop click. */
|
|
139
|
+
let pressOnSurface = false;
|
|
140
|
+
|
|
141
|
+
const viewport = (): ViewportSize => ({
|
|
142
|
+
width: html.clientWidth,
|
|
143
|
+
height: html.clientHeight,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const triggerFor = (t: EventTarget | null): HTMLElement | null => {
|
|
147
|
+
if (!(t instanceof Element)) return null;
|
|
148
|
+
const hit = t.closest(selector);
|
|
149
|
+
return hit instanceof HTMLElement ? hit : null;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/* Every way out lands here, synchronously. dialog.close() queues its
|
|
153
|
+
close event in real browsers, so the listener below only acts when
|
|
154
|
+
the dialog is actually shut: a late event after a reopen is
|
|
155
|
+
ignored.
|
|
156
|
+
|
|
157
|
+
A close the reader asked for (the close box, Escape, the open
|
|
158
|
+
trigger, the backdrop, a swipe) hands focus back to the trigger.
|
|
159
|
+
An automatic one (scroll-away, a breakpoint crossing, focus
|
|
160
|
+
leaving, a press outside) does not: the trigger may be off screen,
|
|
161
|
+
and a focused one reopens on Space against an anchor nobody can
|
|
162
|
+
see. Focus inside the box is dropped before close(), which would
|
|
163
|
+
otherwise restore it to the trigger itself (the dialog's own
|
|
164
|
+
previously-focused-element step; always so for the modal sheet,
|
|
165
|
+
hence the second check after). */
|
|
166
|
+
const finish = (restoreFocus = true): void => {
|
|
167
|
+
const opener = current;
|
|
168
|
+
if (!opener) return;
|
|
169
|
+
current = null;
|
|
170
|
+
|
|
171
|
+
if (frame) {
|
|
172
|
+
win?.cancelAnimationFrame(frame);
|
|
173
|
+
frame = 0;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const active = doc.activeElement;
|
|
177
|
+
|
|
178
|
+
if (
|
|
179
|
+
!restoreFocus &&
|
|
180
|
+
active instanceof HTMLElement &&
|
|
181
|
+
surface.contains(active)
|
|
182
|
+
) {
|
|
183
|
+
active.blur();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (surface.open) surface.close();
|
|
187
|
+
surface.classList.remove("is-anchored", "is-sheet");
|
|
188
|
+
surface.style.left = "";
|
|
189
|
+
surface.style.top = "";
|
|
190
|
+
body.replaceChildren();
|
|
191
|
+
html.classList.remove("popout-open");
|
|
192
|
+
opener.setAttribute("aria-expanded", "false");
|
|
193
|
+
opener.classList.remove("is-open");
|
|
194
|
+
|
|
195
|
+
if (restoreFocus) opener.focus({ preventScroll: true });
|
|
196
|
+
else if (doc.activeElement === opener && active !== opener) opener.blur();
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const place = (): void => {
|
|
200
|
+
if (!current) return;
|
|
201
|
+
const r = current.getBoundingClientRect();
|
|
202
|
+
|
|
203
|
+
const anchor: AnchorRect = {
|
|
204
|
+
left: r.left,
|
|
205
|
+
right: r.right,
|
|
206
|
+
top: r.top - LABEL_CLEAR,
|
|
207
|
+
bottom: r.bottom + LABEL_CLEAR,
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const pos = placeTip(
|
|
211
|
+
anchor,
|
|
212
|
+
surface.getBoundingClientRect(),
|
|
213
|
+
viewport(),
|
|
214
|
+
"below",
|
|
215
|
+
"start",
|
|
216
|
+
edge,
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
surface.style.left = `${pos.x}px`;
|
|
220
|
+
surface.style.top = `${pos.y}px`;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const open = (trigger: HTMLElement): void => {
|
|
224
|
+
const tpl = trigger.nextElementSibling;
|
|
225
|
+
if (!(tpl instanceof HTMLTemplateElement)) return;
|
|
226
|
+
finish(false);
|
|
227
|
+
current = trigger;
|
|
228
|
+
mode = popoutMode(media);
|
|
229
|
+
label.textContent = trigger.dataset.popoutLabel ?? "";
|
|
230
|
+
body.replaceChildren(tpl.content.cloneNode(true));
|
|
231
|
+
trigger.setAttribute("aria-expanded", "true");
|
|
232
|
+
trigger.classList.add("is-open");
|
|
233
|
+
|
|
234
|
+
if (mode === "sheet") {
|
|
235
|
+
surface.classList.add("is-sheet");
|
|
236
|
+
surface.showModal();
|
|
237
|
+
html.classList.add("popout-open");
|
|
238
|
+
} else {
|
|
239
|
+
surface.classList.add("is-anchored");
|
|
240
|
+
surface.show();
|
|
241
|
+
place();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
surface.focus({ preventScroll: true });
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const onClick = (ev: MouseEvent): void => {
|
|
248
|
+
pressing = false;
|
|
249
|
+
const trigger = triggerFor(ev.target);
|
|
250
|
+
|
|
251
|
+
if (trigger) {
|
|
252
|
+
if (trigger === current) finish();
|
|
253
|
+
else open(trigger);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/* The dialog has no padding of its own (popout.css), so a click on
|
|
258
|
+
the element itself is a click on the sheet's backdrop, but only
|
|
259
|
+
when the press that produced this click also started there: a
|
|
260
|
+
press dragged from inside the sheet onto the backdrop must not
|
|
261
|
+
close it. */
|
|
262
|
+
if (ev.target === surface && mode === "sheet" && pressOnSurface) finish();
|
|
263
|
+
pressOnSurface = false;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
/* Anchored only; the sheet's outside is its backdrop. A press on any
|
|
267
|
+
trigger is left to the click handler, or pressing the open trigger
|
|
268
|
+
would close here and reopen on click. */
|
|
269
|
+
const onPointerDown = (ev: Event): void => {
|
|
270
|
+
pressOnSurface = ev.target === surface;
|
|
271
|
+
pressing = true;
|
|
272
|
+
if (!current || mode !== "anchored") return;
|
|
273
|
+
const t = ev.target;
|
|
274
|
+
if (t instanceof Node && surface.contains(t)) return;
|
|
275
|
+
if (triggerFor(t)) return;
|
|
276
|
+
finish(false);
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
/* A touch pan ends in pointercancel, never a click; the press is over,
|
|
280
|
+
so the flag must not outlive it, or a later null-relatedTarget
|
|
281
|
+
focusout is ignored forever and the box sticks open. pointerup is
|
|
282
|
+
deliberately not one of these: a real tap can put pointerup, a focus
|
|
283
|
+
change, and the click in separate tasks, and clearing the flag at
|
|
284
|
+
pointerup lets the focus change's queued close beat the click, which
|
|
285
|
+
then reopens what the tap meant to close. A press dragged off its
|
|
286
|
+
target and released elsewhere is left holding the flag until the
|
|
287
|
+
next click, keydown, or pointerdown; that fails safe, leaving the
|
|
288
|
+
box open rather than closing and reopening it. */
|
|
289
|
+
const onPointerCancel = (): void => {
|
|
290
|
+
pressing = false;
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const inside = (t: EventTarget | null): boolean =>
|
|
294
|
+
t instanceof Node &&
|
|
295
|
+
(surface.contains(t) || (current?.contains(t) ?? false));
|
|
296
|
+
|
|
297
|
+
/* Anchored only: the sheet is modal and keeps focus itself. Keyboard
|
|
298
|
+
focus leaving the box and its trigger closes it (the site-header
|
|
299
|
+
flyout's manner). With no destination, focus went to browser chrome
|
|
300
|
+
or another window, or a press is moving it; the check waits for the
|
|
301
|
+
move to settle, leaves a press to the pointer handlers, and keeps
|
|
302
|
+
the box open when focus is still inside it (a window switch leaves
|
|
303
|
+
the document's focus where it was). */
|
|
304
|
+
const onFocusOut = (ev: FocusEvent): void => {
|
|
305
|
+
if (!current || mode !== "anchored") return;
|
|
306
|
+
|
|
307
|
+
if (ev.relatedTarget !== null) {
|
|
308
|
+
if (!inside(ev.relatedTarget)) finish(false);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (pressing) return;
|
|
313
|
+
|
|
314
|
+
queueMicrotask(() => {
|
|
315
|
+
if (current && mode === "anchored" && !inside(doc.activeElement)) {
|
|
316
|
+
finish(false);
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const onKey = (ev: KeyboardEvent): void => {
|
|
322
|
+
pressing = false;
|
|
323
|
+
if (ev.key !== "Escape" || !current) return;
|
|
324
|
+
ev.preventDefault();
|
|
325
|
+
finish();
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
/* Anchored only: the box sits last in <body>, so native Tab order
|
|
329
|
+
would carry focus to the footer or out of the page. Shift+Tab off
|
|
330
|
+
the first stop goes back to the trigger; Tab off the last stop
|
|
331
|
+
goes to whatever follows the trigger. */
|
|
332
|
+
const onSurfaceKey = (ev: KeyboardEvent): void => {
|
|
333
|
+
if (ev.key !== "Tab" || !current || mode !== "anchored") return;
|
|
334
|
+
const stops = [...surface.querySelectorAll<HTMLElement>(FOCUSABLE)];
|
|
335
|
+
const first = stops.at(0);
|
|
336
|
+
const last = stops.at(-1);
|
|
337
|
+
const active = doc.activeElement;
|
|
338
|
+
|
|
339
|
+
if (ev.shiftKey && (active === surface || active === first)) {
|
|
340
|
+
ev.preventDefault();
|
|
341
|
+
finish();
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (!ev.shiftKey && (active === last || stops.length === 0)) {
|
|
346
|
+
ev.preventDefault();
|
|
347
|
+
const trigger = current;
|
|
348
|
+
finish(false);
|
|
349
|
+
|
|
350
|
+
/* A candidate that matches FOCUSABLE can still silently refuse
|
|
351
|
+
focus: display:none or visibility:hidden by CSS, inside a
|
|
352
|
+
closed <details>, or inside a disabled <fieldset> all pass the
|
|
353
|
+
selector but leave .focus() a no-op. Walk forward from each
|
|
354
|
+
failed candidate (nextFocusableAfter takes it as the new
|
|
355
|
+
anchor) until one actually takes focus, and fall back to the
|
|
356
|
+
trigger when none does. */
|
|
357
|
+
let anchor: HTMLElement = trigger;
|
|
358
|
+
let candidate = nextFocusableAfter(doc, anchor, surface);
|
|
359
|
+
|
|
360
|
+
while (candidate) {
|
|
361
|
+
candidate.focus();
|
|
362
|
+
if (doc.activeElement === candidate) return;
|
|
363
|
+
anchor = candidate;
|
|
364
|
+
candidate = nextFocusableAfter(doc, anchor, surface);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
trigger.focus();
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
/* Anchored only: focus leaving the trigger itself for somewhere
|
|
372
|
+
outside both the trigger and the box closes it. Once open, focus
|
|
373
|
+
starts on the surface itself, so under normal tabbing this trigger
|
|
374
|
+
never holds focus while the box is open. This handler is a guard
|
|
375
|
+
for the case where focus reaches the trigger anyway while the box
|
|
376
|
+
is still open (a script, or assistive tech moving focus directly)
|
|
377
|
+
and then leaves for the rest of the page. */
|
|
378
|
+
const onTriggerFocusOut = (ev: FocusEvent): void => {
|
|
379
|
+
if (!current || mode !== "anchored" || ev.target !== current) return;
|
|
380
|
+
if (ev.relatedTarget === null || inside(ev.relatedTarget)) return;
|
|
381
|
+
finish(false);
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const settle = (): void => {
|
|
385
|
+
frame = 0;
|
|
386
|
+
if (!current) return;
|
|
387
|
+
|
|
388
|
+
if (offscreen(current.getBoundingClientRect(), viewport())) {
|
|
389
|
+
finish(false);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
place();
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
const onScroll = (): void => {
|
|
397
|
+
if (!current || mode !== "anchored" || frame) return;
|
|
398
|
+
frame = win?.requestAnimationFrame(settle) ?? 0;
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const onResize = (): void => {
|
|
402
|
+
if (!current) return;
|
|
403
|
+
|
|
404
|
+
if (popoutMode(media) !== mode) {
|
|
405
|
+
finish(false);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (mode === "anchored") place();
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
const onClose = (): void => {
|
|
413
|
+
if (!surface.open) finish();
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const onTouchStart = (ev: TouchEvent): void => {
|
|
417
|
+
swipeStart =
|
|
418
|
+
mode === "sheet" && body.scrollTop === 0
|
|
419
|
+
? (ev.touches.item(0)?.clientY ?? null)
|
|
420
|
+
: null;
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
const onTouchEnd = (ev: TouchEvent): void => {
|
|
424
|
+
const y = ev.changedTouches.item(0)?.clientY;
|
|
425
|
+
if (y !== undefined && isSwipeClose(swipeStart, y)) finish();
|
|
426
|
+
swipeStart = null;
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const onCloseBtn = (): void => {
|
|
430
|
+
finish();
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
doc.addEventListener("click", onClick);
|
|
434
|
+
doc.addEventListener("pointerdown", onPointerDown);
|
|
435
|
+
doc.addEventListener("pointercancel", onPointerCancel);
|
|
436
|
+
doc.addEventListener("keydown", onKey);
|
|
437
|
+
doc.addEventListener("focusout", onTriggerFocusOut);
|
|
438
|
+
/* capture: scroll does not bubble from inner scrollers (.table-scroll) */
|
|
439
|
+
doc.addEventListener("scroll", onScroll, true);
|
|
440
|
+
win?.addEventListener("resize", onResize);
|
|
441
|
+
surface.addEventListener("close", onClose);
|
|
442
|
+
surface.addEventListener("focusout", onFocusOut);
|
|
443
|
+
surface.addEventListener("keydown", onSurfaceKey);
|
|
444
|
+
surface.addEventListener("touchstart", onTouchStart, { passive: true });
|
|
445
|
+
surface.addEventListener("touchend", onTouchEnd, { passive: true });
|
|
446
|
+
closeBtn.addEventListener("click", onCloseBtn);
|
|
447
|
+
|
|
448
|
+
return {
|
|
449
|
+
destroy(): void {
|
|
450
|
+
finish(false);
|
|
451
|
+
doc.removeEventListener("click", onClick);
|
|
452
|
+
doc.removeEventListener("pointerdown", onPointerDown);
|
|
453
|
+
doc.removeEventListener("pointercancel", onPointerCancel);
|
|
454
|
+
doc.removeEventListener("keydown", onKey);
|
|
455
|
+
doc.removeEventListener("focusout", onTriggerFocusOut);
|
|
456
|
+
doc.removeEventListener("scroll", onScroll, true);
|
|
457
|
+
win?.removeEventListener("resize", onResize);
|
|
458
|
+
surface.remove();
|
|
459
|
+
release(html, "popout");
|
|
460
|
+
},
|
|
461
|
+
};
|
|
462
|
+
};
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { claim, release, type Island, type IslandHandle } from "./core/island";
|
|
2
2
|
import { docOf } from "./core/dom";
|
|
3
3
|
|
|
4
|
-
/* Scroll-to-top floater island (step 9), born from
|
|
5
|
-
script: shows once the header leaves the viewport. */
|
|
4
|
+
/* Scroll-to-top floater island (step 9), born from the consumer's
|
|
5
|
+
layout's inline script: shows once the header leaves the viewport. */
|
|
6
6
|
|
|
7
7
|
export interface ScrollTopOptions {
|
|
8
8
|
buttonId?: string;
|
|
@@ -24,10 +24,11 @@ export interface SiteHeaderOptions {
|
|
|
24
24
|
searchButton?: string;
|
|
25
25
|
searchWrap?: string;
|
|
26
26
|
searchField?: string;
|
|
27
|
-
/* Defaults to formatHeaderDate, the same function
|
|
28
|
-
the server render. Its package-time home (staying
|
|
29
|
-
moving into the library) is
|
|
30
|
-
gives a second site a seam to override it without
|
|
27
|
+
/* Defaults to formatHeaderDate, the same function the consumer's
|
|
28
|
+
layout uses for the server render. Its package-time home (staying
|
|
29
|
+
in src/lib vs. moving into the library) is a settled decision;
|
|
30
|
+
this option just gives a second site a seam to override it without
|
|
31
|
+
forking. */
|
|
31
32
|
formatDate?: (d: Date) => string;
|
|
32
33
|
}
|
|
33
34
|
|
|
@@ -112,8 +113,9 @@ export const mountSiteHeader: Island<SiteHeaderOptions> = (
|
|
|
112
113
|
|
|
113
114
|
const doc = docOf(root);
|
|
114
115
|
|
|
115
|
-
// Header date: live
|
|
116
|
-
// no-JS fallback), same format
|
|
116
|
+
// Header date: live, matching the consumer's original build-time
|
|
117
|
+
// behavior (build-time text is the no-JS fallback), same format
|
|
118
|
+
// function as the server render.
|
|
117
119
|
const dateBox = doc.getElementById(dateId);
|
|
118
120
|
if (dateBox) dateBox.textContent = formatDate(new Date());
|
|
119
121
|
|
package/src/scripts/subscribe.ts
CHANGED
|
@@ -15,8 +15,7 @@
|
|
|
15
15
|
response (Buttondown's captcha interstitial for a suspicious client,
|
|
16
16
|
or an error page) falls back to a native form submit so the reader can
|
|
17
17
|
finish it on Buttondown's own page. Only a thrown fetch (the network
|
|
18
|
-
itself failing) writes the couldn't-reach line.
|
|
19
|
-
docs/superpowers/specs/2026-08-16-mailing-list-wiring-design.md
|
|
18
|
+
itself failing) writes the couldn't-reach line.
|
|
20
19
|
|
|
21
20
|
On the island contract (step 9): mount(root, options?) returns a
|
|
22
21
|
destroy handle, and claim() makes a second mount over an already-wired
|
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
theme; this island only wires the button. Shape follows the library's
|
|
7
7
|
mount(root, options?) contract (step 9): idempotent, and it returns a
|
|
8
8
|
destroy handle. The storage key is a mount option, not a package
|
|
9
|
-
literal (step 11.2);
|
|
9
|
+
literal (step 11.2); each consumer passes its own storage key. */
|
|
10
10
|
|
|
11
|
-
import { type Island, type IslandHandle } from "./core/island";
|
|
11
|
+
import { claim, release, type Island, type IslandHandle } from "./core/island";
|
|
12
12
|
import { docOf } from "./core/dom";
|
|
13
13
|
|
|
14
14
|
export type Theme = "light" | "dark";
|
|
@@ -65,19 +65,26 @@ export function apply(doc: Document, theme: Theme): void {
|
|
|
65
65
|
else delete doc.documentElement.dataset.theme;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/* An action label describes what the button does next ("Switch to dark
|
|
69
|
+
mode"); aria-pressed describes what the button currently is. Carrying
|
|
70
|
+
both means a screen reader in dark mode announces "Switch to light
|
|
71
|
+
mode, pressed", where "pressed" reads as dark mode and the label
|
|
72
|
+
describes turning it off (owner ruling: keep the action label, drop
|
|
73
|
+
aria-pressed). */
|
|
68
74
|
function reflect(
|
|
69
75
|
btn: HTMLButtonElement,
|
|
70
76
|
theme: Theme,
|
|
71
77
|
labels: Record<Theme, string>,
|
|
72
78
|
): void {
|
|
73
|
-
btn.setAttribute("aria-pressed", theme === "dark" ? "true" : "false");
|
|
74
79
|
btn.setAttribute("aria-label", labels[theme]);
|
|
75
80
|
btn.title = labels[theme];
|
|
76
81
|
}
|
|
77
82
|
|
|
78
|
-
/*
|
|
79
|
-
|
|
80
|
-
|
|
83
|
+
/* Each claimed button's own label pair, keyed by element rather than
|
|
84
|
+
read from one mount's closure: a click anywhere reflects every
|
|
85
|
+
claimed button in the document, and a button mounted with its own
|
|
86
|
+
labels keeps them regardless of which mount's listener fired. */
|
|
87
|
+
const labelsOf = new WeakMap<HTMLButtonElement, Record<Theme, string>>();
|
|
81
88
|
|
|
82
89
|
export interface ThemeToggleOptions {
|
|
83
90
|
selector?: string;
|
|
@@ -108,15 +115,19 @@ export const mountThemeToggle: Island<ThemeToggleOptions> = (
|
|
|
108
115
|
const handlers: [HTMLButtonElement, () => void][] = [];
|
|
109
116
|
|
|
110
117
|
for (const btn of root.querySelectorAll<HTMLButtonElement>(selector)) {
|
|
111
|
-
if (
|
|
112
|
-
|
|
118
|
+
if (!claim(btn, "theme-toggle")) continue;
|
|
119
|
+
labelsOf.set(btn, labels);
|
|
113
120
|
reflect(btn, current(doc), labels);
|
|
114
121
|
|
|
115
122
|
const onClick = () => {
|
|
116
123
|
const next: Theme = current(doc) === "dark" ? "light" : "dark";
|
|
117
124
|
apply(doc, next);
|
|
118
125
|
writeStored(storage, storageKey, next);
|
|
119
|
-
|
|
126
|
+
|
|
127
|
+
for (const b of doc.querySelectorAll<HTMLButtonElement>(selector)) {
|
|
128
|
+
const bLabels = labelsOf.get(b);
|
|
129
|
+
if (bLabels) reflect(b, next, bLabels);
|
|
130
|
+
}
|
|
120
131
|
};
|
|
121
132
|
|
|
122
133
|
btn.addEventListener("click", onClick);
|
|
@@ -127,7 +138,8 @@ export const mountThemeToggle: Island<ThemeToggleOptions> = (
|
|
|
127
138
|
destroy(): void {
|
|
128
139
|
for (const [btn, onClick] of handlers) {
|
|
129
140
|
btn.removeEventListener("click", onClick);
|
|
130
|
-
|
|
141
|
+
labelsOf.delete(btn);
|
|
142
|
+
release(btn, "theme-toggle");
|
|
131
143
|
}
|
|
132
144
|
},
|
|
133
145
|
};
|
package/src/shiki/code-theme.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Custom TextMate theme: amber on the parent theme's dark brown.
|
|
2
|
+
/** @type {import("shiki").ThemeRegistration} */
|
|
2
3
|
export default {
|
|
3
4
|
name: "terminal-amber",
|
|
4
5
|
type: "dark",
|
|
@@ -26,7 +27,7 @@ export default {
|
|
|
26
27
|
},
|
|
27
28
|
{
|
|
28
29
|
scope: ["comment", "punctuation.definition.comment"],
|
|
29
|
-
settings: { foreground: "#
|
|
30
|
+
settings: { foreground: "#8e7e67", fontStyle: "italic" },
|
|
30
31
|
},
|
|
31
32
|
{
|
|
32
33
|
scope: ["variable", "support.variable"],
|
package/src/shiki/code-vars.mjs
CHANGED
|
@@ -4,18 +4,37 @@
|
|
|
4
4
|
// literal colors on every span at build; with this transformer the
|
|
5
5
|
// build emits var() instead, and the variables resolve to the exact
|
|
6
6
|
// same hexes until something overrides them, so adopting it changes
|
|
7
|
-
// no rendered pixel.
|
|
7
|
+
// no rendered pixel. It also encodes ">" in highlighted text (see
|
|
8
|
+
// escapeGt), so Code output passes the tooling html-validate preset.
|
|
9
|
+
// Pass it wherever shiki options go: the transformers
|
|
8
10
|
// prop of astro:components' Code, or markdown.shikiConfig.transformers
|
|
9
11
|
// in a consumer's astro config.
|
|
10
12
|
const HEX_TO_VAR = {
|
|
11
13
|
"#ffaa3c": "var(--code-token-keyword)",
|
|
12
14
|
"#ffd18a": "var(--code-token-function)",
|
|
13
15
|
"#e07c14": "var(--code-token-string)",
|
|
14
|
-
"#
|
|
16
|
+
"#8e7e67": "var(--code-token-comment)",
|
|
15
17
|
"#e8d9c3": "var(--code-fg)",
|
|
16
18
|
"#1b140c": "var(--code-bg)",
|
|
17
19
|
};
|
|
18
20
|
|
|
21
|
+
// Tags (quoted attribute values kept whole) or runs of text. Shiki's
|
|
22
|
+
// serializer writes "<" in text as "<", so any "<" left in the
|
|
23
|
+
// html opens a tag.
|
|
24
|
+
const TOKEN = /<(?:[^>"']|"[^"]*"|'[^']*')*>|[^<]+/g;
|
|
25
|
+
|
|
26
|
+
// Encode ">" in text. codeToHtml (the path astro:components' Code
|
|
27
|
+
// takes) serializes with hast-util-to-html, which escapes only "<" and
|
|
28
|
+
// "&" in text, so a highlighted ">" reaches the page raw and fails
|
|
29
|
+
// html-validate's no-raw-characters in the tooling preset. Markdown
|
|
30
|
+
// code never hits this: it goes through codeToHast, and Astro escapes
|
|
31
|
+
// the text itself.
|
|
32
|
+
function escapeGt(html) {
|
|
33
|
+
return html.replace(TOKEN, (token) =>
|
|
34
|
+
token.startsWith("<") ? token : token.replaceAll(">", ">"),
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
19
38
|
function swap(node) {
|
|
20
39
|
const style = node.properties?.style;
|
|
21
40
|
if (typeof style !== "string") return;
|
|
@@ -34,4 +53,7 @@ export default {
|
|
|
34
53
|
span(node) {
|
|
35
54
|
swap(node);
|
|
36
55
|
},
|
|
56
|
+
postprocess(html) {
|
|
57
|
+
return escapeGt(html);
|
|
58
|
+
},
|
|
37
59
|
};
|