@escape-game-over/atlas 0.1.15 → 0.1.17

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.
@@ -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.