@escape-game-over/atlas 0.1.79 → 0.1.81
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@escape-game-over/atlas",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.81",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
|
|
6
6
|
"private": false,
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"./astro/images": "./src/astro/images.ts",
|
|
18
18
|
"./client": "./src/astro/client.ts",
|
|
19
19
|
"./astro/auto-dialog": "./src/astro/AutoDialog.astro",
|
|
20
|
+
"./astro/background-video": "./src/astro/BackgroundVideo.astro",
|
|
20
21
|
"./astro/consent-banner": "./src/astro/ConsentBanner.astro",
|
|
21
22
|
"./astro/document": "./src/astro/Document.astro",
|
|
22
23
|
"./astro/element": "./src/astro/AtlasElement.astro",
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A decorative, silent loop over its own poster, with the pause control WCAG
|
|
4
|
+
* 2.2.2 asks for. Playback is `backgroundVideo`'s; this is the markup it needs,
|
|
5
|
+
* which is where a hero quietly goes wrong:
|
|
6
|
+
*
|
|
7
|
+
* - The poster is a `<picture>`, not the `poster` attribute, so each
|
|
8
|
+
* orientation gets its own still, cut to size. It is usually the largest
|
|
9
|
+
* paint, so it loads eagerly and first.
|
|
10
|
+
* - `preload="none"`: nothing of the loop is fetched until it starts, which is
|
|
11
|
+
* after `load` — `metadata` alone was enough to undo that wait.
|
|
12
|
+
* - `muted`, `loop`, `playsinline`, and hidden from assistive technology.
|
|
13
|
+
* - The control stays hidden until a cut suits the viewport, and names what it
|
|
14
|
+
* will do — `play` or `pause`, the site's own words.
|
|
15
|
+
*
|
|
16
|
+
* The site gives the element its size and position, the control its look, and
|
|
17
|
+
* its two icons as the `playing` slot (shown while it plays: a pause icon) and
|
|
18
|
+
* the `paused` slot.
|
|
19
|
+
*
|
|
20
|
+
* ```astro
|
|
21
|
+
* <BackgroundVideo
|
|
22
|
+
* sources={[{ src: webm, type: 'video/webm; codecs="vp9"' }]}
|
|
23
|
+
* posters={[{ image: portraitStill, media: "(orientation: portrait)" }, { image: still }]}
|
|
24
|
+
* play={t("hero.play")}
|
|
25
|
+
* pause={t("hero.pause")}
|
|
26
|
+
* class="absolute inset-0"
|
|
27
|
+
* buttonClass="absolute bottom-4 right-4 …"
|
|
28
|
+
* >
|
|
29
|
+
* <Icon slot="playing" name="lucide:pause" />
|
|
30
|
+
* <Icon slot="paused" name="lucide:play" />
|
|
31
|
+
* </BackgroundVideo>
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
import { getImage } from "astro:assets";
|
|
35
|
+
import type { HTMLAttributes } from "astro/types";
|
|
36
|
+
import type { NonEmpty } from "../types.ts";
|
|
37
|
+
import AtlasElement from "./AtlasElement.astro";
|
|
38
|
+
import type { VideoPoster, VideoSource } from "./background-video.ts";
|
|
39
|
+
import { backgroundVideoElement } from "./background-video-element.ts";
|
|
40
|
+
|
|
41
|
+
type Props = Omit<HTMLAttributes<"div">, "children"> & {
|
|
42
|
+
readonly sources: NonEmpty<VideoSource>;
|
|
43
|
+
/** Stills by viewport, as `<picture>` reads them: the last has no `media`. */
|
|
44
|
+
readonly posters: NonEmpty<VideoPoster>;
|
|
45
|
+
/** The control's label while paused. */
|
|
46
|
+
readonly play: string;
|
|
47
|
+
/** The control's label while playing. */
|
|
48
|
+
readonly pause: string;
|
|
49
|
+
readonly buttonClass?: string;
|
|
50
|
+
/** For the poster's `srcset`. Defaults to `100vw`, a full-bleed background. */
|
|
51
|
+
readonly sizes?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const {
|
|
55
|
+
sources,
|
|
56
|
+
posters,
|
|
57
|
+
play,
|
|
58
|
+
pause,
|
|
59
|
+
buttonClass,
|
|
60
|
+
sizes = "100vw",
|
|
61
|
+
...attrs
|
|
62
|
+
} = Astro.props;
|
|
63
|
+
|
|
64
|
+
const fallback = posters[posters.length - 1];
|
|
65
|
+
if (fallback?.media !== undefined) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
"BackgroundVideo: the last poster is the fallback, and takes no `media`."
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (posters.slice(0, -1).some((poster) => poster.media === undefined)) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
"BackgroundVideo: every poster but the last needs a `media`; one without would always win."
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const cut = await Promise.all(
|
|
77
|
+
posters.map(async (poster) => ({
|
|
78
|
+
media: poster.media,
|
|
79
|
+
image: await getImage({
|
|
80
|
+
src: poster.image,
|
|
81
|
+
widths: [...(poster.widths ?? [960, 1440, 1920])],
|
|
82
|
+
format: "webp",
|
|
83
|
+
}),
|
|
84
|
+
}))
|
|
85
|
+
);
|
|
86
|
+
const still = cut[cut.length - 1];
|
|
87
|
+
|
|
88
|
+
// Structural, so inline: the still under the video, both covering the element.
|
|
89
|
+
const cover = "width:100%;height:100%;object-fit:cover";
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
<script src="./background-video-element.ts" />
|
|
93
|
+
|
|
94
|
+
<AtlasElement
|
|
95
|
+
of={backgroundVideoElement}
|
|
96
|
+
data-play={play}
|
|
97
|
+
data-pause={pause}
|
|
98
|
+
{...attrs}
|
|
99
|
+
>
|
|
100
|
+
<picture>
|
|
101
|
+
{cut.slice(0, -1).map((poster) => (
|
|
102
|
+
<source
|
|
103
|
+
media={poster.media}
|
|
104
|
+
srcset={poster.image.srcSet.attribute}
|
|
105
|
+
sizes={sizes}
|
|
106
|
+
/>
|
|
107
|
+
))}
|
|
108
|
+
<img
|
|
109
|
+
src={still?.image.src}
|
|
110
|
+
srcset={still?.image.srcSet.attribute}
|
|
111
|
+
sizes={sizes}
|
|
112
|
+
alt=""
|
|
113
|
+
fetchpriority="high"
|
|
114
|
+
style={`position:absolute;inset:0;${cover}`}
|
|
115
|
+
>
|
|
116
|
+
</picture>
|
|
117
|
+
<video
|
|
118
|
+
style={`position:relative;${cover}`}
|
|
119
|
+
muted
|
|
120
|
+
loop
|
|
121
|
+
playsinline
|
|
122
|
+
preload="none"
|
|
123
|
+
aria-hidden="true"
|
|
124
|
+
tabindex="-1"
|
|
125
|
+
>
|
|
126
|
+
{sources.map((source) => (
|
|
127
|
+
<source
|
|
128
|
+
src={source.src}
|
|
129
|
+
media={source.media}
|
|
130
|
+
type={source.type}
|
|
131
|
+
/>
|
|
132
|
+
))}
|
|
133
|
+
</video>
|
|
134
|
+
<button
|
|
135
|
+
type="button"
|
|
136
|
+
data-video-toggle
|
|
137
|
+
hidden
|
|
138
|
+
aria-label={pause}
|
|
139
|
+
class={buttonClass}
|
|
140
|
+
>
|
|
141
|
+
<span data-video-playing>
|
|
142
|
+
<slot name="playing" />
|
|
143
|
+
</span>
|
|
144
|
+
<span
|
|
145
|
+
data-video-paused
|
|
146
|
+
hidden
|
|
147
|
+
>
|
|
148
|
+
<slot name="paused" />
|
|
149
|
+
</span>
|
|
150
|
+
</button>
|
|
151
|
+
</AtlasElement>
|
package/src/astro/Image.astro
CHANGED
|
@@ -8,7 +8,15 @@
|
|
|
8
8
|
* are made at standard device widths up to the source's own. A lazy image
|
|
9
9
|
* gets `auto` in front, so browsers that can measure the real width do.
|
|
10
10
|
*
|
|
11
|
-
* `zoom` opens the image larger in a dialog.
|
|
11
|
+
* `zoom` opens the image larger in a dialog. Its close button shows the `close`
|
|
12
|
+
* slot, the site's own icon, which `zoom` requires: a text `×` centres by the
|
|
13
|
+
* font's metrics, not the button's.
|
|
14
|
+
*
|
|
15
|
+
* ```astro
|
|
16
|
+
* <Image src={photo} alt={alt} sizes="33vw" zoom={{ closeLabel: t("close") }}>
|
|
17
|
+
* <Icon slot="close" name="lucide:x" />
|
|
18
|
+
* </Image>
|
|
19
|
+
* ```
|
|
12
20
|
*/
|
|
13
21
|
import { Picture } from "astro:assets";
|
|
14
22
|
import type { ComponentProps } from "astro/types";
|
|
@@ -49,6 +57,12 @@ const DEVICE_WIDTHS = [640, 750, 828, 1080, 1280, 1668, 1920, 2048, 2560, 3840];
|
|
|
49
57
|
const given = Astro.props;
|
|
50
58
|
const { src, alt, priority = false, zoom } = given;
|
|
51
59
|
|
|
60
|
+
if (zoom !== undefined && !Astro.slots.has("close")) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`Image: "${alt}" zooms but has no close icon. Pass one: <Icon slot="close" …>.`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
52
66
|
const shape =
|
|
53
67
|
given.sizes !== undefined
|
|
54
68
|
? (() => {
|
|
@@ -97,5 +111,9 @@ const picture: ComponentProps<typeof Picture> = {
|
|
|
97
111
|
closeLabel={zoom.closeLabel}
|
|
98
112
|
class={zoom.class}>
|
|
99
113
|
<Picture {...picture} />
|
|
114
|
+
<slot
|
|
115
|
+
name='close'
|
|
116
|
+
slot='close'
|
|
117
|
+
/>
|
|
100
118
|
</Zoom>
|
|
101
119
|
)}
|
package/src/astro/Zoom.astro
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Internal to `Image.astro`. No script: `command="show-modal"` opens the dialog
|
|
4
4
|
* and `closedby="any"` lets a click outside close it. The large image is lazy
|
|
5
|
-
* inside a closed dialog, so it is fetched on open.
|
|
5
|
+
* inside a closed dialog, so it is fetched on open. The close button shows the
|
|
6
|
+
* `close` slot.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import { getImage } from "astro:assets";
|
|
@@ -47,7 +48,7 @@ const id = `atlas-zoom${large.src.replace(/[^\w-]/g, "-")}`;
|
|
|
47
48
|
command="close"
|
|
48
49
|
aria-label={closeLabel}
|
|
49
50
|
>
|
|
50
|
-
|
|
51
|
+
<slot name="close" />
|
|
51
52
|
</button>
|
|
52
53
|
<img
|
|
53
54
|
src={large.src}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Internal to `BackgroundVideo.astro`: binds its markup to `backgroundVideo`.
|
|
2
|
+
import { type BackgroundVideo, backgroundVideo } from "./background-video.ts";
|
|
3
|
+
import { element } from "./element.ts";
|
|
4
|
+
import { data, ref } from "./ref.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Each root's loop, kept across a re-attach: the reader's pause belongs to the
|
|
8
|
+
* video, not to one connection of the element.
|
|
9
|
+
*/
|
|
10
|
+
const loops = new WeakMap<HTMLElement, BackgroundVideo>();
|
|
11
|
+
|
|
12
|
+
export const backgroundVideoElement = element(
|
|
13
|
+
"atlas-background-video",
|
|
14
|
+
({ root, signal }) => {
|
|
15
|
+
const play = data(root, "play");
|
|
16
|
+
const pause = data(root, "pause");
|
|
17
|
+
const button = ref<HTMLButtonElement>(root, "[data-video-toggle]");
|
|
18
|
+
const whenPlaying = ref<HTMLElement>(root, "[data-video-playing]");
|
|
19
|
+
const whenPaused = ref<HTMLElement>(root, "[data-video-paused]");
|
|
20
|
+
|
|
21
|
+
const loop =
|
|
22
|
+
loops.get(root) ??
|
|
23
|
+
backgroundVideo({
|
|
24
|
+
onChange: ({ playing, playable }) => {
|
|
25
|
+
// Nothing to pause where no cut suits this viewport.
|
|
26
|
+
button.hidden = !playable;
|
|
27
|
+
button.setAttribute("aria-label", playing ? pause : play);
|
|
28
|
+
whenPlaying.hidden = !playing;
|
|
29
|
+
whenPaused.hidden = playing;
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
loops.set(root, loop);
|
|
33
|
+
|
|
34
|
+
button.addEventListener("click", () => loop.toggle(), { signal });
|
|
35
|
+
|
|
36
|
+
return loop.attach(ref<HTMLVideoElement>(root, "video"));
|
|
37
|
+
}
|
|
38
|
+
);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A decorative background video's playback — not its markup, and not its button.
|
|
3
|
+
* `BackgroundVideo.astro` is both, built on this; reach for this directly only
|
|
4
|
+
* for markup that component cannot write.
|
|
3
5
|
*
|
|
4
6
|
* In `astro/` because it reaches for `matchMedia` and a media element, which the
|
|
5
7
|
* core is type-checked without.
|
|
@@ -55,6 +57,27 @@
|
|
|
55
57
|
* or `"metadata"` keeps the browser from fetching the loop on its own.
|
|
56
58
|
*/
|
|
57
59
|
|
|
60
|
+
/** One file of a `BackgroundVideo`, as a `<source>`. */
|
|
61
|
+
export interface VideoSource {
|
|
62
|
+
readonly src: string;
|
|
63
|
+
/**
|
|
64
|
+
* With its codecs — `video/webm; codecs="vp9"` — so a browser skips what
|
|
65
|
+
* it cannot play without fetching it to find out.
|
|
66
|
+
*/
|
|
67
|
+
readonly type: string;
|
|
68
|
+
/** The viewports this cut is for; the first matching source wins. */
|
|
69
|
+
readonly media?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One still of a `BackgroundVideo`'s poster, as a `<picture>` source. */
|
|
73
|
+
export interface VideoPoster {
|
|
74
|
+
readonly image: ImageMetadata;
|
|
75
|
+
/** The widths to cut, for `srcset`. Defaults to 960, 1440 and 1920. */
|
|
76
|
+
readonly widths?: readonly number[];
|
|
77
|
+
/** The viewports this still is for. Absent on the last, the fallback. */
|
|
78
|
+
readonly media?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
58
81
|
export interface BackgroundVideoState {
|
|
59
82
|
/** Whether it is playing now: the element's own `paused`, inverted. */
|
|
60
83
|
readonly playing: boolean;
|