polite-media 0.3.0

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/LICENSE +21 -0
  3. package/README.md +675 -0
  4. package/dist/coordinator.d.ts +231 -0
  5. package/dist/coordinator.d.ts.map +1 -0
  6. package/dist/coordinator.js +1017 -0
  7. package/dist/coordinator.js.map +1 -0
  8. package/dist/env.d.ts +30 -0
  9. package/dist/env.d.ts.map +1 -0
  10. package/dist/env.js +49 -0
  11. package/dist/env.js.map +1 -0
  12. package/dist/events.d.ts +70 -0
  13. package/dist/events.d.ts.map +1 -0
  14. package/dist/events.js +42 -0
  15. package/dist/events.js.map +1 -0
  16. package/dist/image.css +1 -0
  17. package/dist/image.d.ts +45 -0
  18. package/dist/image.d.ts.map +1 -0
  19. package/dist/image.js +127 -0
  20. package/dist/image.js.map +1 -0
  21. package/dist/layer.css +1 -0
  22. package/dist/reveal.d.ts +34 -0
  23. package/dist/reveal.d.ts.map +1 -0
  24. package/dist/reveal.js +72 -0
  25. package/dist/reveal.js.map +1 -0
  26. package/dist/sources.d.ts +22 -0
  27. package/dist/sources.d.ts.map +1 -0
  28. package/dist/sources.js +148 -0
  29. package/dist/sources.js.map +1 -0
  30. package/dist/targets.d.ts +20 -0
  31. package/dist/targets.d.ts.map +1 -0
  32. package/dist/targets.js +17 -0
  33. package/dist/targets.js.map +1 -0
  34. package/dist/video.css +1 -0
  35. package/dist/video.d.ts +15 -0
  36. package/dist/video.d.ts.map +1 -0
  37. package/dist/video.js +17 -0
  38. package/dist/video.js.map +1 -0
  39. package/dist/warm.d.ts +13 -0
  40. package/dist/warm.d.ts.map +1 -0
  41. package/dist/warm.js +12 -0
  42. package/dist/warm.js.map +1 -0
  43. package/dist/warming.d.ts +62 -0
  44. package/dist/warming.d.ts.map +1 -0
  45. package/dist/warming.js +136 -0
  46. package/dist/warming.js.map +1 -0
  47. package/package.json +96 -0
  48. package/src/coordinator.ts +1337 -0
  49. package/src/env.ts +56 -0
  50. package/src/events.ts +78 -0
  51. package/src/image.css +74 -0
  52. package/src/image.ts +160 -0
  53. package/src/layer.css +60 -0
  54. package/src/reveal.ts +75 -0
  55. package/src/sources.ts +164 -0
  56. package/src/targets.ts +27 -0
  57. package/src/video.css +74 -0
  58. package/src/video.ts +32 -0
  59. package/src/warm.ts +12 -0
  60. package/src/warming.ts +162 -0
package/README.md ADDED
@@ -0,0 +1,675 @@
1
+ # polite-media
2
+
3
+ Background video, image reveals and next-page image warming. No dependencies, no framework.
4
+
5
+ Three independent entry points, imported separately, because they share almost
6
+ nothing. Bundled, minified and gzipped, which is what `pnpm size` enforces:
7
+
8
+ | | JavaScript | stylesheet | total |
9
+ | -------------------- | ---------- | ---------- | ---------- |
10
+ | `polite-media/video` | 3,926 B | 194 B | **4.1 KB** |
11
+ | `polite-media/image` | 630 B | 202 B | **830 B** |
12
+ | `polite-media/warm` | 615 B | none | **615 B** |
13
+
14
+ `polite-media/layer.css` is a third, optional stylesheet: **137 B** for the
15
+ standard poster-over-video stack. An image-only page never pays for the video
16
+ coordinator.
17
+
18
+ It doesn't flash, doesn't hog the decoder, doesn't eat data on a metered
19
+ connection, doesn't ignore reduced motion, and doesn't autoplay without giving
20
+ anyone a way to stop it.
21
+
22
+ ```sh
23
+ npm install polite-media
24
+ ```
25
+
26
+ ```html
27
+ <!-- data-polite-media goes on the video's direct parent -->
28
+ <div class="your-own-box" data-polite-media>
29
+ <img src="poster.avif" alt="" />
30
+ <!-- decorative: the video is not in the tab order, the poster's alt carries any meaning -->
31
+ <video muted loop playsinline preload="none" tabindex="-1" aria-hidden="true">
32
+ <source src="hero.mp4" type="video/mp4" />
33
+ </video>
34
+ </div>
35
+
36
+ <!-- looping video needs a way to stop it: WCAG 2.2.2. You style it; this ships no CSS -->
37
+ <button type="button" data-polite-pause-control aria-pressed="false">Pause background video</button>
38
+ ```
39
+
40
+ ```js
41
+ import { register } from 'polite-media/video';
42
+ import 'polite-media/video.css';
43
+
44
+ register(document.querySelector('[data-polite-media] video'));
45
+ ```
46
+
47
+ Images are the other half, and a separate import:
48
+
49
+ ```html
50
+ <div class="card">
51
+ <!-- needs its own background-color -->
52
+ <img src="photo.avif" alt="" loading="lazy" data-polite-reveal />
53
+ </div>
54
+ ```
55
+
56
+ ```js
57
+ import { revealImages } from 'polite-media/image';
58
+ import 'polite-media/image.css';
59
+
60
+ revealImages('.card img');
61
+ ```
62
+
63
+ ## Recipes
64
+
65
+ Each is a demo page in this repo that the end-to-end suite drives, so the markup
66
+ is known to work rather than merely plausible.
67
+
68
+ ### A hero video behind a title
69
+
70
+ Load the poster eagerly; it is the Largest Contentful Paint element. Nothing
71
+ needs configuring, since the video fetches once the page has loaded and never
72
+ competes with the page's own bytes.
73
+
74
+ If Lighthouse scores matter to you, add `{ startWhen: 'interaction' }`. The video
75
+ then waits for the visitor's first scroll, tap or keypress, which keeps it out of
76
+ the LCP measurement entirely.
77
+
78
+ ```html
79
+ <div class="hero" data-polite-media>
80
+ <img src="poster.avif" alt="" fetchpriority="high" decoding="async" />
81
+ <video muted loop playsinline preload="none" tabindex="-1" aria-hidden="true">
82
+ <source src="hero.mp4" type="video/mp4" />
83
+ </video>
84
+ </div>
85
+
86
+ <button type="button" data-polite-pause-control aria-pressed="false">Pause background video</button>
87
+ ```
88
+
89
+ ```js
90
+ import { register } from 'polite-media/video';
91
+ import 'polite-media/video.css';
92
+ import 'polite-media/layer.css'; // optional: stacks poster over video so you needn't
93
+
94
+ register(document.querySelector('.hero video'));
95
+ ```
96
+
97
+ ### A grid or feed of videos
98
+
99
+ Cap how many decode at once, and give the observer the card rather than the
100
+ video, which is what you want when the video is `inset: 0` inside it.
101
+
102
+ ```js
103
+ import { configure, register } from 'polite-media/video';
104
+ import 'polite-media/video.css';
105
+
106
+ configure({ atOnce: 1, prefetchMargin: '200px' });
107
+
108
+ for (const card of document.querySelectorAll('.card')) {
109
+ register(card.querySelector('video'), { observe: card });
110
+ }
111
+ ```
112
+
113
+ `atOnce: 1` plays one video at a time everywhere; the default is one on small
114
+ viewports and all of them elsewhere. `prefetchMargin` starts buffering the next
115
+ one before it arrives.
116
+
117
+ ### Images that appear when they are actually decoded
118
+
119
+ `load` fires before the pixels exist, so fading on it can hitch. This waits for
120
+ `decode()`. The container needs its own `background-color`.
121
+
122
+ ```js
123
+ import { revealImages } from 'polite-media/image';
124
+ import 'polite-media/image.css';
125
+
126
+ revealImages('.card img');
127
+ ```
128
+
129
+ ### Warming the next page's hero
130
+
131
+ Document prefetchers fetch the HTML and stop, so the image inside it is
132
+ discovered only once that document parses. This fetches it on hover, focus or
133
+ touch.
134
+
135
+ ```js
136
+ import { warmOnIntent } from 'polite-media/warm';
137
+
138
+ warmOnIntent('a[data-hero]', (link) => ({
139
+ sources: [{ type: 'image/avif', srcset: link.dataset.hero }],
140
+ src: '/fallback.jpg',
141
+ sizes: '(min-width: 50rem) 800px, 100vw',
142
+ }));
143
+ ```
144
+
145
+ ## The attributes
146
+
147
+ Six in total. The distinction that catches people is the middle column: two of
148
+ the ones you write are live on their own, and one is inert until you call
149
+ something.
150
+
151
+ | attribute | goes on | live on its own? |
152
+ | --------------------------- | --------------------------- | --------------------------------------------------------- |
153
+ | `data-polite-media` | the box around poster+video | **yes**, for the CSS. The video still needs `register()` |
154
+ | `data-polite-reveal` | the `<img>` | **no**, without `revealImages()` it reveals late, unfaded |
155
+ | `data-polite-pause-control` | your `<button>` | **yes**, no call anywhere on the page |
156
+ | `data-polite-ready` | the box, or the image | written by the library |
157
+ | `data-polite-failed` | the box | written by the library |
158
+ | `data-polite-paused` | `<html>` | written by the library |
159
+
160
+ `data-polite-reveal` is the one to be careful with. `image.css` hides a marked
161
+ image immediately, so marking one you never pass to `revealImages()` leaves it
162
+ hidden until the failsafe shows it, five seconds later and without a fade. The
163
+ console names any image in that state, so widen the selector or drop the
164
+ attribute.
165
+
166
+ The bottom three are yours to style against and never to write yourself. They are
167
+ the public CSS API, along with `--polite-fade` and `--polite-failsafe`.
168
+
169
+ ## Markup contract
170
+
171
+ 1. Poster and video share one box, arranged by your CSS.
172
+ 2. `data-polite-media` goes on that box **in your markup**, not from script. A
173
+ `<video preload="none">` paints an empty box, so if the hidden state only
174
+ arrived once JS ran, a page whose bundle failed would stack that over the
175
+ poster. Authored, the safe state is the default.
176
+
177
+ That box must be the video's **direct parent**. This is the one rule with a
178
+ silent failure mode: the library writes `data-polite-ready` to
179
+ `video.parentElement`, while the stylesheet matches
180
+ `[data-polite-media][data-polite-ready] > video`. Put the attribute a level
181
+ too high and the two never meet, every rule misses, and the video is visible
182
+ from the start with no error anywhere. The library warns on the console when
183
+ it can detect this.
184
+
185
+ 3. The poster is ideally the video's **frame 0**, which is what makes the handoff
186
+ invisible, but only together with cutting rather than fading. See
187
+ [The fade](#the-fade).
188
+ 4. Poster and video are direct children of the box. The box may hold anything
189
+ else it likes, a scrim, a caption, a pause control, and those are left alone.
190
+ But **every** direct-child `img` or `picture` is treated as the poster and
191
+ hidden on reveal, so a logo or badge belongs deeper, not beside the video.
192
+ 5. Order several `<source>` elements narrowest first: the first one that claims
193
+ the viewport wins. **You do not need an unconditional fallback.** Two queries
194
+ meant to partition the viewport often do not quite meet, and
195
+ `(max-width: 50rem)` beside `(min-width: 50.001rem)` leaves 0.016px matching
196
+ neither at a 16px root. So `media` is treated as a preference: when no source
197
+ claims the current viewport, every decodable one is a candidate and document
198
+ order decides. The console says so once when it happens, because the file it
199
+ picks may be meant for a different screen.
200
+
201
+ The trade is that `media` cannot mean "and otherwise play nothing".
202
+ `atOnce: { small: 0 }` says that properly.
203
+
204
+ 6. The video carries `tabindex="-1" aria-hidden="true"`. It is decorative, and
205
+ without this it lands in the tab order: measured in Firefox, twelve background
206
+ videos sat ahead of the pause button, so a keyboard user reached it on the
207
+ thirteenth Tab.
208
+
209
+ ## API
210
+
211
+ ```js
212
+ // polite-media/video
213
+ register(video, { until, observe, startWhen }); // manage a video
214
+ registerAll(target, { until, startWhen }); // manage everything a selector names
215
+ unregister(video); // stop managing it, release everything
216
+ unregisterAll(); // tear down the whole page
217
+ configure({ ... }); // before the first register, or it throws
218
+ pauseAll(); resumeAll(); // WCAG 2.2.2 control, emits pausechange
219
+
220
+ // polite-media/image
221
+ const stop = revealImages(target, { allowEager }); // reveal on decode
222
+ stop(); // cancel anything pending
223
+
224
+ // polite-media/warm
225
+ warm(options); // warm one image
226
+ const off = warmOnIntent(sel, fn); // warm on hover, focus or touch
227
+ ```
228
+
229
+ There is no root import. Use `polite-media/video`, `polite-media/image` or
230
+ `polite-media/warm`; the resolution error for the bare package name does not name
231
+ them.
232
+
233
+ Types: `ConfigureOptions`, `RegisterOptions`, `RevealImagesOptions`,
234
+ `WarmOptions`, `WarmSource`, `VideoTarget`, `ImageTarget`, `AtOnce`,
235
+ `PoliteVideoEventDetail`, `PoliteImageEventDetail`, `PolitePauseEventDetail`.
236
+ Event names ship as constants
237
+ (`POLITE_VIDEO_READY`, `POLITE_VIDEO_FAILED`, `POLITE_IMAGE_READY`,
238
+ `POLITE_VIDEO_PAUSECHANGE`), because a mistyped event string still compiles
239
+ against lib.dom's `type: string` overload.
240
+
241
+ `polite-video:pausechange` is the odd one out: a user pause is page-wide rather
242
+ than about one video, so it is dispatched on `document` with
243
+ `detail: { paused: boolean }` and does not bubble from any element.
244
+
245
+ ### Options
246
+
247
+ Six, answering three questions. Nothing needs setting; the defaults were measured
248
+ rather than guessed.
249
+
250
+ **How visible must it be?**
251
+
252
+ | option | default | reach for it when |
253
+ | ------------ | ------- | ---------------------------------------------------------------- |
254
+ | `pauseBelow` | `0.5` | a video should run longer, or stop sooner, than "half on screen" |
255
+
256
+ **How many may run?**
257
+
258
+ | option | default | reach for it when |
259
+ | --------------- | ---------------------------- | ---------------------------------------------------------- |
260
+ | `atOnce` | `{ small: 1, large: 'all' }` | a feed wants one at a time everywhere, or phones want none |
261
+ | `smallViewport` | `'(max-width: 767px)'` | your breakpoint is not ours |
262
+
263
+ **When may it begin?**
264
+
265
+ | option | default | reach for it when |
266
+ | ----------------- | --------------- | ----------------------------------------------------------------------------- |
267
+ | `startWhen` | `'page-loaded'` | you want the video out of the LCP measurement, and accept it waiting |
268
+ | `requireBuffered` | `false` | your visitors are on connections where video plays while it is still arriving |
269
+ | `prefetchMargin` | `'0px'` | the next card should be buffered before it arrives |
270
+
271
+ Two numbers that shape playback are deliberately not options. A video leaving the
272
+ viewport waits 400ms before stopping, so a jittery scroll cannot stutter it, and
273
+ an incumbent holding the single slot keeps it until a rival is 15% more visible,
274
+ so a carousel's peeking neighbour cannot flap it back and forth. Both are
275
+ tolerances rather than policies anyone has a view on, and a value picked without
276
+ watching a real carousel does not fail loudly, it just reintroduces the flapping.
277
+
278
+ `configure()` throws if `prefetchMargin`, `pauseBelow` or `smallViewport` is
279
+ patched while videos are registered. Those three are read when the observer and
280
+ the lifecycle listeners are built, so a late change does not merely fail to
281
+ apply: `pauseBelow` half-applies, because eligibility reads it live while the
282
+ threshold ladder does not. `atOnce`, `startWhen` and `requireBuffered` take
283
+ effect on the next pass.
284
+
285
+ `pauseBelow` is range-checked by `configure()`, since TypeScript cannot express
286
+ "a number between 0 and 1", and `atOnce` is checked at runtime for JavaScript
287
+ callers: a `2` would otherwise fall through to the single-slot branch and quietly
288
+ mean `1`.
289
+
290
+ **A malformed `smallViewport` cannot be detected.** An invalid media query does
291
+ not throw and does not normalise to anything recognisable; Chromium echoes the
292
+ text straight back and never matches. So `smallViewport: '(max-width: 767)'`,
293
+ missing its unit, means arbitration silently never engages and phones behave like
294
+ desktops. Check that value by eye.
295
+
296
+ ### Per-video gates
297
+
298
+ `register(video, { until: promise })` holds a video back until the promise
299
+ settles: for a splash screen, a consent dialog, or protecting your LCP. A hero at
300
+ scroll-top is reported visible in the observer's very first batch, so without
301
+ this it starts before whatever the page is waiting on has finished.
302
+
303
+ ```js
304
+ // If the poster is your LCP element, gate the video on it having loaded.
305
+ register(video, {
306
+ until: new Promise((done) => {
307
+ const poster = document.querySelector('img.hero-poster');
308
+ if (!poster || poster.complete) done();
309
+ else poster.addEventListener('load', () => done(), { once: true });
310
+ }),
311
+ });
312
+ ```
313
+
314
+ `register(video, { observe: box })` observes a wrapper instead of the video, for
315
+ when the video is `inset: 0` inside the element that carries the layout.
316
+
317
+ `registerAll(target)` takes the same shapes `revealImages` does (a selector, an
318
+ element, or any collection) and registers each. It does not accept `observe`:
319
+ each observed element maps to exactly one video, and `register` refuses a second
320
+ video on a target it already watches, with a warning.
321
+
322
+ ### `startWhen`
323
+
324
+ A genuine ladder: each rung waits for everything the one before it did, then
325
+ something more.
326
+
327
+ | value | waits for |
328
+ | --------------- | -------------------------------------- |
329
+ | `'visible'` | nothing but being on screen |
330
+ | `'page-loaded'` | + `window`'s `load` event. **Default** |
331
+ | `'interaction'` | + the first pointer, key or scroll |
332
+
333
+ `'page-loaded'` is the default because a deferred module script otherwise fetches
334
+ inside the tail of page load: measured on a demo with one resource held back,
335
+ `'visible'` began the video at 106ms against a `load` at 1560ms, taking 1.45
336
+ seconds of bandwidth the page still needed.
337
+
338
+ **`'interaction'` is the one setting with a real trade-off.** The browser stops
339
+ updating Largest Contentful Paint on "a tap, scroll, or keypress"
340
+ ([web.dev][lcp]), so a video revealed after that signal can never become the LCP
341
+ element, and a synthetic audit, which never interacts, never starts it at all.
342
+ The cost is yours to weigh: a visitor who lands and never scrolls or taps sees a
343
+ still. On a phone that is usually a second, since any flick counts; on a desktop
344
+ it can last as long as they read without moving. Below the fold it barely matters
345
+ either way, since scrolling is the interaction.
346
+
347
+ **Override it per video** when one video is special, which is usually the hero:
348
+
349
+ ```js
350
+ // The grid autoplays on the default; only the LCP candidate holds back.
351
+ register(hero, { startWhen: 'interaction' });
352
+ ```
353
+
354
+ `requireBuffered` is the separate axis: it holds playback until the video can
355
+ play through without stalling. It raises `preload` to `'auto'` when it prepares,
356
+ which is necessary because `preload="none"` means the browser buffers nothing
357
+ until playback is requested, so waiting for `canplaythrough` without the
358
+ promotion would wait forever. All three engines honour the promotion. If the
359
+ buffer never fills, the poster stays, the same outcome as reduced motion or
360
+ Save-Data. It is deliberately not a fourth `startWhen` value; as one it competed
361
+ with `'interaction'`, so "wait for the visitor, and also wait for the buffer"
362
+ could not be expressed at all.
363
+
364
+ Two consequences. A page whose `load` never fires never starts its videos, and
365
+ `requireBuffered` is one of two places the library changes markup you authored:
366
+ `prefetch()` also promotes `preload` to `'auto'` for any video within a
367
+ configured `prefetchMargin`. `until` composes with all of it, and a video waits
368
+ for every gate that applies to it.
369
+
370
+ ### `pauseBelow`
371
+
372
+ Ships at `0.5`: a video runs while more than half of it is on screen and stops
373
+ once less is. Set it to `0` to play while any part shows at all, which in
374
+ practice means a video hanging on by a sliver never stops.
375
+
376
+ **A tall video may never reach a high `pauseBelow`.** `intersectionRatio` is a
377
+ fraction of the _element_, so a box taller than the viewport can never be fully
378
+ intersecting. Measured in Chromium at a 953px viewport, on the observer that
379
+ decides playback:
380
+
381
+ | element height | highest ratio it reaches |
382
+ | -------------- | ------------------------ |
383
+ | 1x viewport | 1.0 |
384
+ | 1.5x viewport | 0.667 |
385
+ | 3x viewport | 0.333 |
386
+
387
+ The ceiling is `viewport / height`, which is why it ships at `0.5`: that is out
388
+ of reach only past twice the viewport, where `0.75` already fails at one and a
389
+ half. **The library warns when it detects a threshold a box can never reach.**
390
+ Whatever you set is also added to the observer's threshold list, because the
391
+ browser only reports at crossings it was told about.
392
+
393
+ ### The pause control
394
+
395
+ A `<button>` carrying `data-polite-pause-control` toggles playback. You supply it
396
+ and its styling; this ships no markup and no CSS for it. The listener is
397
+ delegated on `document`, so it can live anywhere, and several controls stay in
398
+ step automatically.
399
+
400
+ It must be a real `<button>`. The binding is a delegated `click`, and browsers
401
+ only synthesise that from Enter and Space for a native button; a
402
+ `div[role="button"][tabindex="0"]` answers a mouse and ignores a keyboard, which
403
+ is a WCAG 2.1.1 failure.
404
+
405
+ **A pause follows the visitor to the next page**, kept in `sessionStorage`, so on
406
+ a multi-page site the motion stays stopped as they click through and a declared
407
+ `aria-pressed` comes back in the right state. `sessionStorage` rather than
408
+ `localStorage` deliberately, so it lasts the visit rather than silently outliving
409
+ it by weeks. Where storage is denied, which throws rather than returning nothing,
410
+ the pause still applies to the current page and simply does not carry.
411
+
412
+ **If you forget the control, the console says so.** Five seconds after a looping
413
+ video starts, WCAG 2.2.2's own threshold, so a short clip that ends by itself is
414
+ never asked about. If you drive `pauseAll()` from your own UI instead, put
415
+ `data-polite-pause-control` on that control too: it silences the warning and gets
416
+ you `aria-pressed` maintenance for free.
417
+
418
+ There are two ways to convey the state, and you pick one. **A constant label plus
419
+ `aria-pressed="false"`**: declare the attribute and the library keeps it current.
420
+ Or **a label that swaps between "Pause" and "Play"**: leave `aria-pressed` off
421
+ entirely, since a screen reader announcing "Play, pressed" is worse than either
422
+ half, and listen for the state instead.
423
+
424
+ ```js
425
+ import { POLITE_VIDEO_PAUSECHANGE } from 'polite-media/video';
426
+
427
+ document.addEventListener(POLITE_VIDEO_PAUSECHANGE, (event) => {
428
+ control.textContent = event.detail.paused ? 'Play' : 'Pause';
429
+ });
430
+ ```
431
+
432
+ For an **icon** you need no JavaScript at all, since the state is on the root
433
+ element, so the icon cannot drift from what the video is doing. That pairs with
434
+ `aria-pressed` and a constant accessible name, since an icon is not a label.
435
+
436
+ ```css
437
+ .icon-play,
438
+ [data-polite-paused] .icon-pause {
439
+ display: none;
440
+ }
441
+ [data-polite-paused] .icon-play {
442
+ display: inline-block;
443
+ }
444
+ ```
445
+
446
+ ## The fade
447
+
448
+ One custom property controls it, read by both stylesheets: `--polite-fade`.
449
+ **Video defaults to `0s`, a cut. Images default to `350ms`.**
450
+
451
+ They differ because they solve different problems. An image fades in over a
452
+ backdrop with nothing else moving, so the fade only covers the flicker of an
453
+ async decode landing. A video is a second picture that keeps changing.
454
+
455
+ The trap is assuming a frame-0 poster makes a video fade free. It does not,
456
+ because the video does not wait for the fade: playback starts at the reveal, so
457
+ the poster stays frozen on frame 0 while the video underneath advances, and the
458
+ crossfade blends a still against a frame that has moved on. Measured on a real
459
+ hero, SSIM against the video's own frame 0:
460
+
461
+ | elapsed | similarity to frame 0 |
462
+ | ------- | --------------------- |
463
+ | 250ms | 0.72 |
464
+ | 400ms | 0.69 |
465
+ | 1s | 0.64 |
466
+
467
+ Its poster matched frame 0 at 0.994, so the poster was never the problem. At the
468
+ old `400ms` default the layers were already ~31% apart, and at 1s you see a clear
469
+ double exposure of a moving scene.
470
+
471
+ **That measurement does not transfer to an art-directed poster.** The ghost it
472
+ describes is a double exposure of one scene slightly advanced, which is what you
473
+ get when the two layers are nearly the same picture. Blend two genuinely
474
+ different pictures and you get an ordinary dissolve.
475
+
476
+ | poster | at `0s`, the default | with a fade |
477
+ | ------------------------------- | --------------------------------- | ---------------------------------- |
478
+ | the video's frame 0 | seamless, nothing visibly happens | ghosts, and worse the longer it is |
479
+ | a different, art-directed image | a visible jump | an ordinary crossfade |
480
+
481
+ The default cuts because the library cannot tell which you have, and guessing
482
+ wrong on a frame-0 poster is the worse failure. **Turn it on for the videos that
483
+ want it**, one container at a time; `--polite-fade` is an ordinary custom
484
+ property, so it inherits. `demo/art-directed.html` shows both side by side.
485
+
486
+ ```css
487
+ /* this hero dissolves; every other video on the page still cuts */
488
+ .hero[data-polite-media] {
489
+ --polite-fade: 600ms;
490
+ }
491
+ ```
492
+
493
+ **A hard cut is safe here**, which is not true of background-video code in
494
+ general. The reveal fires from `requestVideoFrameCallback`, so a frame has
495
+ already reached the compositor before the swap. Code that reveals on `playing`
496
+ needs a fade to cover the window where nothing has painted yet; this doesn't.
497
+
498
+ Two things that surprise people:
499
+
500
+ - **Both halves share the token.** Setting it on `:root` changes video and images
501
+ together. Scope it to a selector to split them.
502
+ - **An invalid value computes to `0s`, not to the stylesheet's default.**
503
+ `--polite-fade: 0` is invalid, since CSS `<time>` requires a unit, and so is any
504
+ typo. Measured in Chromium: both give an instant swap, because a substitution
505
+ that is invalid at computed-value time falls back to the property's initial
506
+ value, and the `var()` fallback only applies when the property is undefined.
507
+ Harmless on video, which cuts anyway; on images it silently removes the 350ms
508
+ fade.
509
+
510
+ Reduced motion overrides you either way: the stylesheet sets `transition: none`
511
+ there, so a fade you asked for never runs for someone who asked not to see one.
512
+
513
+ ## Warming the next page's image
514
+
515
+ `polite-media/warm` fetches the image the _next_ page will show, while the
516
+ visitor is still deciding to go there. Every document prefetcher stops at the
517
+ HTML: Astro's `data-astro-prefetch`, Next's `<Link>` and quicklink all fetch the
518
+ document, and the hero inside it is discovered only once that document parses.
519
+
520
+ ```js
521
+ import { warmOnIntent } from 'polite-media/warm';
522
+
523
+ // One listener for a whole grid, delegated on the document.
524
+ warmOnIntent('a[data-hero]', (link) => ({
525
+ sources: [{ type: 'image/avif', srcset: link.dataset.hero }],
526
+ src: '/fallback.jpg',
527
+ sizes: '(min-width: 50rem) 800px, 100vw',
528
+ }));
529
+ ```
530
+
531
+ **Nothing in this package parses a media query**, which is the point: the
532
+ candidates are assembled as a detached `<picture>` and the browser picks, running
533
+ the same algorithm it will run on the destination. Hand-rolling that selection
534
+ drifts the moment `sizes` changes in one place and not the other.
535
+
536
+ `warm(options)` warms one image directly, for navigation that isn't a link.
537
+ `warmOnIntent` returns a teardown; call it if you re-bind per navigation, since
538
+ listeners on `document` survive a `<ClientRouter />` swap.
539
+
540
+ **Why not a `<link>` hint.** `imagesrcset` and `imagesizes` do responsive
541
+ selection, but only for `rel="preload"` with `as="image"`, and preload is for
542
+ resources "[your page will need very soon][preload]" rather than the next page's.
543
+ `prefetch` has the right timing and ignores those attributes. A preload link
544
+ injected on hover does select correctly in all three engines, measured, so it is
545
+ a real alternative; what it costs is a Chromium console warning on every warmed
546
+ image, because the resource is by definition never used by the page that
547
+ preloaded it. Going the other way, to `prefetch`, is worse: Safari doesn't
548
+ support `<link rel="prefetch">` and Firefox aborts it with `NS_BINDING_ABORTED`
549
+ without an explicit cache header. A detached image both selects and fetches, so
550
+ none of it applies.
551
+
552
+ **Skipped on Save-Data and 2g**, since nobody asked for these bytes yet. Deduped,
553
+ so repeated hovering warms once. Fetched at `fetchpriority="low"`.
554
+
555
+ **When you don't need this.** If your audience is Chromium and you already use
556
+ Speculation Rules `prerender`, that loads the whole destination including its
557
+ images and does strictly more. It is [not Baseline][prerender] and covers neither
558
+ Safari nor Firefox, which is the gap this fills.
559
+
560
+ ## Client-side routers
561
+
562
+ If your pages are replaced without a reload (Astro's `<ClientRouter />`, or any
563
+ SPA router), **registration has to be re-run on every navigation**. Astro's docs
564
+ are explicit that ["bundled module scripts … are only ever executed once. After
565
+ initial execution they will be ignored, even if the script exists on the new page
566
+ after a transition"][astro-scripts], while the swap "completely replaces" the
567
+ body.
568
+
569
+ ```js
570
+ import { registerAll } from 'polite-media/video';
571
+
572
+ document.addEventListener('astro:page-load', () => registerAll('[data-polite-media] video'));
573
+ ```
574
+
575
+ `registerAll()` is idempotent, so a video that survived the swap is not
576
+ registered twice. Videos that did not survive need no cleanup: the coordinator
577
+ drops any entry whose element has left the document on its next pass.
578
+
579
+ ## What it does
580
+
581
+ - Reveals on a genuinely presented frame, never on `playing`.
582
+ - Plays only what's on screen; stops what isn't.
583
+ - Caps how many videos run at once, on any viewport, not just small ones.
584
+ - Falls through to the next `<source>` when one can't be decoded.
585
+ - Honours `prefers-reduced-motion` live, and Save-Data on the next reconcile.
586
+ - Recovers from bfcache restores, tab refocus and blocked autoplay.
587
+ - Ships a pause control hook for [WCAG 2.2.2][wcag].
588
+ - Emits `polite-video:ready`, `polite-video:failed` and `polite-video:pausechange`.
589
+
590
+ ## What it deliberately doesn't do
591
+
592
+ **It never sets a width, height or aspect ratio.** It owns _when_ media appears;
593
+ your CSS owns _where_. That is why it drops into an existing design at any video
594
+ size, and it would be undone by one dimension declaration.
595
+
596
+ It's also not an image pipeline (no srcset or poster generation: that's a build
597
+ step), not a lazy-loader for images (`loading="lazy"` is native), not a player,
598
+ not a lightbox, and not a scroll-animation library. `polite-media/warm` warms
599
+ candidates you already build, and generates none.
600
+
601
+ **Images are opt-in per image, not per container.** A container-wide rule hides
602
+ every image inside it, including ones the library then declines to fade, so each
603
+ one waits out the failsafe instead of fading.
604
+
605
+ **Eager images are revealed instantly rather than faded.** LCP excludes elements
606
+ at `opacity: 0` and revealing one doesn't restore its candidacy. Pass
607
+ `{ allowEager: true }` to fade anyway.
608
+
609
+ **An image needs a backdrop.** Video degrades to its poster; a lone image
610
+ degrades to nothing, so its container must carry a visible `background-color`.
611
+
612
+ **Images are only hidden where scripting can reveal them.** `image.css` puts the
613
+ hiding rule behind `@media (scripting: enabled)`, Baseline since December 2023,
614
+ so with scripting off the photos arrive unfaded instead of never arriving. And
615
+ because no media query can see a bundle that fails while scripting is on, the
616
+ stylesheet reveals any marked image after `--polite-failsafe` (default `5s`)
617
+ regardless. A missed selector or a dead bundle costs you the fade, not the
618
+ picture. The failsafe applies to every marked image, including managed ones,
619
+ because an earlier design that exempted them could send an already-revealed image
620
+ back to hidden.
621
+
622
+ ## Why it exists
623
+
624
+ Every bug it fixes is one you can't see in development.
625
+
626
+ **`playing` is not when the picture appears.** The usual advice is to swap the
627
+ poster for the video on the `playing` event. Measured on one machine in one run,
628
+ H.264 presented its first frame **1.6 ms before** `playing` fired, while AV1
629
+ presented **0.8 ms after**. It isn't early, it's _unordered_, so no delay tunes
630
+ it away. `requestVideoFrameCallback` is specified in terms of a frame reaching
631
+ the compositor, so it's right by definition.
632
+
633
+ **`canPlayType` lies.** In this repo's own fixtures, Chromium answered
634
+ `"probably"` for `sample-truncated-av1.mp4` and then failed with
635
+ `PIPELINE_ERROR_DECODE: dav1d_send_data() failed with error -22`. That is the bar
636
+ the specification sets, not a quirk: the [HTML Standard][html-canplaytype] says
637
+ the method returns `"probably"` only "if the user agent is confident that the
638
+ type represents a media resource that it can render". Confidence is not a
639
+ guarantee, so codec checks only _order_ the candidates here and the `error` event
640
+ decides.
641
+
642
+ **Absence of `navigator.connection` means allow, not block.** Safari and Firefox
643
+ never expose the Network Information API and Brave disables it as a
644
+ fingerprinting surface. Read absence as "block" and you silently kill video for
645
+ most of the web, while every test on Chrome still passes.
646
+
647
+ **Video comes back frozen after a back-navigation.** Scripts don't re-run on a
648
+ bfcache restore, and mobile browsers pause video while the tab is hidden and
649
+ leave it paused on return.
650
+
651
+ ## Status
652
+
653
+ Framework-agnostic by construction: no framework dependency, standard DOM only,
654
+ developed against Astro projects.
655
+
656
+ **Tested on Chromium, Firefox and WebKit**, all three driven by Playwright
657
+ against real media on every change, alongside 211 unit tests. Other engines are
658
+ unexercised rather than unsupported.
659
+
660
+ **iOS Safari is outside the matrix**, and Playwright's WebKit does not stand in
661
+ for it. The path that rests on it is the retry after a refused `play()`, which
662
+ rejects with `NotAllowedError`; the library follows MDN's documented remedy of
663
+ surfacing a control and waiting for a gesture.
664
+
665
+ **`0.x`**, so a minor bump may still change behaviour; `CHANGELOG.md` says when
666
+ it does. Nothing runs it in production so far.
667
+
668
+ Contributing, and how to run the suite: [CONTRIBUTING.md](CONTRIBUTING.md). MIT.
669
+
670
+ [astro-scripts]: https://docs.astro.build/en/guides/view-transitions/#script-re-execution
671
+ [html-canplaytype]: https://html.spec.whatwg.org/multipage/media.html
672
+ [prerender]: https://developer.mozilla.org/en-US/docs/Web/API/Speculation_Rules_API
673
+ [lcp]: https://web.dev/articles/lcp
674
+ [preload]: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/rel/preload
675
+ [wcag]: https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html