@escape-game-over/atlas 0.1.79 → 0.1.80

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.79",
3
+ "version": "0.1.80",
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>
@@ -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;