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 +123 -0
- package/README.md +167 -57
- package/dist/coordinator.d.ts +51 -3
- package/dist/coordinator.d.ts.map +1 -1
- package/dist/coordinator.js +172 -105
- package/dist/coordinator.js.map +1 -1
- package/dist/events.d.ts +8 -0
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +6 -0
- package/dist/events.js.map +1 -1
- package/dist/fade.d.ts +11 -0
- package/dist/fade.d.ts.map +1 -0
- package/dist/fade.js +17 -0
- package/dist/fade.js.map +1 -0
- package/dist/image.css +1 -1
- package/dist/image.d.ts +6 -13
- package/dist/image.d.ts.map +1 -1
- package/dist/image.js +38 -20
- package/dist/image.js.map +1 -1
- package/dist/layer.css +1 -1
- package/dist/sources.js +4 -4
- package/dist/targets.d.ts.map +1 -1
- package/dist/targets.js +0 -2
- package/dist/targets.js.map +1 -1
- package/dist/video.css +1 -1
- package/dist/video.d.ts +1 -1
- package/dist/video.d.ts.map +1 -1
- package/dist/video.js +1 -1
- package/dist/video.js.map +1 -1
- package/dist/warming.d.ts +4 -1
- package/dist/warming.d.ts.map +1 -1
- package/dist/warming.js +3 -0
- package/dist/warming.js.map +1 -1
- package/package.json +13 -13
- package/src/coordinator.ts +221 -125
- package/src/events.ts +9 -0
- package/src/fade.ts +19 -0
- package/src/image.css +13 -6
- package/src/image.ts +49 -31
- package/src/layer.css +3 -2
- package/src/sources.ts +4 -4
- package/src/targets.ts +0 -2
- package/src/video.css +32 -19
- package/src/video.ts +1 -0
- package/src/warming.ts +6 -1
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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>`
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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.
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
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
|
|
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`, `
|
|
277
|
-
`POLITE_VIDEO_PAUSECHANGE`), because a mistyped event
|
|
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:
|
|
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`
|
|
330
|
-
not throw and does not normalise to anything recognisable; Chromium echoes
|
|
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
|
|
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
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
|
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
|
|
405
|
-
`prefetch()` also promotes `preload` to `'auto'` for any video within
|
|
406
|
-
configured `prefetchMargin
|
|
407
|
-
|
|
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
|
|
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
|
|
645
|
-
at `opacity: 0
|
|
646
|
-
`
|
|
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
|
|
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()`,
|
|
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
|
|
package/dist/coordinator.d.ts
CHANGED
|
@@ -52,8 +52,13 @@ export interface ConfigureOptions {
|
|
|
52
52
|
*/
|
|
53
53
|
prefetchMargin?: string;
|
|
54
54
|
/**
|
|
55
|
-
* Which viewports count as small. Configurable because
|
|
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
|
|
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":"
|
|
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"}
|