polite-media 0.4.3 → 0.6.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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,129 @@
3
3
  Notable changes, newest first. Versions follow [semver](https://semver.org); while
4
4
  this is `0.x`, a minor bump may still break things and will say so here.
5
5
 
6
+ ## 0.6.0 (2026-10-07)
7
+
8
+ ### Breaking
9
+
10
+ - **`data-polite-active` is on `<html>` only while a registered video may play.** It used to stay
11
+ for as long as any video was registered, so under reduced motion, on Save-Data or a 2g connection,
12
+ or with `atOnce` at `0`, the README's recipe showed a pause control with nothing to stop. It now
13
+ follows those gates on every reconcile, and a user pause keeps it, since the control is then what
14
+ resumes. CSS that used the attribute to mean "a video is registered" needs another hook.
15
+
16
+ - **A revealed poster is hidden with `opacity`, so its `alt` stays in the accessibility tree.**
17
+ `video.css` hid it with `visibility: hidden`, which removes it from the tree, while the README
18
+ tells you to put the meaning in the poster's `alt` and hide the video. It now sets
19
+ `opacity: 0 !important` and `pointer-events: none` on the poster image, inside a `<picture>` too,
20
+ after the same `--polite-fade` delay. `!important` because image.css's failsafe animation would
21
+ otherwise keep a marked poster that nothing manages opaque. A rule of yours that changes the
22
+ poster's opacity after the reveal now needs `!important` as well, and a test that waited for the
23
+ poster with Playwright's `toBeHidden()` must wait for computed opacity `0`, since Playwright
24
+ counts an element at opacity 0 as visible.
25
+
26
+ - **`smallViewport` defaults to `'(max-width: 767.98px)'`**, from `'(max-width: 767px)'`. Beside a
27
+ `(min-width: 768px)` the old default left every width between 767px and 768px, which page zoom
28
+ produces, matching neither, so arbitration treated those viewports as large. The gap is now
29
+ 0.02px. Range syntax would close it fully, but Safari before 16.4 never matches it. Pass
30
+ `smallViewport` yourself to keep the old value.
31
+
32
+ ### Added
33
+
34
+ - **A warning when a registered video is not decorative.** The markup contract asks for
35
+ `tabindex="-1" aria-hidden="true"` on the video, and nothing checked it. `register()` now warns
36
+ once per page, naming the video, when its `tabindex` attribute is not `-1` or no
37
+ `aria-hidden="true"` sits on the video or a box around it.
38
+
39
+ - **A warning when `--polite-fade` is not a CSS time.** A value such as `0.6` computes to `0s`
40
+ rather than to the stylesheet's default, so it silently removed the image fade. The first video
41
+ box and the first fading image that see such a value are named once per page. The browser parses
42
+ the value, so `calc(0.3s * 2)` passes, and a bare `0` stays quiet because it gives the cut it
43
+ looks like.
44
+
45
+ - **`register(video, { until, prefetchWhileGated: true })`** lets a gated video download while it
46
+ waits, so a splash buys it load time. A gate used to hold the fetch as well as playback, and that
47
+ stays the default, since a page may rely on a consent dialog holding the bytes too. The download
48
+ still waits for `startWhen`, reduced motion, Save-Data and a user pause, and starts once the video
49
+ is on screen or within `prefetchMargin`. A host can end its splash on the video's own
50
+ `canplaythrough`, which fires while the gate still holds; the README shows the recipe.
51
+
52
+ ### Fixed
53
+
54
+ - **`warm()` with `sources` fetched the candidate for the full viewport width.** A `<source>`
55
+ without its own `sizes` is selected as if it said `100vw`, and `sizes` went on the `<img>` alone,
56
+ so a desktop warm could fetch a file several times the size the destination then picked. Every
57
+ `<source>` now carries the `sizes` it was passed.
58
+
59
+ - **A video buffered ahead through `prefetchMargin` started playing off screen when its first source
60
+ could not be decoded.** The source fallback revealed and played whatever came next, whether or not
61
+ anything had asked the video to play. It now moves to the next source and waits.
62
+
63
+ - **A lazy image still faded in under reduced motion.** `image.css`'s reduced-motion rule lost on
64
+ specificity to the rule that sets the fade, so the README's promise that reduced motion overrides
65
+ `--polite-fade` held only for eager images that opted in. Lazy images now arrive at once there.
66
+
67
+ - **A paused page still buffered videos within `prefetchMargin`.** The prefetch observer fetched
68
+ without asking whether the visitor had paused. It now waits, and resuming starts the fetch.
69
+
70
+ ### Documentation
71
+
72
+ - **`register()` and `pauseAll()` carry their documentation in the published types.** Their doc
73
+ comments sat above other functions, so an editor hovering either showed nothing.
74
+
75
+ - **An `until` gate does not protect LCP**, and the README no longer says it does. The video's
76
+ first frame is an LCP candidate whenever it paints; only `startWhen: 'interaction'` keeps it out.
77
+ The poster-`load` gate example is gone for the same reason.
78
+ - **A gate that never settles holds its video for good.** The README shows racing it against a
79
+ timeout of your own.
80
+ - **The wrapper recipe in "Composing your own animation" gates only on images that will load.**
81
+ Lazy images in a horizontal scroller may never be fetched, so a wrapper waiting on all of them
82
+ waits for its failsafe.
83
+
84
+ ## 0.5.0 (2026-09-12)
85
+
86
+ ### Breaking
87
+
88
+ - **`data-polite-media` no longer has to be the video's direct parent.** The library writes its
89
+ state to the nearest `[data-polite-media]` ancestor, and `video.css` and `layer.css` match the
90
+ video as a descendant of the box rather than a child. The poster rules are unchanged: a poster is
91
+ still a direct child, so a logo deeper in the box is still left alone. One box holds one video, and
92
+ boxes must not nest, because the box's ready state reveals every video inside it. If your CSS
93
+ extended `[data-polite-media] > video`, drop the `>`.
94
+
95
+ - **`image.css` hides only lazy images.** An eager image may be the LCP element, and a deferred
96
+ module cannot reveal it before first paint, so the stylesheet now leaves `loading="eager"` (and
97
+ images with no `loading`) alone. To fade one anyway, write `data-polite-reveal="eager"` on it.
98
+ `revealImages()`'s `allowEager` option is ignored and warns, because the opt-in has to be where
99
+ the stylesheet can see it.
100
+
101
+ ### Added
102
+
103
+ - **`registerAll()` and `revealImages()` take no argument** and default to
104
+ `'[data-polite-media] video'` and `'img[data-polite-reveal]'`, which is what both known consumers
105
+ had written out.
106
+
107
+ - **`polite-video:blocked`, and `data-polite-blocked` on the box**, while the browser refuses
108
+ `play()` until a gesture. The retry was already there; what was missing was any way for a host to
109
+ know it was waiting, so a play affordance could not be shown. Fires once per refusal, not on every
110
+ retried attempt, and the attribute is removed once playback starts.
111
+
112
+ ### Fixed
113
+
114
+ - **A pause was lost from `<html>` on a client-side navigation.** A router such as Astro's
115
+ `<ClientRouter />` replaces the root element's attributes with the next document's, and the pause
116
+ state was only written on the first registration of a page load. The videos stayed paused, but
117
+ `data-polite-paused` and a declared `aria-pressed` said otherwise. Both are now re-asserted on
118
+ every `register()`.
119
+
120
+ - **`polite-video:ready` fired again each time a video scrolled back into view.** The frame was
121
+ still on screen, so nothing had been revealed. It now fires once per reveal; a retraction (reduced
122
+ motion, Save-Data, a source fallback) still announces the re-reveal.
123
+
124
+ ### Documentation
125
+
126
+ - The README now lists all four places the library changes an element you authored: `preload`
127
+ (twice), `muted`, and `src` plus `load()` when a `<source>` list is resolved.
128
+
6
129
  ## 0.4.3 (2026-09-06)
7
130
 
8
131
  ### Fixed
package/README.md CHANGED
@@ -11,7 +11,7 @@ npm install polite-media
11
11
  ```
12
12
 
13
13
  ```html
14
- <!-- data-polite-media goes on the video's direct parent -->
14
+ <!-- data-polite-media goes on the box that holds poster and video -->
15
15
  <div class="your-own-box" data-polite-media>
16
16
  <img src="poster.avif" alt="" />
17
17
  <!-- decorative: the video is not in the tab order, the poster's alt carries any meaning -->
@@ -122,7 +122,7 @@ one before it arrives.
122
122
  import { revealImages } from 'polite-media/image';
123
123
  import 'polite-media/image.css';
124
124
 
125
- revealImages('.card img');
125
+ revealImages(); // every img[data-polite-reveal] on the page
126
126
  ```
127
127
 
128
128
  ### Warming the next page's hero
@@ -143,7 +143,7 @@ warmOnIntent('a[data-hero]', (link) => ({
143
143
 
144
144
  ## The attributes
145
145
 
146
- Eight in total. The distinction that catches people is the middle column: two of
146
+ Nine in total. The distinction that catches people is the middle column: two of
147
147
  the ones you write are live on their own, and one is inert until you call
148
148
  something.
149
149
 
@@ -154,19 +154,24 @@ something.
154
154
  | `data-polite-pause-control` | your `<button>` | **yes**, no call anywhere on the page |
155
155
  | `data-polite-ready` | the box, or the image | written by the library |
156
156
  | `data-polite-failed` | the box | written by the library |
157
+ | `data-polite-blocked` | the box | written by the library |
157
158
  | `data-polite-paused` | `<html>` | written by the library |
158
159
  | `data-polite-active` | `<html>` | written by the library |
159
160
  | `data-polite-managed` | the `<img>` | written by the library |
160
161
 
161
162
  `data-polite-reveal` is the one to be careful with. `image.css` hides a marked
162
- image immediately, so marking one you never pass to `revealImages()` leaves it
163
- hidden until the failsafe shows it, five seconds later and without a fade. The
163
+ lazy image immediately, so marking one you never pass to `revealImages()` leaves
164
+ it hidden until the failsafe shows it, five seconds later and without a fade. The
164
165
  console names any image in that state, so widen the selector or drop the
165
- attribute.
166
+ attribute. An eager image is never hidden; give the attribute the value `eager`
167
+ to opt one into the fade anyway.
166
168
 
167
- `data-polite-active` is on `<html>` for as long as at least one video is
168
- registered. A pause control is markup on every page, but it must not offer to
169
- stop something that was never registered, and nothing else lets CSS answer that:
169
+ `data-polite-active` is on `<html>` while a registered video may play. It is
170
+ absent when nothing is registered, under reduced motion, on Save-Data or a 2g
171
+ connection, and where `atOnce` is `0` for the viewport; a user pause keeps it,
172
+ since the control is then what resumes. A pause control is markup on every page,
173
+ but it must not offer to stop something that cannot move, and CSS alone cannot
174
+ see most of those reasons:
170
175
 
171
176
  ```css
172
177
  [data-polite-pause-control] {
@@ -177,9 +182,14 @@ stop something that was never registered, and nothing else lets CSS answer that:
177
182
  }
178
183
  ```
179
184
 
180
- The bottom five are yours to style against and never to write yourself. They are
185
+ The bottom six are yours to style against and never to write yourself. They are
181
186
  the public CSS API, along with `--polite-fade` and `--polite-failsafe`.
182
187
 
188
+ `data-polite-blocked` is on the box while the browser refuses `play()` until a
189
+ gesture, which MDN reports as `NotAllowedError`. The library keeps retrying on its
190
+ own; the attribute is there so your CSS can show a play affordance in the
191
+ meantime, and it is removed once playback starts.
192
+
183
193
  ### Composing your own animation with the reveal
184
194
 
185
195
  Gating an animation of your own on `data-polite-ready` works, and needs one thing
@@ -200,6 +210,14 @@ bundle that never arrives leaves your wrapper hidden for good even though the
200
210
  library's own failsafe fired. Give any rule of yours that hides on
201
211
  `:not([data-polite-ready])` its own failsafe on the same delay.
202
212
 
213
+ **Gate a wrapper only on images that will load.** The `:has()` above waits on
214
+ every marked image inside the card, and lazy loading may never fetch some of
215
+ them. Measured in Chromium, in a 412px-wide horizontal scroller of nine lazy
216
+ images, only the first three loaded, and three in the middle never did, even
217
+ after scrolling past them. A card holding that scroller waits for your failsafe
218
+ and then appears unfaded. Gate it on the images in view when it appears, such as
219
+ the first, or leave scrollers out of the rule.
220
+
203
221
  One limit worth knowing: a skipped `content-visibility: auto` subtree runs no
204
222
  animations, so neither failsafe fires there until the section is scrolled into
205
223
  view. It resolves itself the moment anyone looks at it, which is the only moment
@@ -213,21 +231,20 @@ it could have mattered.
213
231
  arrived once JS ran, a page whose bundle failed would stack that over the
214
232
  poster. Authored, the safe state is the default.
215
233
 
216
- That box must be the video's **direct parent**. This is the one rule with a
217
- silent failure mode: the library writes `data-polite-ready` to
218
- `video.parentElement`, while the stylesheet matches
219
- `[data-polite-media][data-polite-ready] > video`. Put the attribute a level
220
- too high and the two never meet, every rule misses, and the video is visible
221
- from the start with no error anywhere. The library warns on the console when
222
- it can detect this.
234
+ The video may sit anywhere inside that box: the library writes its state to
235
+ the nearest `[data-polite-media]` ancestor, and the stylesheet matches the
236
+ video as a descendant. One box holds one video, and boxes must not nest,
237
+ because the box's ready state reveals every video inside it: a second one
238
+ would appear before it had painted. With no box above the video at all,
239
+ nothing hides it and the library warns on the console.
223
240
 
224
241
  3. The poster is ideally the video's **frame 0**, which is what makes the handoff
225
242
  invisible, but only together with cutting rather than fading. See
226
243
  [The fade](#the-fade).
227
- 4. Poster and video are direct children of the box. The box may hold anything
228
- else it likes, a scrim, a caption, a pause control, and those are left alone.
229
- But **every** direct-child `img` or `picture` is treated as the poster and
230
- hidden on reveal, so a logo or badge belongs deeper, not beside the video.
244
+ 4. The poster is a direct child of the box. The box may hold anything else it
245
+ likes, a scrim, a caption, a pause control, and those are left alone. But
246
+ **every** direct-child `img` or `picture` is treated as the poster and hidden
247
+ on reveal, so a logo or badge belongs deeper, not beside the video.
231
248
  5. Order several `<source>` elements narrowest first: the first one that claims
232
249
  the viewport wins. **You do not need an unconditional fallback.** Two queries
233
250
  meant to partition the viewport often do not quite meet, and
@@ -243,21 +260,25 @@ it could have mattered.
243
260
  6. The video carries `tabindex="-1" aria-hidden="true"`. It is decorative, and
244
261
  without this it lands in the tab order: measured in Firefox, twelve background
245
262
  videos sat ahead of the pause button, so a keyboard user reached it on the
246
- thirteenth Tab.
263
+ thirteenth Tab. The console says so once per page when a registered video
264
+ lacks either; `aria-hidden` on a box around it counts too. Anything the
265
+ picture means goes in the poster's `alt`: the reveal hides the poster with
266
+ `opacity`, not `visibility`, so it stays in the accessibility tree while the
267
+ video plays.
247
268
 
248
269
  ## API
249
270
 
250
271
  ```js
251
272
  // polite-media/video
252
- register(video, { until, observe, startWhen }); // manage a video
253
- registerAll(target, { until, startWhen }); // manage everything a selector names
273
+ register(video, { until, observe, startWhen, prefetchWhileGated }); // manage a video
274
+ registerAll(target?, { until, startWhen, prefetchWhileGated }); // every [data-polite-media] video by default
254
275
  unregister(video); // stop managing it, release everything
255
276
  unregisterAll(); // tear down the whole page
256
277
  configure({ ... }); // before the first register, or it throws
257
278
  pauseAll(); resumeAll(); // WCAG 2.2.2 control, emits pausechange
258
279
 
259
280
  // polite-media/image
260
- const stop = revealImages(target, { allowEager }); // reveal on decode
281
+ const stop = revealImages(target?); // reveal on decode; every marked image by default
261
282
  stop(); // cancel anything pending
262
283
 
263
284
  // polite-media/warm
@@ -273,9 +294,15 @@ Types: `ConfigureOptions`, `RegisterOptions`, `RevealImagesOptions`,
273
294
  `WarmOptions`, `WarmSource`, `VideoTarget`, `ImageTarget`, `AtOnce`,
274
295
  `PoliteVideoEventDetail`, `PoliteImageEventDetail`, `PolitePauseEventDetail`.
275
296
  Event names ship as constants
276
- (`POLITE_VIDEO_READY`, `POLITE_VIDEO_FAILED`, `POLITE_IMAGE_READY`,
277
- `POLITE_VIDEO_PAUSECHANGE`), because a mistyped event string still compiles
278
- against lib.dom's `type: string` overload.
297
+ (`POLITE_VIDEO_READY`, `POLITE_VIDEO_FAILED`, `POLITE_VIDEO_BLOCKED`,
298
+ `POLITE_IMAGE_READY`, `POLITE_VIDEO_PAUSECHANGE`), because a mistyped event
299
+ string still compiles against lib.dom's `type: string` overload.
300
+
301
+ `polite-video:ready` fires once per reveal. A video that scrolls away and back
302
+ keeps its last frame on screen, so it is not announced again; one whose reveal
303
+ was retracted, by reduced motion, Save-Data or a source fallback, is announced
304
+ again when it re-reveals. `polite-video:blocked` fires once when the browser
305
+ refuses `play()` until a gesture, and not again on each refused retry.
279
306
 
280
307
  `polite-video:pausechange` is the odd one out: a user pause is page-wide rather
281
308
  than about one video, so it is dispatched on `document` with
@@ -297,7 +324,13 @@ rather than guessed.
297
324
  | option | default | reach for it when |
298
325
  | --------------- | ---------------------------- | ---------------------------------------------------------- |
299
326
  | `atOnce` | `{ small: 1, large: 'all' }` | a feed wants one at a time everywhere, or phones want none |
300
- | `smallViewport` | `'(max-width: 767px)'` | your breakpoint is not ours |
327
+ | `smallViewport` | `'(max-width: 767.98px)'` | your breakpoint is not ours |
328
+
329
+ The `smallViewport` default meets a `(min-width: 768px)` with a 0.02px gap rather
330
+ than a whole pixel, so a fractional width from page zoom lands on one side. Pair
331
+ your own queries the same way. Range syntax, `(width < 768px)`, would close the
332
+ gap entirely, but Safari before 16.4 never matches it, so phones there would
333
+ count as large and play every visible video at once.
301
334
 
302
335
  **When may it begin?**
303
336
 
@@ -326,35 +359,77 @@ effect on the next pass.
326
359
  callers: a `2` would otherwise fall through to the single-slot branch and quietly
327
360
  mean `1`.
328
361
 
329
- **A malformed `smallViewport` cannot be detected.** An invalid media query does
330
- not throw and does not normalise to anything recognisable; Chromium echoes the
331
- text straight back and never matches. So `smallViewport: '(max-width: 767)'`,
362
+ **A malformed `smallViewport` is not checked for you.** An invalid media query
363
+ does not throw and does not normalise to anything recognisable; Chromium echoes
364
+ the text straight back and never matches. So `smallViewport: '(max-width: 767)'`,
332
365
  missing its unit, means arbitration silently never engages and phones behave like
333
- desktops. Check that value by eye.
366
+ desktops. Check it in the console on both sides of your breakpoint:
367
+ `matchMedia(query).matches` should be `true` below it and `false` above. A
368
+ malformed query is `false` on both.
334
369
 
335
370
  ### Per-video gates
336
371
 
337
372
  `register(video, { until: promise })` holds a video back until the promise
338
- settles: for a splash screen, a consent dialog, or protecting your LCP. A hero at
339
- scroll-top is reported visible in the observer's very first batch, so without
340
- this it starts before whatever the page is waiting on has finished.
373
+ settles: for a splash screen or a consent dialog. A hero at scroll-top is
374
+ reported visible in the observer's very first batch, so without this it starts
375
+ before whatever the page is waiting on has finished.
376
+
377
+ **A gate delays the video; it does not keep it out of Largest Contentful Paint.**
378
+ The first frame is an LCP candidate like any image, whenever it paints: measured
379
+ in Chromium on a real hero gated behind a splash, the video's first frame
380
+ replaced the poster as the LCP element just after the gate opened. Only
381
+ [`startWhen: 'interaction'`](#startwhen) keeps a video out of LCP.
382
+
383
+ The gate opens when the promise settles, fulfilled or rejected, and not
384
+ otherwise: one that never settles holds the video on its poster for the life of
385
+ the page. If whatever resolves it might never run, race it against a timeout of
386
+ your own:
341
387
 
342
388
  ```js
343
- // If the poster is your LCP element, gate the video on it having loaded.
344
- register(video, {
345
- until: new Promise((done) => {
346
- const poster = document.querySelector('img.hero-poster');
347
- if (!poster || poster.complete) done();
348
- else poster.addEventListener('load', () => done(), { once: true });
349
- }),
350
- });
389
+ const timeout = (ms) => new Promise((done) => setTimeout(done, ms));
390
+
391
+ register(video, { until: Promise.race([splashDone, timeout(15000)]) });
392
+ ```
393
+
394
+ **A splash can buy the video load time.** A gate holds the download as well as
395
+ playback, so a hero behind a five-second splash only starts fetching once the
396
+ splash ends. `prefetchWhileGated: true` lets it download while it waits: only
397
+ playback waits for the gate, while `startWhen`, reduced motion, Save-Data and a
398
+ pause still hold the fetch. The browser fires `canplaythrough` once it can play
399
+ through, which a splash can end on, with a floor and a cap of its own:
400
+
401
+ ```js
402
+ const wait = (ms) => new Promise((done) => setTimeout(done, ms));
403
+ let endSplash;
404
+ const splashDone = new Promise((done) => (endSplash = done));
405
+
406
+ register(hero, { until: splashDone, prefetchWhileGated: true });
407
+
408
+ // Nothing downloads where nothing will play (reduced motion, Save-Data,
409
+ // atOnce: 0), or while the visitor has paused.
410
+ const root = document.documentElement;
411
+ const buffered =
412
+ root.hasAttribute('data-polite-active') && !root.hasAttribute('data-polite-paused')
413
+ ? new Promise((done) => hero.addEventListener('canplaythrough', done, { once: true }))
414
+ : Promise.resolve();
415
+
416
+ // At least 1.5s of splash, at most 4.5s, and in between until the hero can play through.
417
+ await Promise.race([Promise.all([buffered, wait(1500)]), wait(4500)]);
418
+ hideSplash();
419
+ endSplash();
351
420
  ```
352
421
 
422
+ Keep the cap. Whether iOS Safari buffers under `preload="auto"` before anything
423
+ calls `play()` is untested, and the cap is what ends the splash if the event
424
+ never comes.
425
+
353
426
  `register(video, { observe: box })` observes a wrapper instead of the video, for
354
427
  when the video is `inset: 0` inside the element that carries the layout.
355
428
 
356
429
  `registerAll(target)` takes the same shapes `revealImages` does (a selector, an
357
- element, or any collection) and registers each. It does not accept `observe`:
430
+ element, or any collection) and registers each; called with nothing it takes
431
+ `'[data-polite-media] video'`, which is what every known caller had written out.
432
+ It does not accept `observe`:
358
433
  each observed element maps to exactly one video, and `register` refuses a second
359
434
  video on a target it already watches, with a warning.
360
435
 
@@ -401,10 +476,14 @@ with `'interaction'`, so "wait for the visitor, and also wait for the buffer"
401
476
  could not be expressed at all.
402
477
 
403
478
  Two consequences. A page whose `load` never fires never starts its videos, and
404
- `requireBuffered` is one of two places the library changes markup you authored:
405
- `prefetch()` also promotes `preload` to `'auto'` for any video within a
406
- configured `prefetchMargin`. `until` composes with all of it, and a video waits
407
- for every gate that applies to it.
479
+ `requireBuffered` is one of four places the library changes the element you
480
+ authored. `prefetch()` also promotes `preload` to `'auto'` for any video within
481
+ a configured `prefetchMargin`; `muted` is set before every `play()`, since muted
482
+ is the one condition autoplay can rely on; and when a `<source>` list is
483
+ resolved, `src` is assigned the chosen file and `load()` is called, so
484
+ `currentSrc` reflects that choice and the `<source>` children are no longer
485
+ consulted. `until` composes with all of it, and a video waits for every gate
486
+ that applies to it.
408
487
 
409
488
  ### `pauseBelow`
410
489
 
@@ -544,7 +623,9 @@ Two things that surprise people:
544
623
  that is invalid at computed-value time falls back to the property's initial
545
624
  value, and the `var()` fallback only applies when the property is undefined.
546
625
  Harmless on video, which cuts anyway; on images it silently removes the 350ms
547
- fade.
626
+ fade. The console names the first image or video box where it is set to
627
+ something that is not a time. A bare `0` is left alone, since the cut it gives
628
+ is what it looks like it asks for.
548
629
 
549
630
  Reduced motion overrides you either way: the stylesheet sets `transition: none`
550
631
  there, so a fade you asked for never runs for someone who asked not to see one.
@@ -567,6 +648,11 @@ warmOnIntent('a[data-hero]', (link) => ({
567
648
  }));
568
649
  ```
569
650
 
651
+ Give a format through `sources` with its `type`, as above, rather than as a bare `srcset`: a
652
+ browser that cannot decode AVIF then skips it and warms the fallback, instead of fetching a file it
653
+ cannot show. `sizes` goes on every `<source>` and on the image, since a `<source>` without its own
654
+ is chosen as if it said `100vw`.
655
+
570
656
  **Nothing in this package parses a media query**, which is the point: the
571
657
  candidates are assembled as a detached `<picture>` and the browser picks, running
572
658
  the same algorithm it will run on the destination. Hand-rolling that selection
@@ -615,6 +701,24 @@ document.addEventListener('astro:page-load', () => registerAll('[data-polite-med
615
701
  registered twice. Videos that did not survive need no cleanup: the coordinator
616
702
  drops any entry whose element has left the document on its next pass.
617
703
 
704
+ ## Astro
705
+
706
+ This is developed against Astro projects, and the same three things come up.
707
+
708
+ **Import each stylesheet from the component that emits its attribute.** A build folds every
709
+ component's CSS into one file, so a stylesheet imported anywhere covers everywhere, while a dev
710
+ server serves it per component and does not. Import `image.css` from some other component and the
711
+ fade works in the build and is missing in dev, on exactly the pages that do not render whichever
712
+ component happened to import it. The console says so when the stylesheet never arrives.
713
+
714
+ **Put the `<script>` in the component that renders the media**, not in the layout. Astro bundles a
715
+ component's script once per page, so a page that never renders that component never downloads the
716
+ coordinator.
717
+
718
+ **A strict CSP needs nothing from this package.** It writes attributes and never an inline `style`,
719
+ which the end-to-end suite asserts on every demo, so `style-src` needs no `'unsafe-inline'` on its
720
+ account.
721
+
618
722
  ## What it does
619
723
 
620
724
  - Reveals on a genuinely presented frame, never on `playing`.
@@ -622,9 +726,11 @@ drops any entry whose element has left the document on its next pass.
622
726
  - Caps how many videos run at once, on any viewport, not just small ones.
623
727
  - Falls through to the next `<source>` when one can't be decoded.
624
728
  - Honours `prefers-reduced-motion` live, and Save-Data on the next reconcile.
625
- - Recovers from bfcache restores, tab refocus and blocked autoplay.
729
+ - Recovers from bfcache restores, tab refocus and blocked autoplay, and says so
730
+ while it waits.
626
731
  - Ships a pause control hook for [WCAG 2.2.2][wcag].
627
- - Emits `polite-video:ready`, `polite-video:failed` and `polite-video:pausechange`.
732
+ - Emits `polite-video:ready`, `polite-video:failed`, `polite-video:blocked` and
733
+ `polite-video:pausechange`.
628
734
 
629
735
  ## What it deliberately doesn't do
630
736
 
@@ -641,9 +747,12 @@ candidates you already build, and generates none.
641
747
  every image inside it, including ones the library then declines to fade, so each
642
748
  one waits out the failsafe instead of fading.
643
749
 
644
- **Eager images are revealed instantly rather than faded.** LCP excludes elements
645
- at `opacity: 0` and revealing one doesn't restore its candidacy. Pass
646
- `{ allowEager: true }` to fade anyway.
750
+ **Eager images are never hidden, so they cut rather than fade.** LCP excludes
751
+ elements at `opacity: 0`, and a deferred module cannot reveal one before first
752
+ paint, so `image.css` hides only `loading="lazy"` images and the module marks an
753
+ eager one ready at once. To fade an eager image anyway, write
754
+ `data-polite-reveal="eager"` on it: the choice has to be in the markup, where
755
+ the stylesheet can see it, which is why the old `allowEager` option is gone.
647
756
 
648
757
  **An image needs a backdrop.** Video degrades to its poster; a lone image
649
758
  degrades to nothing, so its container must carry a visible `background-color`.
@@ -717,11 +826,12 @@ Framework-agnostic by construction: no framework dependency, standard DOM only,
717
826
  developed against Astro projects.
718
827
 
719
828
  **Tested on Chromium, Firefox and WebKit**, all three driven by Playwright
720
- against real media on every change, alongside 222 unit tests. Other engines are
829
+ against real media on every change, alongside 233 unit tests. Other engines are
721
830
  unexercised rather than unsupported.
722
831
 
723
832
  **iOS Safari is outside the matrix**, and Playwright's WebKit does not stand in
724
- for it. The path that rests on it is the retry after a refused `play()`, which
833
+ for it. The path that rests on it is the retry after a refused `play()`, announced
834
+ as `polite-video:blocked` so it can be seen in the field, which
725
835
  rejects with `NotAllowedError`; the library follows MDN's documented remedy of
726
836
  surfacing a control and waiting for a gesture.
727
837
 
@@ -52,8 +52,13 @@ export interface ConfigureOptions {
52
52
  */
53
53
  prefetchMargin?: string;
54
54
  /**
55
- * Which viewports count as small. Configurable because 767px is one project's
55
+ * Which viewports count as small. Configurable because 768px is one project's
56
56
  * breakpoint, not a fact about phones.
57
+ *
58
+ * The default stops at 767.98px, leaving 0.02px beside a `(min-width: 768px)`
59
+ * rather than the whole pixel a zoomed page's fractional width can fall into.
60
+ * Range syntax would close the gap, but Safari before 16.4 never matches it, so
61
+ * phones there would count as large.
57
62
  */
58
63
  smallViewport?: string;
59
64
  /**
@@ -158,6 +163,9 @@ export interface RegisterOptions {
158
163
  * at scroll-top is reported visible in the observer's very first batch, so
159
164
  * without a gate it starts before whatever the page is waiting on (a splash
160
165
  * screen, a consent dialog) has finished.
166
+ *
167
+ * Settling, fulfilled or rejected, is the only release: a promise that never
168
+ * settles holds the video on its poster for the life of the page.
161
169
  */
162
170
  until?: Promise<unknown>;
163
171
  /**
@@ -173,14 +181,38 @@ export interface RegisterOptions {
173
181
  * the same rule buys nothing.
174
182
  */
175
183
  startWhen?: StartWhen;
184
+ /**
185
+ * Let the video download while `until` holds it, so a splash buys it load time.
186
+ * Only playback waits for the gate; the download still waits for `startWhen`,
187
+ * Save-Data, reduced motion and a user pause, and starts once the video is on
188
+ * screen or within `prefetchMargin`.
189
+ *
190
+ * Opt-in, because a gate holding the fetch too is what `until` has always
191
+ * done, and a page gating a video behind a consent dialog may rely on it.
192
+ */
193
+ prefetchWhileGated?: boolean;
176
194
  }
177
195
  export declare function reconcile(): void;
196
+ /**
197
+ * Starts managing a video: reveals it on its first genuinely painted frame,
198
+ * plays it only while it is visible, falls through its `<source>` list when one
199
+ * cannot be decoded, and stops it when a gate closes.
200
+ *
201
+ * The video and its poster must already share a box carrying `data-polite-media`
202
+ * in the authored markup, and the video should be `muted loop playsinline
203
+ * preload="none"`. Calling this twice on the same element is a no-op.
204
+ *
205
+ * @param video the element to manage
206
+ * @param options see {@link RegisterOptions}
207
+ */
178
208
  export declare function register(video: HTMLVideoElement, options?: RegisterOptions): void;
179
209
  /** Anything that names one or more videos. See {@link Target}. */
180
210
  export type VideoTarget = Target<HTMLVideoElement>;
181
211
  /**
182
212
  * Registers every video a target names, so the common case is one line and
183
- * matches `revealImages` on the image side rather than being a second idea.
213
+ * matches `revealImages` on the image side rather than being a second idea. With
214
+ * no target it takes every video inside a `[data-polite-media]` box, which is
215
+ * what every known caller passed by hand.
184
216
  *
185
217
  * `observe` is deliberately not accepted. Each observed element maps to exactly
186
218
  * one entry, so handing the same wrapper to several videos would silently
@@ -190,13 +222,29 @@ export type VideoTarget = Target<HTMLVideoElement>;
190
222
  * Idempotent, because `register` is: safe to call on every navigation of a
191
223
  * client-side router, where module scripts do not re-run.
192
224
  */
193
- export declare function registerAll(target: VideoTarget, options?: Omit<RegisterOptions, 'observe'>): void;
225
+ export declare function registerAll(target?: VideoTarget, options?: Omit<RegisterOptions, 'observe'>): void;
194
226
  /**
195
227
  * Stops managing a video and releases everything it owned: the observer entry,
196
228
  * any pending pause timer, its listeners, and the page-level listeners once it
197
229
  * was the last one. Safe to call for a video that was never registered.
198
230
  */
199
231
  export declare function unregister(video: HTMLVideoElement): void;
232
+ /**
233
+ * Stops every managed video and keeps them stopped.
234
+ *
235
+ * WCAG 2.2.2 applies to content that moves automatically, runs for more than
236
+ * five seconds, and sits alongside other content -- which a looping background
237
+ * video does. Honouring `prefers-reduced-motion` is necessary but, per the W3C
238
+ * understanding document, is not listed as satisfying the criterion, so a
239
+ * mechanism the user can actually operate has to exist.
240
+ *
241
+ * The host supplies the button and its styling; the library ships no markup and
242
+ * no CSS for it. A `<button>` carrying `data-polite-pause-control` toggles this.
243
+ *
244
+ * It has to be a real `<button>`. The binding is a delegated `click`, and a
245
+ * browser only synthesises that from Enter and Space for a native button, so a
246
+ * `div[role="button"][tabindex="0"]` responds to a mouse and not to a keyboard.
247
+ */
200
248
  export declare function pauseAll(): void;
201
249
  /** Lets playback resume, undoing {@link pauseAll}. */
202
250
  export declare function resumeAll(): void;
@@ -1 +1 @@
1
- {"version":3,"file":"coordinator.d.ts","sourceRoot":"","sources":["../src/coordinator.ts"],"names":[],"mappings":"AAUA,OAAO,EAAkB,KAAK,MAAM,EAAE,MAAM,cAAc,CAAC;AAE3D;;;;;;;;GAQG;AAEH;;;;;GAKG;AACH,MAAM,MAAM,MAAM,GAAG,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;AAEnC;;;;;;GAMG;AACH,MAAM,MAAM,SAAS,GAAG,SAAS,GAAG,aAAa,GAAG,aAAa,CAAC;AAElE;;;;;;;;GAQG;AACH,MAAM,WAAW,gBAAgB;IAC/B;;;;;;;;;;;;;;;;OAgBG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;;;;;;;;;;;;;;OAkBG;IACH,MAAM,CAAC,EAAE,MAAM,GAAG;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;IACnD;;;;;;;;;;;;;OAaG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA+BG;IACH,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB;;;;;;;;;;;;;OAaG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B;AA4ED;;;;;;;GAOG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,gBAAgB,GAAG,IAAI,CAcvD;AAwDD,MAAM,WAAW,eAAe;IAC9B;;;;;OAKG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC,OAAO,CAAC,CAAC;IACzB;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,SAAS,CAAC;CACvB;AAuhBD,wBAAgB,SAAS,IAAI,IAAI,CAsFhC;AA+OD,wBAAgB,QAAQ,CAAC,KAAK,EAAE,gBAAgB,EAAE,OAAO,GAAE,eAAoB,GAAG,IAAI,CA0DrF;AAED,kEAAkE;AAClE,MAAM,MAAM,WAAW,GAAG,MAAM,CAAC,gBAAgB,CAAC,CAAC;AAEnD;;;;;;;;;;;GAWG;AACH,wBAAgB,WAAW,CACzB,MAAM,EAAE,WAAW,EACnB,OAAO,GAAE,IAAI,CAAC,eAAe,EAAE,SAAS,CAAM,GAC7C,IAAI,CAEN;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,gBAAgB,GAAG,IAAI,CAwBxD;AAoED,wBAAgB,QAAQ,IAAI,IAAI,CAG/B;AAED,sDAAsD;AACtD,wBAAgB,SAAS,IAAI,IAAI,CAGhC;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,IAAI,IAAI,CAQpC;AAED,2EAA2E;AAC3E,wBAAgB,aAAa,IAAI,IAAI,CAQpC;AAED,0EAA0E;AAC1E,wBAAgB,OAAO,IAAI;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,OAAO,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE,CAErF"}
1
+ {"version":3,"file":"coordinator.d.ts","sourceRoot":"","sources":["../src/coordinator.ts"],"names":[],"mappings":"AAYA,OAAO,EAAkB,KAAK,MAAM,EAAE,MAAM,cAAc,CAAC;AAE3D;;;;;;;;GAQG;AAEH;;;;;GAKG;AACH,MAAM,MAAM,MAAM,GAAG,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;AAEnC;;;;;;GAMG;AACH,MAAM,MAAM,SAAS,GAAG,SAAS,GAAG,aAAa,GAAG,aAAa,CAAC;AAElE;;;;;;;;GAQG;AACH,MAAM,WAAW,gBAAgB;IAC/B;;;;;;;;;;;;;;;;OAgBG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;;;;OAQG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;;;;;;;;;;;;;;OAkBG;IACH,MAAM,CAAC,EAAE,MAAM,GAAG;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;IACnD;;;;;;;;;;;;;OAaG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA+BG;IACH,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB;;;;;;;;;;;;;OAaG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B;AA4ED;;;;;;;GAOG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,gBAAgB,GAAG,IAAI,CAcvD;AA0DD,MAAM,WAAW,eAAe;IAC9B;;;;;;;;OAQG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC,OAAO,CAAC,CAAC;IACzB;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB;;;;;;;;OAQG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAC;CAC9B;AA0lBD,wBAAgB,SAAS,IAAI,IAAI,CA4FhC;AAmOD;;;;;;;;;;;GAWG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,gBAAgB,EAAE,OAAO,GAAE,eAAoB,GAAG,IAAI,CAgErF;AAED,kEAAkE;AAClE,MAAM,MAAM,WAAW,GAAG,MAAM,CAAC,gBAAgB,CAAC,CAAC;AAEnD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,WAAW,CACzB,MAAM,GAAE,WAAyC,EACjD,OAAO,GAAE,IAAI,CAAC,eAAe,EAAE,SAAS,CAAM,GAC7C,IAAI,CAEN;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,gBAAgB,GAAG,IAAI,CAwBxD;AAiDD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,QAAQ,IAAI,IAAI,CAG/B;AAED,sDAAsD;AACtD,wBAAgB,SAAS,IAAI,IAAI,CAGhC;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,IAAI,IAAI,CAIpC;AAED,2EAA2E;AAC3E,wBAAgB,aAAa,IAAI,IAAI,CAQpC;AAED,0EAA0E;AAC1E,wBAAgB,OAAO,IAAI;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,OAAO,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE,CAErF"}