@escape-game-over/atlas 0.1.15 → 0.1.16

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.
@@ -1,7 +1,7 @@
1
1
  # Client scripts: what Atlas owns in the browser, and what it refuses to
2
2
 
3
3
  Everything else in this package runs at build time and emits text. This is the
4
- other half: seven modules under `src/astro/` that run in a reader's browser, and
4
+ other half: the modules under `src/astro/` that run in a reader's browser, and
5
5
  one rule they all keep.
6
6
 
7
7
  **They draw nothing.**
@@ -25,15 +25,24 @@ It sets no class, no style and no `aria`, which is where the choices live. It
25
25
  also never *finds* an element: it is handed the ones a project looked up, so no
26
26
  attribute name in this package has to be matched by any consumer.
27
27
 
28
- | Module | Owns | Leaves to the project |
29
- | ---------------------- | -------------------------------------------------------------- | ------------------------------------------------- |
30
- | `./astro/carousel` | which slide is current: modulo, swipe, autoplay, the move lock | the transform, dots, arrows, `aria` |
31
- | `./astro/filters` | which items match, and what the address bar says | every DOM read and write, and the markup contract |
32
- | `./astro/filters-view` | the two DOM writes every filtered list turned out to share | finding the elements, and everything else drawn |
33
- | `./astro/consent` | remembering an answer, expiring it, handing it to Google | the banner — its wording, its buttons, its law |
34
- | `./astro/element` | the two lifetimes a custom element has, and one abort signal | what the element is and does |
35
- | `./astro/dom` | scoped `one`/`all` lookups, typed | the selectors |
36
- | `./astro/dev-log` | a panel a failed wiring can announce itself in, in dev only | calling it behind `import.meta.env.DEV` |
28
+ `youtube` is the other exception, and the one that cannot be avoided: the player
29
+ *is* the behaviour, so something has to put an iframe in the page. What it sets
30
+ on it has no taste in it the address, the title, the permissions, full screen,
31
+ the referrer policy and everything that does, classes above all, comes from the
32
+ caller through a callback. It too is handed the button and the box rather than
33
+ finding them.
34
+
35
+ | Module | Owns | Leaves to the project |
36
+ | -------------------------- | -------------------------------------------------------------- | ------------------------------------------------- |
37
+ | `./astro/carousel` | which slide is current: modulo, swipe, autoplay, the move lock | the transform, dots, arrows, `aria` |
38
+ | `./astro/filters` | which items match, and what the address bar says | every DOM read and write, and the markup contract |
39
+ | `./astro/filters-view` | the two DOM writes every filtered list turned out to share | finding the elements, and everything else drawn |
40
+ | `./astro/youtube` | the swap from poster to player, on the click and not before | the poster, the button, the iframe's classes |
41
+ | `./astro/background-video` | playing or paused, playable or not, and which cut is loaded | the play/pause control, its icons and its label |
42
+ | `./astro/consent` | remembering an answer, expiring it, handing it to Google | the banner — its wording, its buttons, its law |
43
+ | `./astro/element` | the two lifetimes a custom element has, and one abort signal | what the element is and does |
44
+ | `./astro/dom` | scoped `one`/`all` lookups, typed | the selectors |
45
+ | `./astro/dev-log` | a panel a failed wiring can announce itself in, in dev only | calling it behind `import.meta.env.DEV` |
37
46
 
38
47
  `./astro/consent` is the only one of these with a build-time half. Its
39
48
  `consentApplies()` reports whether there is a Google tag to consent to, which a
@@ -53,8 +62,10 @@ the options, and when one drifts nothing reports it — a listener added without
53
62
  its twin simply survives teardown.
54
63
 
55
64
  ```ts
56
- const detach = slider.attach(viewport); // carousel: one element
57
- const detach = list.attach(); // filters: no element, only popstate
65
+ const detach = slider.attach(viewport); // carousel: one element
66
+ const detach = list.attach(); // filters: no element, only popstate
67
+ const detach = loop.attach(video); // background-video: the <video>
68
+ const detach = trailer.attach(button, box); // youtube: what is pressed, what it replaces
58
69
  ```
59
70
 
60
71
  `AtlasElement` says the same thing in the shape a custom element needs, because
@@ -111,7 +122,7 @@ than an item that silently never matches.
111
122
 
112
123
  | Kind | State | Item value | Matches when | In the URL | History |
113
124
  | -------- | --------------- | --------------- | ------------------------------------------------ | ------------------------------- | ------- |
114
- | `text` | the query | searchable text | folded query is a substring; empty keeps all | `?q=…`, dropped if empty | replace |
125
+ | `text` | the query | searchable text | every folded query word appears; empty keeps all | `?q=…`, dropped if empty | replace |
115
126
  | `choice` | one value, `""` | one, or several | the item holds the chosen one; `""` keeps all | `?category=…`, dropped if empty | push |
116
127
  | `flag` | boolean | boolean | off keeps all; on keeps only items that carry it | `?featured=1`, absent when off | push |
117
128
 
@@ -159,6 +170,41 @@ parameters or comma-joined) that nothing has needed yet.
159
170
 
160
171
  `param` is optional. A field without one is state-only and never reaches the URL.
161
172
 
173
+ ### Facet counts: `matchedWithout`
174
+
175
+ A dropdown that says how many results each option would give needs a different
176
+ set from `matched`: every field *except its own*. Counted against `matched`, a
177
+ facet whose value is already picked can only ever offer that value — every other
178
+ option reads zero, because the items holding them were just filtered out by the
179
+ very field being asked about.
180
+
181
+ ```ts
182
+ onChange({ matched }) {
183
+ const rest = list.matchedWithout("country");
184
+ for (const option of countryOptions) {
185
+ const value = option.dataset.choice;
186
+ const count = rows.filter(
187
+ (row) => rest.has(row.key) && row.country === value
188
+ ).length;
189
+ // …the project writes `count` wherever its design puts it
190
+ }
191
+ }
192
+ ```
193
+
194
+ It runs the list's own matcher — folding, tokens, several-valued choices, flags —
195
+ so a count cannot drift from the list beneath it. That drift is not
196
+ hypothetical: a consumer wrote its own predicate for these counts, folding
197
+ without trimming and matching the query as one run, and it agreed with the list
198
+ only until the list learned to match word by word. Nothing reported it; the
199
+ counts were simply wrong.
200
+
201
+ Computed on first ask and kept until the state next moves, so a render that asks
202
+ once per option pays one pass per field. A field that is not narrowing anything —
203
+ an empty query, no choice, a flag that is off — answers with `matched` itself.
204
+ Grouping the survivors by option stays with the caller, which already holds each
205
+ item's values; it is safe to call from `onChange`, which never runs during
206
+ construction.
207
+
162
208
  ### The four URL decisions
163
209
 
164
210
  1. **Replace while typing, push on a choice.** An input emits an event per
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -15,6 +15,7 @@
15
15
  ".": "./src/index.ts",
16
16
  "./astro": "./src/astro/index.ts",
17
17
  "./astro/images": "./src/astro/images.ts",
18
+ "./astro/background-video": "./src/astro/background-video.ts",
18
19
  "./astro/carousel": "./src/astro/carousel.ts",
19
20
  "./astro/consent": "./src/astro/consent.ts",
20
21
  "./astro/dev-log": "./src/astro/dev-log.ts",
@@ -22,7 +23,8 @@
22
23
  "./astro/element": "./src/astro/element.ts",
23
24
  "./astro/filters": "./src/astro/filters.ts",
24
25
  "./astro/filters-view": "./src/astro/filters-view.ts",
25
- "./astro/meta-tags": "./src/astro/MetaTags.astro"
26
+ "./astro/meta-tags": "./src/astro/MetaTags.astro",
27
+ "./astro/youtube": "./src/astro/youtube.ts"
26
28
  },
27
29
  "bin": {
28
30
  "atlas": "bin/use-project.mjs"
@@ -53,8 +55,8 @@
53
55
  "typescript": ">=5"
54
56
  },
55
57
  "devDependencies": {
56
- "@biomejs/biome": "2.5.12",
57
- "@types/node": "26.5.0",
58
+ "@biomejs/biome": "2.5.13",
59
+ "@types/node": "26.5.1",
58
60
  "@vitest/coverage-istanbul": "5.0.0",
59
61
  "astro": "7.3.2",
60
62
  "typescript": "6.0.3",
@@ -0,0 +1,290 @@
1
+ /**
2
+ * A decorative background video's playback — not its markup, and not its button.
3
+ *
4
+ * In `astro/` because it reaches for `matchMedia` and a media element, which the
5
+ * core is type-checked without.
6
+ *
7
+ * **It draws nothing.** No icon, no label, no `hidden`, no `aria`. It owns
8
+ * whether the video is playing, whether there is anything to play, and which of
9
+ * its cuts is loaded, and calls `onChange` when the first two move; the project
10
+ * draws its own play/pause control from that and points the control at
11
+ * `toggle`.
12
+ *
13
+ * ```ts
14
+ * const loop = backgroundVideo({
15
+ * onChange: ({ playing, playable }) => {
16
+ * button.hidden = !playable;
17
+ * button.setAttribute("aria-label", playing ? pauseLabel : playLabel);
18
+ * },
19
+ * });
20
+ *
21
+ * button.addEventListener("click", () => loop.toggle(), { signal });
22
+ * const detach = loop.attach(video);
23
+ * ```
24
+ *
25
+ * What it owns is the handful of things every looping hero needs and most get
26
+ * quietly wrong:
27
+ *
28
+ * - **Reduced motion starts it paused.** A reader who asked their system for
29
+ * less motion should not get a full-width loop behind the headline.
30
+ * - **A way to stop it.** WCAG 2.2.2 asks for one on anything that moves for
31
+ * more than five seconds, and a loop moves forever. The control is the
32
+ * project's; the state it shows is this.
33
+ * - **No control over nothing.** A `<source media>` that matches no viewport
34
+ * leaves the element empty, and a pause button beside an empty element pauses
35
+ * nothing. `playable` says so, and the caller hides it.
36
+ * - **The right cut after a rotation.** A browser chooses among `<source
37
+ * media>` once, when the element loads, and never again — so a phone turned
38
+ * sideways keeps the portrait cut, cropped across a landscape screen, until
39
+ * something reloads it.
40
+ * - **The reader's pause surviving that reload.** A reload resets the element
41
+ * to its markup, and markup that says `autoplay` starts again the video they
42
+ * had just stopped.
43
+ *
44
+ * The markup wants `muted`, `loop` and `playsinline`, and — being decoration —
45
+ * `aria-hidden="true"` and `tabindex="-1"`. `autoplay` is better left out: this
46
+ * starts the video itself, and without this script there is no control to stop
47
+ * it with, while with the attribute a reduced-motion reader sees the first
48
+ * frames move before this gets to pause them. Left in, everything still works.
49
+ */
50
+
51
+ export interface BackgroundVideoState {
52
+ /** Whether it is playing now: the element's own `paused`, inverted. */
53
+ readonly playing: boolean;
54
+ /**
55
+ * Whether a source was chosen for this viewport.
56
+ *
57
+ * False when no `<source media>` matched, or every one that did failed —
58
+ * there is nothing on screen to pause, so the control has nothing to do
59
+ * and should not be offered.
60
+ */
61
+ readonly playable: boolean;
62
+ }
63
+
64
+ export interface BackgroundVideoOptions {
65
+ /**
66
+ * Called once on `attach`, and then whenever `playing` or `playable` moves.
67
+ *
68
+ * Once on `attach` although nothing moved, because the control's first
69
+ * state is only known then: whether the viewport matched a source and
70
+ * whether reduced motion held it still are both read from the page, and the
71
+ * markup cannot say either in advance. Never called for an event that
72
+ * changed neither, so a caller can redraw unconditionally.
73
+ */
74
+ readonly onChange: (state: BackgroundVideoState) => void;
75
+ }
76
+
77
+ export interface BackgroundVideo {
78
+ readonly state: BackgroundVideoState;
79
+ /** Plays, as the reader's choice: it outlives a reload and a re-attach. */
80
+ play(): void;
81
+ /** Pauses, as the reader's choice: it outlives a reload and a re-attach. */
82
+ pause(): void;
83
+ /** Whichever of the two the video is not doing now. What a button wants. */
84
+ toggle(): void;
85
+ /**
86
+ * Takes over one `<video>`, and returns the undo.
87
+ *
88
+ * Applies the starting state — the reader's earlier choice if they made
89
+ * one, otherwise playing unless reduced motion is set — and starts
90
+ * listening to the element and to each of its sources' media queries. The
91
+ * undo stops listening and leaves the video as it is.
92
+ *
93
+ * Before `attach` and after the undo, `play`, `pause` and `toggle` do
94
+ * nothing: there is no element to act on. The reader's choice is kept
95
+ * across the gap, as `carousel` keeps its index.
96
+ */
97
+ attach(video: HTMLVideoElement): () => void;
98
+ }
99
+
100
+ const REDUCED_MOTION = "(prefers-reduced-motion: reduce)";
101
+
102
+ /**
103
+ * `HTMLMediaElement.NETWORK_NO_SOURCE`, as a literal.
104
+ *
105
+ * The state an element settles in when it has run out of sources to try —
106
+ * none matched, or each that did failed. Written out so nothing global is read
107
+ * at import, which is when a module like this must do nothing at all.
108
+ */
109
+ const NETWORK_NO_SOURCE = 3;
110
+
111
+ /**
112
+ * The media element events after which either half of the state may differ.
113
+ *
114
+ * `emptied` is a reload beginning, `loadstart` a source having been chosen.
115
+ * `error` is listened to separately, in the capture phase — see `attach`.
116
+ */
117
+ const EVENTS = ["play", "pause", "emptied", "loadstart"] as const;
118
+
119
+ export function backgroundVideo(
120
+ options: BackgroundVideoOptions
121
+ ): BackgroundVideo {
122
+ const { onChange } = options;
123
+
124
+ /** The element while attached, and nothing otherwise. */
125
+ let video: HTMLVideoElement | undefined;
126
+
127
+ /**
128
+ * What the reader chose, once they have chosen.
129
+ *
130
+ * Undefined until they press something, and then it outranks reduced
131
+ * motion: a reader who asked for less motion and then pressed play has
132
+ * answered the question for this video. Kept across a detach, so an element
133
+ * moved in the DOM comes back as the reader left it.
134
+ */
135
+ let chosen: boolean | undefined;
136
+
137
+ let state: BackgroundVideoState = { playing: false, playable: false };
138
+
139
+ /**
140
+ * Whether this attachment has announced yet.
141
+ *
142
+ * The first read of an attachment is announced even if it matches the last
143
+ * state, since the control has never been drawn from it. A flag rather
144
+ * than a forced call at the end of `attach`, because applying the starting
145
+ * state can fire `play` on its own — and then the control was told twice.
146
+ */
147
+ let fresh = false;
148
+
149
+ /** Reads the element, and announces if anything moved. */
150
+ const sync = (): void => {
151
+ if (video === undefined) return;
152
+ const next: BackgroundVideoState = {
153
+ playing: !video.paused,
154
+ // Both halves, because neither alone is true. `currentSrc` is empty
155
+ // until a source is chosen, but is not reliably cleared when a
156
+ // reload then finds none; `networkState` says an element has run
157
+ // out of sources, but passes through that state for a moment at
158
+ // the start of every reload too.
159
+ playable:
160
+ video.currentSrc !== "" &&
161
+ video.networkState !== NETWORK_NO_SOURCE,
162
+ };
163
+ if (
164
+ !fresh &&
165
+ next.playing === state.playing &&
166
+ next.playable === state.playable
167
+ ) {
168
+ return;
169
+ }
170
+ fresh = false;
171
+ state = next;
172
+ onChange(state);
173
+ };
174
+
175
+ /**
176
+ * Puts the element in the wanted state, and makes it stay there.
177
+ *
178
+ * `autoplay` is the half that stays. A reload resets the element to paused
179
+ * and then consults the attribute, so keeping it equal to what is wanted is
180
+ * what carries a reader's pause — or their play — across one.
181
+ */
182
+ const apply = (playing: boolean): void => {
183
+ if (video === undefined) return;
184
+ video.autoplay = playing;
185
+ if (playing) {
186
+ // Refused when a browser will not autoplay even a muted video —
187
+ // iOS in low-power mode is the one people meet. The element stays
188
+ // paused, and the control should say "play".
189
+ video.play().catch(() => sync());
190
+ } else {
191
+ video.pause();
192
+ }
193
+ };
194
+
195
+ const choose = (playing: boolean): void => {
196
+ if (video === undefined) return;
197
+ chosen = playing;
198
+ apply(playing);
199
+ };
200
+
201
+ return {
202
+ get state() {
203
+ return state;
204
+ },
205
+ play: () => choose(true),
206
+ pause: () => choose(false),
207
+ // From the element rather than from `chosen`. The control shows what
208
+ // the video is doing, so pressing it must do the opposite of that —
209
+ // including when the browser paused it rather than the reader.
210
+ toggle: () => {
211
+ if (video !== undefined) choose(video.paused);
212
+ },
213
+
214
+ attach(element) {
215
+ video = element;
216
+ fresh = true;
217
+ const listeners = new AbortController();
218
+ const { signal } = listeners;
219
+
220
+ // What decorative means, and what every browser requires before it
221
+ // will start a video nobody pressed play on.
222
+ element.muted = true;
223
+
224
+ for (const type of EVENTS) {
225
+ element.addEventListener(type, () => sync(), { signal });
226
+ }
227
+ // A `<source>` that is skipped or fails fires `error` at itself, and
228
+ // `error` does not bubble. The capture phase passes through the
229
+ // video on the way down, so this hears every source's failure —
230
+ // the last of which is how an element says it found nothing.
231
+ element.addEventListener("error", () => sync(), {
232
+ capture: true,
233
+ signal,
234
+ });
235
+
236
+ const sources = [...element.querySelectorAll("source")];
237
+
238
+ /**
239
+ * Which source a browser would choose now: the first whose media
240
+ * matches and whose type it can play.
241
+ *
242
+ * Compared before reloading rather than reloading on every change,
243
+ * because not every crossing changes the answer. A later source's
244
+ * query flipping while an earlier one still matches picks the same
245
+ * file, and a reload would only restart the loop from its first
246
+ * frame for nothing.
247
+ */
248
+ const choice = (): number =>
249
+ sources.findIndex(
250
+ (source) =>
251
+ (source.media === "" ||
252
+ matchMedia(source.media).matches) &&
253
+ (source.type === "" ||
254
+ element.canPlayType(source.type) !== "")
255
+ );
256
+
257
+ let chosenSource = choice();
258
+ // One listener per distinct query: two sources sharing one would
259
+ // otherwise reload twice for a single rotation.
260
+ const queries = new Set(
261
+ sources
262
+ .map((source) => source.media)
263
+ .filter((media) => media !== "")
264
+ );
265
+ for (const media of queries) {
266
+ matchMedia(media).addEventListener(
267
+ "change",
268
+ () => {
269
+ const next = choice();
270
+ if (next === chosenSource) return;
271
+ chosenSource = next;
272
+ // `emptied` and then `loadstart` follow, and `sync`
273
+ // hears both — so the control follows without a call
274
+ // from here.
275
+ element.load();
276
+ },
277
+ { signal }
278
+ );
279
+ }
280
+
281
+ apply(chosen ?? !matchMedia(REDUCED_MOTION).matches);
282
+ sync();
283
+
284
+ return () => {
285
+ listeners.abort();
286
+ video = undefined;
287
+ };
288
+ },
289
+ };
290
+ }
@@ -60,7 +60,7 @@
60
60
  *
61
61
  * The kinds are closed, and deliberately few:
62
62
  *
63
- * - `text` — free entry, folded and substring-matched. The search box.
63
+ * - `text` — free entry, folded and matched word by word. The search box.
64
64
  * - `choice` — one of a set, or none. Tabs, a `<select>`, a radio group. The
65
65
  * control picks one; an item may hold several and answer to any — see
66
66
  * `ItemValue`.
@@ -200,6 +200,29 @@ export interface SetOptions {
200
200
  export interface Filters<F extends FieldMap> {
201
201
  readonly state: FilterState<F>;
202
202
  readonly matched: ReadonlySet<string>;
203
+ /**
204
+ * The keys that survive every field *except* this one.
205
+ *
206
+ * The question a facet asks of its own options: how many results would
207
+ * picking each of them give, with everything else as it stands. Counted
208
+ * against `matched` instead, a dropdown whose value is already set could
209
+ * only ever offer that value — every other option would read zero, since
210
+ * the items holding it have just been filtered out by the very field being
211
+ * asked about.
212
+ *
213
+ * The same matcher as `matched`, folding and tokens included, rather than a
214
+ * second one in the caller. A facet count computed by a hand-written
215
+ * predicate drifts from the list it is counting the moment either changes —
216
+ * a count of four above a list of three — and nothing reports it.
217
+ *
218
+ * Computed on first ask and kept until the state next moves, so a render
219
+ * that asks once per option pays one pass per field rather than one per
220
+ * option. A field that is not narrowing anything answers with `matched`
221
+ * itself: without it, the rest is exactly what already matched.
222
+ *
223
+ * Safe to call from `onChange`, which never runs during construction.
224
+ */
225
+ matchedWithout(field: keyof F): ReadonlySet<string>;
203
226
  set<K extends keyof F>(
204
227
  field: K,
205
228
  value: StateValue<F[K]>,
@@ -315,6 +338,17 @@ export function filters<const F extends FieldMap>(
315
338
  let state = blank();
316
339
  let matched: ReadonlySet<string> = new Set(items.map((item) => item.key));
317
340
 
341
+ /**
342
+ * Each `text` field's query, folded and split, for the current state.
343
+ *
344
+ * Folded once per state rather than once per item. The other kinds compare
345
+ * values as they are, so there is nothing to prepare for them.
346
+ */
347
+ let queries = new Map<string, readonly string[]>();
348
+
349
+ /** `matchedWithout` answers for the current state. Emptied when it moves. */
350
+ const without = new Map<string, ReadonlySet<string>>();
351
+
318
352
  /**
319
353
  * Whether the URL is this instance's to write.
320
354
  *
@@ -324,61 +358,74 @@ export function filters<const F extends FieldMap>(
324
358
  */
325
359
  let attached = false;
326
360
 
361
+ /**
362
+ * Whether a field is narrowing anything in the current state.
363
+ *
364
+ * An empty query, no choice and a flag that is off all keep every item —
365
+ * the flag above all, which keeps everything rather than keeping the items
366
+ * that are *not* flagged. See `Field`.
367
+ */
368
+ const narrows = (name: keyof F & string): boolean => {
369
+ const kind = fields[name]?.kind;
370
+ if (kind === "text") return (queries.get(name) ?? []).length > 0;
371
+ if (kind === "choice") return state[name] !== "";
372
+ return state[name] === true;
373
+ };
374
+
375
+ /**
376
+ * Whether one item survives every field but `except`, in the current state.
377
+ *
378
+ * The one matcher. `matched` is this with nothing excepted and
379
+ * `matchedWithout` is this with one field excepted, so a facet count and
380
+ * the list it counts cannot disagree about what a match is.
381
+ */
382
+ function passes(item: FilterItem<F>, except?: keyof F & string): boolean {
383
+ for (const name of names) {
384
+ if (name === except || !narrows(name)) continue;
385
+ const kind = fields[name]?.kind;
386
+ if (kind === "text") {
387
+ // Every token must appear, not one run that must appear whole.
388
+ // A haystack is several things joined (a title, plus its
389
+ // category, plus its summary), and the order they were joined
390
+ // in is an accident of whoever wrote the template. Matching
391
+ // the query as one run made that accident load-bearing:
392
+ // against "Copper Kettle" in the Kitchen category, "kettle
393
+ // kitchen" found nothing while "kettle" alone worked, and the
394
+ // first is what someone narrowing a list types.
395
+ const tokens = queries.get(name) ?? [];
396
+ const haystack = haystacks.get(item.key)?.get(name) ?? "";
397
+ if (!tokens.every((token) => haystack.includes(token))) {
398
+ return false;
399
+ }
400
+ } else if (kind === "choice") {
401
+ // An item may sit in several categories while the control
402
+ // still picks one — see `ItemValue`. One value is the same
403
+ // question asked of a set of one.
404
+ const wanted = state[name] as string;
405
+ const held = item.values[name];
406
+ const holds = Array.isArray(held)
407
+ ? held.includes(wanted)
408
+ : held === wanted;
409
+ if (!holds) return false;
410
+ } else if (item.values[name] !== true) {
411
+ return false;
412
+ }
413
+ }
414
+ return true;
415
+ }
416
+
327
417
  function recompute(): void {
328
- // The query folded once per render rather than once per item. The other
329
- // kinds compare values as they are, so there is nothing to prepare.
330
- // Split into tokens, all of which must appear — not one substring that
331
- // must appear whole. A haystack is several things joined (a title, plus
332
- // its category, plus its summary), and the order they were joined in is
333
- // an accident of whoever wrote the template. Matching the query as one
334
- // run made that accident load-bearing: against "Copper Kettle" in the
335
- // Kitchen category, "kettle kitchen" found nothing while "kettle" alone
336
- // worked, and the first is what someone narrowing a list types.
337
- const queries = new Map<string, string[]>();
418
+ queries = new Map();
338
419
  for (const name of names) {
339
420
  if (fields[name]?.kind !== "text") continue;
340
421
  const folded = fold(state[name] as string);
341
422
  queries.set(name, folded === "" ? [] : folded.split(/\s+/));
342
423
  }
424
+ without.clear();
343
425
 
344
426
  const next = new Set<string>();
345
427
  for (const item of items) {
346
- let hit = true;
347
- for (const name of names) {
348
- const kind = fields[name]?.kind;
349
- if (kind === "text") {
350
- const tokens = queries.get(name) ?? [];
351
- if (tokens.length === 0) continue;
352
- const haystack = haystacks.get(item.key)?.get(name) ?? "";
353
- if (!tokens.every((token) => haystack.includes(token))) {
354
- hit = false;
355
- break;
356
- }
357
- } else if (kind === "choice") {
358
- const wanted = state[name] as string;
359
- if (wanted === "") continue;
360
- // An item may sit in several categories while the control
361
- // still picks one — see `ItemValue`. One value is the same
362
- // question asked of a set of one.
363
- const held = item.values[name];
364
- const holds = Array.isArray(held)
365
- ? held.includes(wanted)
366
- : held === wanted;
367
- if (!holds) {
368
- hit = false;
369
- break;
370
- }
371
- } else {
372
- // A flag off is not a filter: it keeps everything, rather
373
- // than keeping the items that are *not* flagged. See `Field`.
374
- if (state[name] !== true) continue;
375
- if (item.values[name] !== true) {
376
- hit = false;
377
- break;
378
- }
379
- }
380
- }
381
- if (hit) next.add(item.key);
428
+ if (passes(item)) next.add(item.key);
382
429
  }
383
430
  matched = next;
384
431
  }
@@ -467,6 +514,20 @@ export function filters<const F extends FieldMap>(
467
514
  return matched;
468
515
  },
469
516
 
517
+ matchedWithout(field) {
518
+ const name = field as keyof F & string;
519
+ if (!narrows(name)) return matched;
520
+ const cached = without.get(name);
521
+ if (cached !== undefined) return cached;
522
+
523
+ const rest = new Set<string>();
524
+ for (const item of items) {
525
+ if (passes(item, name)) rest.add(item.key);
526
+ }
527
+ without.set(name, rest);
528
+ return rest;
529
+ },
530
+
470
531
  set(field, value, setOptions) {
471
532
  const name = field as keyof F & string;
472
533
  // Compared as everything downstream will read it, not as it arrived.
@@ -0,0 +1,160 @@
1
+ /**
2
+ * A YouTube video that loads nothing from YouTube until a reader asks for it.
3
+ *
4
+ * In `astro/` because it listens for a click and makes an element, which the
5
+ * core is type-checked without.
6
+ *
7
+ * An embedded player is the heaviest thing most pages carry that nobody asked
8
+ * for: the iframe fetches the player, its scripts and its tracking on load, once
9
+ * per video, whether or not anyone presses play. So the page renders a poster
10
+ * and a button — the project's own markup, drawn however it likes — and this
11
+ * swaps the player in on the click, with `autoplay=1` so that the one press the
12
+ * reader already made is the one that starts it.
13
+ *
14
+ * ```ts
15
+ * const trailer = youtubeEmbed({
16
+ * id: "dQw4w9WgXcQ",
17
+ * title: "Trailer: The Blue Room",
18
+ * frame: (iframe) => {
19
+ * iframe.className = "absolute inset-0 h-full w-full";
20
+ * },
21
+ * });
22
+ *
23
+ * const detach = trailer.attach(button, poster);
24
+ * ```
25
+ *
26
+ * **It draws one element, and decides nothing about how it looks.** Unlike
27
+ * `carousel` this has to put something into the page — the player is the
28
+ * behaviour — so it is the second exception to the rule in
29
+ * `docs/client-scripts.md`, after `filters-view`, and it is drawn as narrowly.
30
+ * What it sets on the iframe is what every embed needs and has no taste in it:
31
+ * the address, the title, the permissions, full screen, the referrer policy.
32
+ * Classes, size and position are the caller's, through `frame`.
33
+ *
34
+ * Kept apart from `videoObject` in `jsonld/video.ts` although both are about a
35
+ * YouTube video: that one describes it to a crawler at build time, this plays
36
+ * it in a browser, and the only thing they share is an id the caller already
37
+ * holds. They do not even want the same host — Google reads the `youtube.com`
38
+ * player as `embedUrl`, and a reader is better served by `youtube-nocookie.com`.
39
+ */
40
+
41
+ export interface YouTubeEmbedOptions {
42
+ /**
43
+ * The video's id: what follows `v=` in a watch link, or `youtu.be/`.
44
+ *
45
+ * Checked at construction, because the tempting mistake is pasting the
46
+ * whole link, and that fails in the one place nobody looks — inside the
47
+ * player, after the click, as YouTube's own "video unavailable".
48
+ */
49
+ readonly id: string;
50
+ /**
51
+ * The iframe's title, which is its accessible name.
52
+ *
53
+ * Required and refused when blank: a frame without one is announced as
54
+ * "frame" and nothing else, and a screen-reader user tabbing into the
55
+ * player has no way to tell which video it is.
56
+ */
57
+ readonly title: string;
58
+ /**
59
+ * Anything the project wants on the iframe — classes, above all.
60
+ *
61
+ * Called once, after everything this module sets and before the iframe is
62
+ * in the page, so what it writes is the last word and nothing is drawn
63
+ * unstyled first.
64
+ */
65
+ readonly frame?: (iframe: HTMLIFrameElement) => void;
66
+ }
67
+
68
+ export interface YouTubeEmbed {
69
+ /**
70
+ * Swaps `container`'s contents for the player when `trigger` is clicked,
71
+ * and returns the undo.
72
+ *
73
+ * Two elements rather than one, because they are different things: the
74
+ * trigger is the button a reader presses, and the container is what the
75
+ * player replaces — usually the poster and that same button together. A
76
+ * click anywhere in the container would be simpler and wrong, since a
77
+ * caption link or a credit inside it would then start the video.
78
+ *
79
+ * Once only. The button is gone with the poster, and a second player in
80
+ * the same box would be two videos playing over each other.
81
+ *
82
+ * The undo removes the listener and nothing else. A player already loaded
83
+ * stays where it is — taking it out would stop a video someone is
84
+ * watching, and a page swap removes it anyway.
85
+ */
86
+ attach(trigger: HTMLElement, container: Element): () => void;
87
+ }
88
+
89
+ /**
90
+ * The shape of a YouTube video id.
91
+ *
92
+ * Eleven characters from the URL-safe base64 alphabet, which is what every id
93
+ * has been. A shape check rather than a lookup: it cannot say the video exists,
94
+ * only that what was handed over is an id and not a link.
95
+ */
96
+ const VIDEO_ID = /^[\w-]{11}$/;
97
+
98
+ /**
99
+ * The permissions YouTube's own embed code asks for.
100
+ *
101
+ * `autoplay` is the one that matters here: without it the `autoplay=1` in the
102
+ * address is refused, and the reader who pressed play gets a second play
103
+ * button to press.
104
+ */
105
+ const ALLOW =
106
+ "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share";
107
+
108
+ export function youtubeEmbed(options: YouTubeEmbedOptions): YouTubeEmbed {
109
+ const { id, title, frame } = options;
110
+
111
+ if (!VIDEO_ID.test(id)) {
112
+ throw new Error(
113
+ `"${id}" is not a YouTube video id — expected the id alone, as it appears after "v=" or "youtu.be/", not a link.`
114
+ );
115
+ }
116
+ if (title.trim() === "") {
117
+ throw new Error(
118
+ `The YouTube embed for "${id}" has no title. It is the frame's accessible name — without one a screen reader announces "frame" and nothing else.`
119
+ );
120
+ }
121
+
122
+ // `youtube-nocookie.com` rather than `youtube.com`: the same player, which
123
+ // sets no cookie until the video is played — and here it is only ever
124
+ // loaded to be played, so nothing is lost by it.
125
+ const src = `https://www.youtube-nocookie.com/embed/${id}?autoplay=1`;
126
+
127
+ return {
128
+ attach(trigger, container) {
129
+ const listeners = new AbortController();
130
+
131
+ trigger.addEventListener(
132
+ "click",
133
+ () => {
134
+ const iframe =
135
+ container.ownerDocument.createElement("iframe");
136
+ iframe.src = src;
137
+ iframe.title = title;
138
+ iframe.allow = ALLOW;
139
+ iframe.allowFullscreen = true;
140
+ // Stated rather than inherited. YouTube refuses to play an
141
+ // embed that arrives with no referrer at all, and a site
142
+ // sending `Referrer-Policy: no-referrer` would otherwise get
143
+ // a player that loads and shows an error in place of the
144
+ // video. This is the policy YouTube's own embed code sets.
145
+ iframe.referrerPolicy = "strict-origin-when-cross-origin";
146
+ frame?.(iframe);
147
+
148
+ container.replaceChildren(iframe);
149
+ // The button that had focus has just left the page, which
150
+ // drops a keyboard reader back at the top of the document.
151
+ // The player is where they were, and what they asked for.
152
+ iframe.focus();
153
+ },
154
+ { once: true, signal: listeners.signal }
155
+ );
156
+
157
+ return () => listeners.abort();
158
+ },
159
+ };
160
+ }