@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.
package/README.md CHANGED
@@ -296,16 +296,18 @@ const careers: WhenEnabled<typeof site, "careers", Careers> = { ats: "…" }; /
296
296
  ```jsonc
297
297
  "exports": {
298
298
  ".": "./src/index.ts",
299
- "./astro": "./src/astro/index.ts",
300
- "./astro/images": "./src/astro/images.ts",
301
- "./astro/carousel": "./src/astro/carousel.ts",
302
- "./astro/consent": "./src/astro/consent.ts",
303
- "./astro/dev-log": "./src/astro/dev-log.ts",
304
- "./astro/dom": "./src/astro/dom.ts",
305
- "./astro/element": "./src/astro/element.ts",
306
- "./astro/filters": "./src/astro/filters.ts",
307
- "./astro/meta-tags": "./src/astro/MetaTags.astro",
308
- "./astro/filters-view": "./src/astro/filters-view.ts"
299
+ "./astro": "./src/astro/index.ts",
300
+ "./astro/images": "./src/astro/images.ts",
301
+ "./astro/background-video": "./src/astro/background-video.ts",
302
+ "./astro/carousel": "./src/astro/carousel.ts",
303
+ "./astro/consent": "./src/astro/consent.ts",
304
+ "./astro/dev-log": "./src/astro/dev-log.ts",
305
+ "./astro/dom": "./src/astro/dom.ts",
306
+ "./astro/element": "./src/astro/element.ts",
307
+ "./astro/filters": "./src/astro/filters.ts",
308
+ "./astro/filters-view": "./src/astro/filters-view.ts",
309
+ "./astro/meta-tags": "./src/astro/MetaTags.astro",
310
+ "./astro/youtube": "./src/astro/youtube.ts"
309
311
  }
310
312
  ```
311
313
 
@@ -322,7 +324,8 @@ opposite: it runs inside the build, from a page, and is unusable from a config.
322
324
  Merging them breaks whichever caller loads first.
323
325
 
324
326
  The rest of `astro/` is the browser half — `carousel`, `filters`, `filters-view`,
325
- `consent`, `element`, `dom`, `dev-log` — which runs in a reader's browser rather
327
+ `youtube`, `background-video`, `consent`, `element`, `dom`, `dev-log` — which
328
+ runs in a reader's browser rather
326
329
  than in the build, and is separate again for the same reason: none of it can be
327
330
  reached from a config, and none of it draws. See
328
331
  [`docs/client-scripts.md`](docs/client-scripts.md).
@@ -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,25 @@ 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 |
39
+ | `./astro/filters-view` | the two DOM writes every filtered list turned out to share | finding the elements, and everything else drawn |
40
+ | `./astro/markup` | the attributes a template writes and a script reads, typed | naming the roles, and everything done with them |
41
+ | `./astro/youtube` | the swap from poster to player, on the click and not before | the poster, the button, the iframe's classes |
42
+ | `./astro/background-video` | playing or paused, playable or not, and which cut is loaded | the play/pause control, its icons and its label |
43
+ | `./astro/consent` | remembering an answer, expiring it, handing it to Google | the banner — its wording, its buttons, its law |
44
+ | `./astro/element` | the two lifetimes a custom element has, and one abort signal | what the element is and does |
45
+ | `./astro/dom` | scoped `one`/`all` lookups, typed | the selectors |
46
+ | `./astro/dev-log` | a panel a failed wiring can announce itself in, in dev only | calling it behind `import.meta.env.DEV` |
37
47
 
38
48
  `./astro/consent` is the only one of these with a build-time half. Its
39
49
  `consentApplies()` reports whether there is a Google tag to consent to, which a
@@ -53,8 +63,10 @@ the options, and when one drifts nothing reports it — a listener added without
53
63
  its twin simply survives teardown.
54
64
 
55
65
  ```ts
56
- const detach = slider.attach(viewport); // carousel: one element
57
- const detach = list.attach(); // filters: no element, only popstate
66
+ const detach = slider.attach(viewport); // carousel: one element
67
+ const detach = list.attach(); // filters: no element, only popstate
68
+ const detach = loop.attach(video); // background-video: the <video>
69
+ const detach = trailer.attach(button, box); // youtube: what is pressed, what it replaces
58
70
  ```
59
71
 
60
72
  `AtlasElement` says the same thing in the shape a custom element needs, because
@@ -111,7 +123,7 @@ than an item that silently never matches.
111
123
 
112
124
  | Kind | State | Item value | Matches when | In the URL | History |
113
125
  | -------- | --------------- | --------------- | ------------------------------------------------ | ------------------------------- | ------- |
114
- | `text` | the query | searchable text | folded query is a substring; empty keeps all | `?q=…`, dropped if empty | replace |
126
+ | `text` | the query | searchable text | every folded query word appears; empty keeps all | `?q=…`, dropped if empty | replace |
115
127
  | `choice` | one value, `""` | one, or several | the item holds the chosen one; `""` keeps all | `?category=…`, dropped if empty | push |
116
128
  | `flag` | boolean | boolean | off keeps all; on keeps only items that carry it | `?featured=1`, absent when off | push |
117
129
 
@@ -159,6 +171,41 @@ parameters or comma-joined) that nothing has needed yet.
159
171
 
160
172
  `param` is optional. A field without one is state-only and never reaches the URL.
161
173
 
174
+ ### Facet counts: `matchedWithout`
175
+
176
+ A dropdown that says how many results each option would give needs a different
177
+ set from `matched`: every field *except its own*. Counted against `matched`, a
178
+ facet whose value is already picked can only ever offer that value — every other
179
+ option reads zero, because the items holding them were just filtered out by the
180
+ very field being asked about.
181
+
182
+ ```ts
183
+ onChange({ matched }) {
184
+ const rest = list.matchedWithout("country");
185
+ for (const option of countryOptions) {
186
+ const value = option.dataset.choice;
187
+ const count = rows.filter(
188
+ (row) => rest.has(row.key) && row.country === value
189
+ ).length;
190
+ // …the project writes `count` wherever its design puts it
191
+ }
192
+ }
193
+ ```
194
+
195
+ It runs the list's own matcher — folding, tokens, several-valued choices, flags —
196
+ so a count cannot drift from the list beneath it. That drift is not
197
+ hypothetical: a consumer wrote its own predicate for these counts, folding
198
+ without trimming and matching the query as one run, and it agreed with the list
199
+ only until the list learned to match word by word. Nothing reported it; the
200
+ counts were simply wrong.
201
+
202
+ Computed on first ask and kept until the state next moves, so a render that asks
203
+ once per option pays one pass per field. A field that is not narrowing anything —
204
+ an empty query, no choice, a flag that is off — answers with `matched` itself.
205
+ Grouping the survivors by option stays with the caller, which already holds each
206
+ item's values; it is safe to call from `onChange`, which never runs during
207
+ construction.
208
+
162
209
  ### The four URL decisions
163
210
 
164
211
  1. **Replace while typing, push on a choice.** An input emits an event per
@@ -316,17 +363,208 @@ filters({ … }) + searchBox + hideEmpty // the roll-up gone too
316
363
  A page whose markup wants a class instead of `hidden`, or removal from the DOM,
317
364
  skips the helper and writes it in `onChange`. Nothing degrades.
318
365
 
366
+ ## `markup`
367
+
368
+ A script that enhances server-rendered markup has to agree with the template on
369
+ attribute names, and nothing checks that agreement. `data-filter-regoin` in the
370
+ template and `dataset.filterRegion` in the script compile, build and ship, and
371
+ the facet simply never matches: the page reads as a filter with no results, not
372
+ as a typo. A shared constant pins one name. `markup` pins every name a script
373
+ and its template share, and the values in them.
374
+
375
+ ```ts
376
+ // network-markup.ts — imported by the template and by the script
377
+ export const row = markup("network-row", {
378
+ key: { kind: "text" },
379
+ status: { kind: "choice", of: ["open", "soon"] },
380
+ state: { kind: "text", optional: true },
381
+ });
382
+ export const search = markup("network-search");
383
+ ```
384
+
385
+ ```astro
386
+ <a {...row.attrs({ key, status: "open", state: store.state })}>
387
+ <input {...search.attrs()} type="search">
388
+ ```
389
+
390
+ ```ts
391
+ for (const { element, values } of row.all(this)) {
392
+ values.status; // "open" | "soon"
393
+ }
394
+ const input = search.require<HTMLInputElement>(this).element;
395
+ ```
396
+
397
+ - **Names are derived, never spelled.** `data-network-row` marks the element and
398
+ `data-network-row-status` holds a field. A field that does not exist is a
399
+ compile error in the template and in the script.
400
+ - **Values are typed on the way in.** `attrs` takes each field's type: a missing
401
+ required field, or a status outside its list, fails `astro check`. It also
402
+ checks at run time, because this runs at build time and a value can arrive as
403
+ a plain `string` from an env var or a cast; a throw there fails the build
404
+ rather than shipping a page whose script throws on every visit.
405
+ - **And parsed on the way out.** `read`, `all`, `one` and `require` parse every
406
+ field and throw, naming the element and the attribute, when one is missing or
407
+ malformed. A malformed match fails the whole `all` rather than being skipped:
408
+ a list that quietly loses a row is the failure this exists to prevent.
409
+
410
+ The kinds are data, not builders, for the reason `filters` gives:
411
+ `text`, `number`, `list`, `choice` and `flag`, the first four optionally
412
+ `optional`. A `flag` is off by being absent, never `"false"`, so a stylesheet can
413
+ select it with a bare attribute.
414
+
415
+ `write` sets fields back onto an element, for state a stylesheet reads — a
416
+ dropdown that is blocked — so the script uses the names the template did.
417
+
418
+ **What it still cannot reach is the stylesheet.** Tailwind generates CSS from
419
+ class names it finds as literals in the source, so a variant keyed on one of
420
+ these attributes has to spell it: `group-data-network-dropdown-blocked/dd:`.
421
+ That is the one place a rename is not a compile error. It is also the one place
422
+ a drift is visible, since the style stops applying, where a script reading the
423
+ wrong attribute fails silently. `attribute(field)` returns the name for the
424
+ consumers that can take a computed one, such as an `attributeFilter`.
425
+
426
+ It keeps the rule at the top of this page in the only reading that admits a
427
+ lookup: it finds only elements carrying its own marker, writes only its own
428
+ attributes, and sets no class, `aria` or `hidden`. The names are the project's,
429
+ so nothing in this package has to be matched.
430
+
431
+ ## `youtube`
432
+
433
+ A YouTube video that loads nothing from YouTube until a reader asks for it. The
434
+ page renders a poster and a button, drawn however the project likes; the click
435
+ swaps the player in, with `autoplay=1` so the one press already made is the one
436
+ that starts it.
437
+
438
+ ```ts
439
+ const trailer = youtubeEmbed({
440
+ id: box.dataset.trailer,
441
+ title: box.dataset.trailerTitle,
442
+ frame: (iframe) => {
443
+ iframe.className = "absolute inset-0 h-full w-full";
444
+ },
445
+ });
446
+
447
+ const detach = trailer.attach(button, box);
448
+ ```
449
+
450
+ An embedded player is the heaviest thing most pages carry that nobody asked for:
451
+ the iframe fetches the player, its scripts and its tracking on load, per video,
452
+ whether or not anyone presses play. Deferring it is the whole point, and the rest
453
+ is what every embed needs and has no taste in:
454
+
455
+ - **`youtube-nocookie.com`**, the same player without a cookie until it plays —
456
+ and here it is only ever loaded to be played.
457
+ - **A title**, which is the frame's accessible name. Refused when blank: a
458
+ frame without one is announced as "frame" and nothing else.
459
+ - **`allow` with `autoplay`**, or the `autoplay=1` is refused and the reader who
460
+ pressed play gets a second button to press. The list is YouTube's own.
461
+ - **`allowfullscreen`**, and **`referrerpolicy="strict-origin-when-cross-origin"`**
462
+ stated rather than inherited: YouTube will not play an embed that arrives with
463
+ no referrer, so a site sending `Referrer-Policy: no-referrer` would otherwise
464
+ get a player showing an error where the video should be.
465
+ - **Focus moves to the player.** The button that had it has just left the page,
466
+ which would drop a keyboard reader back at the top of the document.
467
+
468
+ `frame` is called after all of that and before the iframe is in the page, so its
469
+ word is the last one and nothing is drawn unstyled first.
470
+
471
+ **The id is checked at construction.** The tempting mistake is pasting the whole
472
+ link, and that fails in the one place nobody looks — inside the player, after the
473
+ click, as YouTube's own "video unavailable". A shape check cannot say the video
474
+ exists, only that what was handed over is an id and not a URL.
475
+
476
+ **Two elements, and a click on the first only.** The trigger is what a reader
477
+ presses; the container is what the player replaces, usually the poster and that
478
+ same button together. Listening on the whole container would be simpler and
479
+ wrong, since a caption link inside it would start the video. Once only, and the
480
+ undo removes the listener and nothing else — a player already loaded stays,
481
+ because taking it out would stop a video someone is watching.
482
+
483
+ It is kept apart from `videoObject` in `jsonld/video.ts`. That one describes the
484
+ video to a crawler at build time and this plays it in a browser; they share
485
+ only an id the caller already holds, and do not even want the same host — Google
486
+ reads the `youtube.com` player as `embedUrl`.
487
+
488
+ ## `background-video`
489
+
490
+ A muted, looping, decorative `<video>` — a hero loop. It owns whether the video
491
+ plays, whether there is anything to play, and which cut is loaded; the project
492
+ draws its own play/pause control from what `onChange` reports and points the
493
+ control at `toggle`.
494
+
495
+ ```ts
496
+ const loop = backgroundVideo({
497
+ onChange: ({ playing, playable }) => {
498
+ button.hidden = !playable;
499
+ button.setAttribute("aria-label", playing ? pauseLabel : playLabel);
500
+ playIcon.toggleAttribute("hidden", playing);
501
+ pauseIcon.toggleAttribute("hidden", !playing);
502
+ },
503
+ });
504
+
505
+ button.addEventListener("click", () => loop.toggle(), { signal });
506
+ const detach = loop.attach(video);
507
+ ```
508
+
509
+ What it owns is what every hero loop needs and most get quietly wrong:
510
+
511
+ - **Reduced motion starts it paused.** A reader who asked their system for less
512
+ motion should not get a full-width loop behind the headline.
513
+ - **A way to stop it.** WCAG 2.2.2 asks for one on anything that moves for more
514
+ than five seconds, and a loop moves forever. The control, its icons and its
515
+ label are the project's; `playing` is what they show.
516
+ - **No control over nothing.** A `<source media>` that matches no viewport
517
+ leaves the element empty. `playable` says so — `currentSrc` empty, or the
518
+ element out of sources — and the caller hides the control.
519
+ - **The right cut after a rotation.** A browser chooses among `<source media>`
520
+ once, when the element loads, and never again, so a phone turned sideways
521
+ keeps the portrait cut. This listens to every source's query and calls
522
+ `load()` when the answer changes — *the answer*, not any crossing: a later
523
+ source's query flipping while an earlier one still matches chooses the same
524
+ file, and a reload would restart the loop from its first frame for nothing.
525
+ - **The reader's pause surviving that reload.** A reload resets the element to
526
+ its markup, and markup that says `autoplay` starts again the video they had
527
+ stopped. `autoplay` is kept equal to what is wanted, which is what a reload
528
+ consults.
529
+
530
+ `toggle` reads the element rather than the last press, because the control shows
531
+ what the video is doing and pressing it must do the opposite — including when
532
+ the browser paused it rather than the reader. The reader's choice outranks
533
+ reduced motion once made, and survives a detach, so an element moved in the DOM
534
+ comes back as the reader left it.
535
+
536
+ The markup wants `muted`, `loop` and `playsinline`, and — being decoration —
537
+ `aria-hidden="true"` and `tabindex="-1"`. `autoplay` is better left out: this
538
+ starts the video itself, and without this script there is no control to stop it
539
+ with, while with the attribute a reduced-motion reader sees the first frames
540
+ move before this pauses them. Left in, everything still works. `muted` is also
541
+ set on attach, since no browser will start an unmuted video nobody pressed play
542
+ on.
543
+
319
544
  ## Testing
320
545
 
321
- `filters` and `filters-view` are the only modules here with tests, and both for
322
- the same reason: neither finds an element. `filters` takes item text as data and
323
- `filters-view` takes elements it was handed, so both run against stubs rather
324
- than a document. `tests/filters.test.ts` fakes `location`, `history` and one
325
- `popstate` listener, covering the matching, the four URL decisions and
326
- attach/detach; `tests/filters-view.test.ts` fakes plain objects with a `hidden`
327
- property, pins the write-back guard by counting *writes* — so deleting the guard
328
- fails rather than passing on an identical value — and pins the nesting order in
329
- both directions.
546
+ None of these tests has a document, and none needs one: every module under test
547
+ is handed what it touches rather than finding it, so a stub object with the
548
+ right properties is a faithful stand-in.
549
+
550
+ - `tests/filters.test.ts` fakes `location`, `history` and one `popstate`
551
+ listener, covering the matching, `matchedWithout`, the four URL decisions and
552
+ attach/detach.
553
+ - `tests/filters-view.test.ts` fakes plain objects with a `hidden` property,
554
+ pins the write-back guard by counting *writes* — so deleting the guard fails
555
+ rather than passing on an identical value — and pins the nesting order in
556
+ both directions.
557
+ - `tests/youtube.test.ts` fakes the trigger, and a container whose
558
+ `ownerDocument` makes the iframe — which is why the module reaches the
559
+ document through the container rather than the global.
560
+ - `tests/background-video.test.ts` stubs `matchMedia` with `vi.stubGlobal`, with
561
+ `matches` read live as a browser's is, and fakes the `<video>` down to
562
+ `paused`, `currentSrc`, `networkState`, `autoplay`, `play`, `pause` and `load`,
563
+ including what resource selection does after a reload.
564
+ - `tests/markup.test.ts` fakes elements down to four attribute methods and a
565
+ root that answers bare attribute selectors, and round-trips every field kind
566
+ through `attrs` and `read`. `type-tests/markup.ts` pins the half that is a
567
+ compile error.
330
568
 
331
569
  `carousel`, `consent`, `element` and `dom` have none. They need a real DOM and
332
570
  this package carries no environment for one; adding `happy-dom` as a dev
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.17",
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,9 @@
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/markup": "./src/astro/markup.ts",
27
+ "./astro/meta-tags": "./src/astro/MetaTags.astro",
28
+ "./astro/youtube": "./src/astro/youtube.ts"
26
29
  },
27
30
  "bin": {
28
31
  "atlas": "bin/use-project.mjs"
@@ -53,8 +56,8 @@
53
56
  "typescript": ">=5"
54
57
  },
55
58
  "devDependencies": {
56
- "@biomejs/biome": "2.5.12",
57
- "@types/node": "26.5.0",
59
+ "@biomejs/biome": "2.5.13",
60
+ "@types/node": "26.5.1",
58
61
  "@vitest/coverage-istanbul": "5.0.0",
59
62
  "astro": "7.3.2",
60
63
  "typescript": "6.0.3",