@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
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
/* The rAF loop with dt clamp (step 10), one home for
|
|
2
|
-
|
|
1
|
+
/* The rAF loop with dt clamp (step 10), one home for a pattern that
|
|
2
|
+
used to be scattered across the site's own scripts and path-player.ts.
|
|
3
|
+
now is the rAF
|
|
3
4
|
timestamp: monotonic in a real browser, unlike Date.now(), which a
|
|
4
5
|
wall-clock adjustment can move backward; the max(0, ...) floor is
|
|
5
6
|
cheap belt-and-suspenders against that case feeding an integrator a
|
|
6
7
|
negative dt. stop() resets the clock so a stop/start gap (a hidden
|
|
7
|
-
tab, a closed dialog) never arrives as one giant dt.
|
|
8
|
+
tab, a closed dialog) never arrives as one giant dt. A stop, a
|
|
9
|
+
start, or both called reentrantly from inside cb keep exactly one
|
|
10
|
+
scheduled chain: each generation is stamped at schedule time, and a
|
|
11
|
+
tick from a stale generation is dropped instead of rescheduling
|
|
12
|
+
itself alongside the newer chain. */
|
|
8
13
|
export interface FrameLoop {
|
|
9
14
|
start(): void;
|
|
10
15
|
stop(): void;
|
|
@@ -20,21 +25,25 @@ export function createFrameLoop(
|
|
|
20
25
|
let handle = 0;
|
|
21
26
|
let last = 0;
|
|
22
27
|
let live = false;
|
|
28
|
+
let gen = 0;
|
|
23
29
|
|
|
24
|
-
function tick(now: number): void {
|
|
25
|
-
if (!live) return;
|
|
30
|
+
function tick(now: number, mine: number): void {
|
|
31
|
+
if (!live || mine !== gen) return;
|
|
26
32
|
|
|
27
33
|
const elapsed = Math.max(0, (now - last) / 1000);
|
|
28
34
|
const dt = last === 0 ? firstDt : Math.min(clamp, elapsed);
|
|
29
35
|
|
|
30
36
|
last = now;
|
|
31
37
|
cb(dt);
|
|
32
|
-
// cb may have called stop() reentrantly; a fresh function body reads live without stale narrowing.
|
|
33
|
-
scheduleNext();
|
|
34
|
-
}
|
|
35
38
|
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
// cb may have called stop() and/or start() reentrantly; gen moves
|
|
40
|
+
// on either call, so mine still matching gen here already means
|
|
41
|
+
// this chain is still the live one.
|
|
42
|
+
if (mine === gen) {
|
|
43
|
+
handle = win.requestAnimationFrame((now2) => {
|
|
44
|
+
tick(now2, mine);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
38
47
|
}
|
|
39
48
|
|
|
40
49
|
return {
|
|
@@ -42,11 +51,17 @@ export function createFrameLoop(
|
|
|
42
51
|
if (live) return;
|
|
43
52
|
live = true;
|
|
44
53
|
last = 0;
|
|
45
|
-
|
|
54
|
+
gen++;
|
|
55
|
+
const mine = gen;
|
|
56
|
+
|
|
57
|
+
handle = win.requestAnimationFrame((now) => {
|
|
58
|
+
tick(now, mine);
|
|
59
|
+
});
|
|
46
60
|
},
|
|
47
61
|
stop(): void {
|
|
48
62
|
if (!live) return;
|
|
49
63
|
live = false;
|
|
64
|
+
gen++;
|
|
50
65
|
win.cancelAnimationFrame(handle);
|
|
51
66
|
last = 0;
|
|
52
67
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* Inline icon markup, vendored from Lucide (https://lucide.dev, ISC
|
|
2
2
|
license), replacing the Unicode glyphs whose rendering varied by
|
|
3
3
|
platform font. Same line style as the site's existing inline SVGs
|
|
4
|
-
(the to-top chevron in
|
|
4
|
+
(the to-top chevron in the consumer's layout). Buttons carry their own
|
|
5
5
|
aria-labels; the svg itself is decorative and aria-hidden.
|
|
6
6
|
Home: the js package's core (owner decision 1, 2026-08-31); Lucide,
|
|
7
7
|
ISC license, attribution retained. */
|
|
@@ -98,7 +98,6 @@ export function iconMarkup(
|
|
|
98
98
|
|
|
99
99
|
export const ICON_X = iconMarkup("x");
|
|
100
100
|
export const ICON_PLAY = iconMarkup("play");
|
|
101
|
-
export const ICON_SPARKLES = iconMarkup("sparkles");
|
|
102
101
|
export const ICON_PAUSE = iconMarkup("pause");
|
|
103
102
|
export const ICON_ROTATE_CCW = iconMarkup("rotate-ccw");
|
|
104
103
|
export const ICON_CHEVRON_LEFT = iconMarkup("chevron-left");
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/* Pure placement shared by the measured islands (link-tip, popout;
|
|
2
|
+
spec docs/superpowers/specs/2026-09-26-popout-design.md moved it
|
|
3
|
+
here from link-tip.ts). Measure then place, never predict. */
|
|
4
|
+
|
|
5
|
+
const GAP = 1; /* px between anchor and tip; the old bottom: calc(100% + 1px) */
|
|
6
|
+
const INSET = 2; /* px of horizontal lead-out past the anchor edge */
|
|
7
|
+
|
|
8
|
+
export const EDGE = 12; /* px of breathing room against either viewport edge */
|
|
9
|
+
|
|
10
|
+
export type TipPlace = "above" | "below";
|
|
11
|
+
export type TipAlign = "start" | "end";
|
|
12
|
+
export interface AnchorRect {
|
|
13
|
+
left: number;
|
|
14
|
+
right: number;
|
|
15
|
+
top: number;
|
|
16
|
+
bottom: number;
|
|
17
|
+
}
|
|
18
|
+
export interface TipSize {
|
|
19
|
+
width: number;
|
|
20
|
+
height: number;
|
|
21
|
+
}
|
|
22
|
+
export interface ViewportSize {
|
|
23
|
+
width: number;
|
|
24
|
+
height: number;
|
|
25
|
+
}
|
|
26
|
+
export interface TipPosition {
|
|
27
|
+
x: number;
|
|
28
|
+
y: number;
|
|
29
|
+
place: TipPlace;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* Pure placement for the singleton tip: start/end alignment against
|
|
33
|
+
the anchor, horizontal clamp into [edge, vw - edge] with the left
|
|
34
|
+
edge winning when both bind, and a vertical flip when the preferred
|
|
35
|
+
side would leave the viewport. All measured values; no prediction. */
|
|
36
|
+
export function placeTip(
|
|
37
|
+
anchor: AnchorRect,
|
|
38
|
+
tip: TipSize,
|
|
39
|
+
viewport: ViewportSize,
|
|
40
|
+
place: TipPlace = "above",
|
|
41
|
+
align: TipAlign = "start",
|
|
42
|
+
edge: number = EDGE,
|
|
43
|
+
): TipPosition {
|
|
44
|
+
const startX = anchor.left - INSET;
|
|
45
|
+
const endX = anchor.right + INSET - tip.width;
|
|
46
|
+
let x = align === "start" ? startX : endX;
|
|
47
|
+
|
|
48
|
+
const overRight = x + tip.width - (viewport.width - edge);
|
|
49
|
+
if (overRight > 0) x -= overRight;
|
|
50
|
+
if (x < edge) x = edge;
|
|
51
|
+
let finalPlace = place;
|
|
52
|
+
|
|
53
|
+
if (place === "above" && anchor.top - GAP - tip.height < 0) {
|
|
54
|
+
finalPlace = "below";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (place === "below" && anchor.bottom + GAP + tip.height > viewport.height) {
|
|
58
|
+
finalPlace = "above";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const y =
|
|
62
|
+
finalPlace === "above"
|
|
63
|
+
? anchor.top - GAP - tip.height
|
|
64
|
+
: anchor.bottom + GAP;
|
|
65
|
+
|
|
66
|
+
return { x: Math.round(x), y: Math.round(y), place: finalPlace };
|
|
67
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/* The localStorage read/validate/write codec (step 10), one home for
|
|
2
|
-
|
|
2
|
+
a pattern that used to be scattered across the site's own scripts:
|
|
3
|
+
parse is
|
|
3
4
|
the validator (junk parses to null and read() then removes the key),
|
|
4
5
|
and every storage touch sits inside try/catch so private mode or
|
|
5
6
|
disabled storage degrades to in-page-only state, never a thrown
|
package/src/scripts/ecosystem.ts
CHANGED
|
@@ -68,7 +68,12 @@ export function validateDocument(raw: unknown): EcosystemDocument | null {
|
|
|
68
68
|
|
|
69
69
|
/** The self entry's family first, each group by ascending priority and
|
|
70
70
|
then label, capped at limit. Null when selfKey is absent, which is
|
|
71
|
-
the refusal that keeps a site out of a list missing itself.
|
|
71
|
+
the refusal that keeps a site out of a list missing itself.
|
|
72
|
+
|
|
73
|
+
The self entry always survives the cap: a footer that would render
|
|
74
|
+
the cap without the site's own entry is worse than one that swaps
|
|
75
|
+
out its lowest-priority stranger, so the self entry takes the last
|
|
76
|
+
slot when sorting alone would have dropped it past the limit. */
|
|
72
77
|
export function sortEntries(
|
|
73
78
|
entries: EcosystemDocEntry[],
|
|
74
79
|
selfKey: string,
|
|
@@ -77,15 +82,18 @@ export function sortEntries(
|
|
|
77
82
|
const self = entries.find((entry) => entry.key === selfKey);
|
|
78
83
|
if (!self) return null;
|
|
79
84
|
const own = self.family;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
85
|
+
|
|
86
|
+
const sorted = [...entries].sort((a, b) => {
|
|
87
|
+
const aOwn = a.family === own ? 0 : 1;
|
|
88
|
+
const bOwn = b.family === own ? 0 : 1;
|
|
89
|
+
if (aOwn !== bOwn) return aOwn - bOwn;
|
|
90
|
+
if (a.priority !== b.priority) return a.priority - b.priority;
|
|
91
|
+
return a.label.localeCompare(b.label);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const capped = sorted.slice(0, limit);
|
|
95
|
+
if (limit > 0 && !capped.includes(self)) capped[capped.length - 1] = self;
|
|
96
|
+
return capped;
|
|
89
97
|
}
|
|
90
98
|
|
|
91
99
|
const CACHE_KEY = "half-built-ecosystem";
|
|
@@ -15,11 +15,41 @@ export interface FocusModeOptions {
|
|
|
15
15
|
target?: EventTarget;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/* Text entry is not navigation: a reader who clicked into a field and
|
|
19
|
+
types keeps the click highlight. Tab always means keyboard. */
|
|
20
|
+
function isTextEntry(t: EventTarget | null): boolean {
|
|
21
|
+
if (!(t instanceof HTMLElement)) return false;
|
|
22
|
+
|
|
23
|
+
if (
|
|
24
|
+
t.isContentEditable ||
|
|
25
|
+
t instanceof HTMLTextAreaElement ||
|
|
26
|
+
t instanceof HTMLSelectElement
|
|
27
|
+
) {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!(t instanceof HTMLInputElement)) return false;
|
|
32
|
+
|
|
33
|
+
return ![
|
|
34
|
+
"checkbox",
|
|
35
|
+
"radio",
|
|
36
|
+
"button",
|
|
37
|
+
"submit",
|
|
38
|
+
"reset",
|
|
39
|
+
"range",
|
|
40
|
+
"color",
|
|
41
|
+
"file",
|
|
42
|
+
"image",
|
|
43
|
+
].includes(t.type);
|
|
44
|
+
}
|
|
45
|
+
|
|
18
46
|
export const mountFocusMode: Island<FocusModeOptions> = (
|
|
19
47
|
root,
|
|
20
48
|
options = {},
|
|
21
49
|
): IslandHandle => {
|
|
22
|
-
const el =
|
|
50
|
+
const el =
|
|
51
|
+
root instanceof Document ? root.documentElement : (root as HTMLElement);
|
|
52
|
+
|
|
23
53
|
const target = options.target ?? window;
|
|
24
54
|
|
|
25
55
|
if (!claim(el, "focus-mode")) {
|
|
@@ -31,7 +61,9 @@ export const mountFocusMode: Island<FocusModeOptions> = (
|
|
|
31
61
|
};
|
|
32
62
|
}
|
|
33
63
|
|
|
34
|
-
const onKeydown = (): void => {
|
|
64
|
+
const onKeydown = (ev: Event): void => {
|
|
65
|
+
if (!(ev instanceof KeyboardEvent)) return;
|
|
66
|
+
if (ev.key !== "Tab" && isTextEntry(ev.target)) return;
|
|
35
67
|
el.dataset.focus = "keyboard";
|
|
36
68
|
};
|
|
37
69
|
|
package/src/scripts/lightbox.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
/* Image lightbox
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
/* Image lightbox. Exported pure math plus mountLightbox, on the island
|
|
2
|
+
contract (step 9): mount(root, options?) returns a destroy handle;
|
|
3
|
+
claim() makes a second mount over an already-wired link a no-op.
|
|
4
|
+
Called from the consumer's layout script block and testable under
|
|
5
|
+
jsdom. */
|
|
6
6
|
|
|
7
7
|
import { claim, release, type Island, type IslandHandle } from "./core/island";
|
|
8
8
|
import { iconButton, docOf } from "./core/dom";
|
|
@@ -116,6 +116,26 @@ export function outOfWhack(
|
|
|
116
116
|
return overlap < WHACK_FRACTION * Math.min(w * h, boxW * boxH);
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
/* One mouse notch (100px) is about the old fixed 1.2x step; a
|
|
120
|
+
trackpad's many small deltas add up to the same total instead of
|
|
121
|
+
stepping 1.2x each. deltaMode 1 is lines, 2 is pages. */
|
|
122
|
+
const WHEEL_K = Math.log(1.2) / 100;
|
|
123
|
+
const LINE_PX = 16;
|
|
124
|
+
|
|
125
|
+
export function wheelZoomFactor(
|
|
126
|
+
deltaY: number,
|
|
127
|
+
deltaMode: number,
|
|
128
|
+
pageHeight: number,
|
|
129
|
+
): number {
|
|
130
|
+
if (deltaY === 0) return 1;
|
|
131
|
+
|
|
132
|
+
let px = deltaY;
|
|
133
|
+
if (deltaMode === 1) px = deltaY * LINE_PX;
|
|
134
|
+
else if (deltaMode === 2) px = deltaY * pageHeight;
|
|
135
|
+
|
|
136
|
+
return Math.exp(-px * WHEEL_K);
|
|
137
|
+
}
|
|
138
|
+
|
|
119
139
|
export function readout(view: ZoomView, fit: number, prefix = "FIT"): string {
|
|
120
140
|
const sign = (n: number): string => {
|
|
121
141
|
const r = Math.round(n);
|
|
@@ -359,6 +379,10 @@ export const mountLightbox: Island<LightboxOptions> = (
|
|
|
359
379
|
let view: ZoomView = { zoom: 1, x: 0, y: 0 };
|
|
360
380
|
let boxW = FALLBACK_BOX.w;
|
|
361
381
|
let boxH = FALLBACK_BOX.h;
|
|
382
|
+
/* Registered while the dialog is open, one at a time: refit on
|
|
383
|
+
resize, torn down on close so a reopen registers a fresh one
|
|
384
|
+
rather than piling up. */
|
|
385
|
+
let onResize: (() => void) | null = null;
|
|
362
386
|
|
|
363
387
|
const applyView = (r: Refs): void => {
|
|
364
388
|
const item = items[index];
|
|
@@ -369,6 +393,17 @@ export const mountLightbox: Island<LightboxOptions> = (
|
|
|
369
393
|
r.homeBtn.hidden = !outOfWhack(view, item.w, item.h, boxW, boxH);
|
|
370
394
|
};
|
|
371
395
|
|
|
396
|
+
/* fit and view must move together: readout's FIT/percent judgment is
|
|
397
|
+
view.zoom === fit, so any path that resets the view to fit (a new
|
|
398
|
+
item, HOME, 0, the double-click reset, or a resize) recomputes
|
|
399
|
+
both here rather than setting view alone and leaving fit stale. */
|
|
400
|
+
const resetToFit = (r: Refs): void => {
|
|
401
|
+
const item = items[index];
|
|
402
|
+
fit = fitZoom(item.w, item.h, boxW, boxH);
|
|
403
|
+
view = initialView(item.w, item.h, boxW, boxH);
|
|
404
|
+
applyView(r);
|
|
405
|
+
};
|
|
406
|
+
|
|
372
407
|
const goTo = (r: Refs, i: number): void => {
|
|
373
408
|
const n = items.length;
|
|
374
409
|
show(r, ((i % n) + n) % n);
|
|
@@ -412,15 +447,13 @@ export const mountLightbox: Island<LightboxOptions> = (
|
|
|
412
447
|
const show = (r: Refs, i: number): void => {
|
|
413
448
|
index = i;
|
|
414
449
|
const item = items[index];
|
|
415
|
-
fit = fitZoom(item.w, item.h, boxW, boxH);
|
|
416
|
-
view = initialView(item.w, item.h, boxW, boxH);
|
|
417
450
|
r.caption.textContent = item.caption;
|
|
418
451
|
r.img.alt = item.caption;
|
|
419
452
|
r.dims.textContent = `${String(item.w)} × ${String(item.h)}`;
|
|
420
453
|
r.counter.textContent = `${String(index + 1).padStart(2, "0")} / ${String(items.length).padStart(2, "0")}`;
|
|
421
454
|
r.img.src = item.thumb;
|
|
422
455
|
swapToFull(r.img, item.href, doc);
|
|
423
|
-
|
|
456
|
+
resetToFit(r);
|
|
424
457
|
};
|
|
425
458
|
|
|
426
459
|
const ensureRefs = (): Refs => {
|
|
@@ -465,23 +498,21 @@ export const mountLightbox: Island<LightboxOptions> = (
|
|
|
465
498
|
if (ev.key === "+" || ev.key === "=") rezoom(ZOOM_STEP, 0, 0);
|
|
466
499
|
if (ev.key === "-") rezoom(1 / ZOOM_STEP, 0, 0);
|
|
467
500
|
|
|
468
|
-
if (ev.key === "0")
|
|
469
|
-
view = initialView(item().w, item().h, boxW, boxH);
|
|
470
|
-
applyView(r);
|
|
471
|
-
}
|
|
501
|
+
if (ev.key === "0") resetToFit(r);
|
|
472
502
|
});
|
|
473
503
|
|
|
474
504
|
r.homeBtn.addEventListener("click", () => {
|
|
475
|
-
|
|
476
|
-
applyView(r);
|
|
505
|
+
resetToFit(r);
|
|
477
506
|
});
|
|
478
507
|
|
|
479
508
|
r.viewbox.addEventListener(
|
|
480
509
|
"wheel",
|
|
481
510
|
(ev) => {
|
|
482
511
|
ev.preventDefault();
|
|
512
|
+
const factor = wheelZoomFactor(ev.deltaY, ev.deltaMode, boxH);
|
|
513
|
+
if (factor === 1) return;
|
|
483
514
|
const { cx, cy } = rel(ev);
|
|
484
|
-
rezoom(
|
|
515
|
+
rezoom(factor, cx, cy);
|
|
485
516
|
},
|
|
486
517
|
{ passive: false },
|
|
487
518
|
);
|
|
@@ -502,8 +533,7 @@ export const mountLightbox: Island<LightboxOptions> = (
|
|
|
502
533
|
const { cx, cy } = rel(ev);
|
|
503
534
|
rezoom(1 / view.zoom, cx, cy);
|
|
504
535
|
} else {
|
|
505
|
-
|
|
506
|
-
applyView(r);
|
|
536
|
+
resetToFit(r);
|
|
507
537
|
}
|
|
508
538
|
});
|
|
509
539
|
|
|
@@ -569,6 +599,16 @@ export const mountLightbox: Island<LightboxOptions> = (
|
|
|
569
599
|
r.viewbox.addEventListener("pointerup", lift);
|
|
570
600
|
r.viewbox.addEventListener("pointercancel", lift);
|
|
571
601
|
|
|
602
|
+
/* Torn down here rather than in destroy(): every close (veil,
|
|
603
|
+
Escape, close box, or a consumer's own close()) fires this, so
|
|
604
|
+
the listener never outlives the open dialog. */
|
|
605
|
+
r.dialog.addEventListener("close", () => {
|
|
606
|
+
if (onResize) {
|
|
607
|
+
window.removeEventListener("resize", onResize);
|
|
608
|
+
onResize = null;
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
|
|
572
612
|
refs = r;
|
|
573
613
|
return r;
|
|
574
614
|
};
|
|
@@ -588,6 +628,17 @@ export const mountLightbox: Island<LightboxOptions> = (
|
|
|
588
628
|
boxW = rect.width || FALLBACK_BOX.w;
|
|
589
629
|
boxH = rect.height || FALLBACK_BOX.h;
|
|
590
630
|
goTo(r, Math.max(0, set.indexOf(link)));
|
|
631
|
+
|
|
632
|
+
if (!onResize) {
|
|
633
|
+
onResize = (): void => {
|
|
634
|
+
const rr = r.viewbox.getBoundingClientRect();
|
|
635
|
+
boxW = rr.width || FALLBACK_BOX.w;
|
|
636
|
+
boxH = rr.height || FALLBACK_BOX.h;
|
|
637
|
+
resetToFit(r);
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
window.addEventListener("resize", onResize);
|
|
641
|
+
}
|
|
591
642
|
};
|
|
592
643
|
|
|
593
644
|
const mounted: {
|
|
@@ -624,6 +675,7 @@ export const mountLightbox: Island<LightboxOptions> = (
|
|
|
624
675
|
}
|
|
625
676
|
|
|
626
677
|
if (refs) {
|
|
678
|
+
if (refs.dialog.open) refs.dialog.close();
|
|
627
679
|
refs.dialog.remove();
|
|
628
680
|
refs = null;
|
|
629
681
|
}
|
package/src/scripts/link-tip.ts
CHANGED
|
@@ -1,68 +1,6 @@
|
|
|
1
1
|
import { claim, release, type Island, type IslandHandle } from "./core/island";
|
|
2
2
|
import { docOf } from "./core/dom";
|
|
3
|
-
|
|
4
|
-
const GAP = 1; /* px between anchor and tip; the old bottom: calc(100% + 1px) */
|
|
5
|
-
const INSET = 2; /* px of horizontal lead-out past the anchor edge */
|
|
6
|
-
const EDGE = 12; /* px of breathing room against either viewport edge */
|
|
7
|
-
|
|
8
|
-
export type TipPlace = "above" | "below";
|
|
9
|
-
export type TipAlign = "start" | "end";
|
|
10
|
-
export interface AnchorRect {
|
|
11
|
-
left: number;
|
|
12
|
-
right: number;
|
|
13
|
-
top: number;
|
|
14
|
-
bottom: number;
|
|
15
|
-
}
|
|
16
|
-
export interface TipSize {
|
|
17
|
-
width: number;
|
|
18
|
-
height: number;
|
|
19
|
-
}
|
|
20
|
-
export interface ViewportSize {
|
|
21
|
-
width: number;
|
|
22
|
-
height: number;
|
|
23
|
-
}
|
|
24
|
-
export interface TipPosition {
|
|
25
|
-
x: number;
|
|
26
|
-
y: number;
|
|
27
|
-
place: TipPlace;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/* Pure placement for the singleton tip: start/end alignment against
|
|
31
|
-
the anchor, horizontal clamp into [edge, vw - edge] with the left
|
|
32
|
-
edge winning when both bind, and a vertical flip when the preferred
|
|
33
|
-
side would leave the viewport. All measured values; no prediction. */
|
|
34
|
-
export function placeTip(
|
|
35
|
-
anchor: AnchorRect,
|
|
36
|
-
tip: TipSize,
|
|
37
|
-
viewport: ViewportSize,
|
|
38
|
-
place: TipPlace = "above",
|
|
39
|
-
align: TipAlign = "start",
|
|
40
|
-
edge: number = EDGE,
|
|
41
|
-
): TipPosition {
|
|
42
|
-
const startX = anchor.left - INSET;
|
|
43
|
-
const endX = anchor.right + INSET - tip.width;
|
|
44
|
-
let x = align === "start" ? startX : endX;
|
|
45
|
-
|
|
46
|
-
const overRight = x + tip.width - (viewport.width - edge);
|
|
47
|
-
if (overRight > 0) x -= overRight;
|
|
48
|
-
if (x < edge) x = edge;
|
|
49
|
-
let finalPlace = place;
|
|
50
|
-
|
|
51
|
-
if (place === "above" && anchor.top - GAP - tip.height < 0) {
|
|
52
|
-
finalPlace = "below";
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
if (place === "below" && anchor.bottom + GAP + tip.height > viewport.height) {
|
|
56
|
-
finalPlace = "above";
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const y =
|
|
60
|
-
finalPlace === "above"
|
|
61
|
-
? anchor.top - GAP - tip.height
|
|
62
|
-
: anchor.bottom + GAP;
|
|
63
|
-
|
|
64
|
-
return { x: Math.round(x), y: Math.round(y), place: finalPlace };
|
|
65
|
-
}
|
|
3
|
+
import { placeTip, EDGE } from "./core/placement";
|
|
66
4
|
|
|
67
5
|
export interface LinkTipOptions {
|
|
68
6
|
selector?: string;
|
|
@@ -97,9 +35,10 @@ export const mountLinkTips: Island<LinkTipOptions> = (
|
|
|
97
35
|
const win = doc.defaultView;
|
|
98
36
|
/* Touch: claims (so double mounts stay no-ops) but attaches nothing;
|
|
99
37
|
mobile browsers already show the URL themselves. jsdom has no
|
|
100
|
-
matchMedia at all (same widened-type guard
|
|
101
|
-
path-player.ts): the plain Window type always
|
|
102
|
-
so an unwidened optional chain reads as
|
|
38
|
+
matchMedia at all (same widened-type guard used elsewhere in the
|
|
39
|
+
package, path-player.ts included): the plain Window type always
|
|
40
|
+
declares the method, so an unwidened optional chain reads as
|
|
41
|
+
always-true to the lint. */
|
|
103
42
|
const mmWin = win as { matchMedia?: typeof window.matchMedia } | null;
|
|
104
43
|
|
|
105
44
|
if (mmWin?.matchMedia?.("(hover: none)").matches) {
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
2026-08-22); flip SCRUB to re-enable. */
|
|
7
7
|
import { buildPlateModal } from "./plate-modal";
|
|
8
8
|
import { createFrameLoop } from "./core/frame-loop";
|
|
9
|
+
import { BP_PHONE_MAX } from "./core/breakpoints";
|
|
9
10
|
import { ICON_PLAY, ICON_PAUSE, ICON_ROTATE_CCW } from "./core/icons";
|
|
10
11
|
import { playheadX, formatReadout, columnIndex } from "./path-player-math";
|
|
11
12
|
import {
|
|
@@ -21,8 +22,8 @@ import {
|
|
|
21
22
|
which no-inferrable-types rejects on a literal) so flipping this to
|
|
22
23
|
re-enable scrubbing is a one-line change: a literal type here would
|
|
23
24
|
make the `if (SCRUB)` below statically always-false and trip the
|
|
24
|
-
no-unnecessary-condition lint rule. Same widening idea as
|
|
25
|
-
|
|
25
|
+
no-unnecessary-condition lint rule. Same widening idea as this
|
|
26
|
+
file's own matchMedia guard below. */
|
|
26
27
|
const SCRUB = false as boolean;
|
|
27
28
|
const AXIS_H = 14;
|
|
28
29
|
const TRACK_GAP = 5;
|
|
@@ -161,6 +162,8 @@ export function createPathPlayer<S>(
|
|
|
161
162
|
restartBtn.className = "pp-restart icon-box press-box";
|
|
162
163
|
restartBtn.setAttribute("aria-label", restart);
|
|
163
164
|
restartBtn.innerHTML = ICON_ROTATE_CCW;
|
|
165
|
+
playBtn.innerHTML = ICON_PLAY;
|
|
166
|
+
playBtn.setAttribute("aria-pressed", "false");
|
|
164
167
|
|
|
165
168
|
if (!stageReady) {
|
|
166
169
|
playBtn.disabled = true;
|
|
@@ -192,10 +195,10 @@ export function createPathPlayer<S>(
|
|
|
192
195
|
captionToggle.textContent = about;
|
|
193
196
|
captionToggle.setAttribute("aria-controls", caption.id);
|
|
194
197
|
|
|
195
|
-
/* Same widening as
|
|
196
|
-
bare typeof check reads as always-true to the
|
|
197
|
-
type is widened too so a test stub without
|
|
198
|
-
still valid. */
|
|
198
|
+
/* Same widening as the package's other matchMedia guards: jsdom has
|
|
199
|
+
no matchMedia, and a bare typeof check reads as always-true to the
|
|
200
|
+
lint. The query list type is widened too so a test stub without
|
|
201
|
+
addEventListener is still valid. */
|
|
199
202
|
interface PhoneQuery {
|
|
200
203
|
matches: boolean;
|
|
201
204
|
addEventListener?: (
|
|
@@ -211,7 +214,7 @@ export function createPathPlayer<S>(
|
|
|
211
214
|
|
|
212
215
|
if (!mediaQuery) return null;
|
|
213
216
|
|
|
214
|
-
return mediaQuery.call(globalThis,
|
|
217
|
+
return mediaQuery.call(globalThis, `(max-width: ${BP_PHONE_MAX}px)`);
|
|
215
218
|
};
|
|
216
219
|
|
|
217
220
|
const phone = (): boolean => phoneQuery()?.matches ?? false;
|
|
@@ -305,6 +308,15 @@ export function createPathPlayer<S>(
|
|
|
305
308
|
staticTracks = paintStatic(w, scale);
|
|
306
309
|
}
|
|
307
310
|
|
|
311
|
+
/* The timeline canvas is CSS-width-driven, and its offscreen static
|
|
312
|
+
layer is baked at a fixed pixel width; a viewport resize while the
|
|
313
|
+
modal is open leaves it stale until the next close/open round trip
|
|
314
|
+
unless it re-lays here. */
|
|
315
|
+
const onResize = (): void => {
|
|
316
|
+
layoutTracks();
|
|
317
|
+
drawFrame();
|
|
318
|
+
};
|
|
319
|
+
|
|
308
320
|
function drawFrame(): void {
|
|
309
321
|
const ctx = tracksCanvas.getContext("2d");
|
|
310
322
|
if (!ctx || !staticTracks) return;
|
|
@@ -404,6 +416,7 @@ export function createPathPlayer<S>(
|
|
|
404
416
|
setPlaying(false);
|
|
405
417
|
loop.stop();
|
|
406
418
|
config.sink.pause?.();
|
|
419
|
+
win.removeEventListener("resize", onResize);
|
|
407
420
|
});
|
|
408
421
|
|
|
409
422
|
if (SCRUB) {
|
|
@@ -416,13 +429,14 @@ export function createPathPlayer<S>(
|
|
|
416
429
|
pm.open(opener);
|
|
417
430
|
setCaptionShown(!phone());
|
|
418
431
|
layoutTracks();
|
|
432
|
+
win.addEventListener("resize", onResize);
|
|
419
433
|
readout.textContent = formatReadout(state.t, config.duration);
|
|
420
434
|
config.sink.resume?.();
|
|
421
435
|
|
|
422
436
|
/* Reduced motion: open paused; the transport still plays on demand.
|
|
423
|
-
jsdom has no matchMedia, hence the widened type (same guard
|
|
424
|
-
|
|
425
|
-
which is cast non-null: this one must stay optional so a
|
|
437
|
+
jsdom has no matchMedia, hence the widened type (same guard used
|
|
438
|
+
elsewhere in this file). Kept separate from the frame loop's
|
|
439
|
+
`win`, which is cast non-null: this one must stay optional so a
|
|
426
440
|
matchMedia-less jsdom does not throw. */
|
|
427
441
|
const mmWin = doc.defaultView as {
|
|
428
442
|
matchMedia?: typeof window.matchMedia;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
/* Shared plate-window chrome
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
/* Shared plate-window chrome, built for the fluid path player and
|
|
2
|
+
generalized for shared use: dialog + veil, corner-stroked zone, ink
|
|
3
|
+
plate, boxed labels, close. Consumers: the image lightbox
|
|
4
|
+
(legacyPrefix "lb" keeps its old class names alive) and the path
|
|
5
|
+
player. */
|
|
6
6
|
|
|
7
7
|
import { ICON_X } from "./core/icons";
|
|
8
8
|
|
|
@@ -86,8 +86,19 @@ export function buildPlateModal(
|
|
|
86
86
|
button's innerHTML detaches the click's target mid-bubble, and a
|
|
87
87
|
detached node is contained by nothing, which read as a veil click
|
|
88
88
|
and closed the dialog under the pause button (found 2026-08-23). */
|
|
89
|
+
/* A click targets the nearest common ancestor of press and release,
|
|
90
|
+
so a drag from inside the plate that ends on the veil arrives here
|
|
91
|
+
targeted at the dialog. Only a press that also began on the veil
|
|
92
|
+
closes. */
|
|
93
|
+
let pressOnVeil = false;
|
|
94
|
+
|
|
95
|
+
dialog.addEventListener("pointerdown", (ev) => {
|
|
96
|
+
pressOnVeil = ev.target === dialog;
|
|
97
|
+
});
|
|
98
|
+
|
|
89
99
|
dialog.addEventListener("click", (ev) => {
|
|
90
|
-
if (ev.target === dialog) dialog.close();
|
|
100
|
+
if (ev.target === dialog && pressOnVeil) dialog.close();
|
|
101
|
+
pressOnVeil = false;
|
|
91
102
|
});
|
|
92
103
|
|
|
93
104
|
const addLabel = (
|