polite-media 0.4.2 → 0.5.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/src/image.ts CHANGED
@@ -24,22 +24,16 @@ export type ImageTarget = Target<HTMLImageElement>;
24
24
 
25
25
  export interface RevealImagesOptions {
26
26
  /**
27
- * Manage images that are not `loading="lazy"`.
28
- *
29
- * Off by default because LCP excludes elements at `opacity: 0`, and revealing
30
- * one does not restore its candidacy -- so fading an eager, above-the-fold
31
- * image can forfeit the metric it was meant to improve. A lazy image was never
32
- * an LCP candidate, so the default is risk-free.
33
- *
34
- * Turning this on is legitimate and sometimes right: an eager grid that would
35
- * otherwise cut from its backdrop to the photo on whatever frame the async
36
- * decode lands looks worse without a fade. It just has to be a decision rather
37
- * than an accident.
27
+ * @deprecated Ignored since 0.5, and warns. image.css no longer hides an eager
28
+ * image at all, so the opt-in has to be in the markup where the stylesheet can
29
+ * see it: `data-polite-reveal="eager"` on the image.
38
30
  */
39
31
  allowEager?: boolean;
40
32
  }
41
33
 
42
34
  const READY = 'data-polite-ready';
35
+ /** Written while this module owns an image's reveal, so image.css can stand down. */
36
+ const MANAGED = 'data-polite-managed';
43
37
 
44
38
  function markReady(image: HTMLImageElement): void {
45
39
  image.setAttribute(READY, '');
@@ -52,11 +46,12 @@ function markReady(image: HTMLImageElement): void {
52
46
  }
53
47
 
54
48
  /**
55
- * Every image this module has taken responsibility for.
56
- *
57
- * A WeakSet rather than an attribute: the stylesheet no longer needs to know
58
- * which images are managed, so writing it into the DOM would be state kept for
59
- * nobody. Weak so a released image is not pinned by the bookkeeping.
49
+ * Every image a `revealImages()` call has ever claimed, for the stray-image
50
+ * warning. Distinct from `data-polite-managed`, which marks an image only while
51
+ * this module still owns its reveal so the stylesheet's failsafe stands down: a
52
+ * teardown removes the attribute but the claim stays, since the warning is about
53
+ * selectors that never reached an image at all. Weak so a released image is not
54
+ * pinned by the bookkeeping.
60
55
  */
61
56
  const managed = new WeakSet<HTMLImageElement>();
62
57
 
@@ -90,6 +85,7 @@ function scheduleUnmanagedCheck(): void {
90
85
  }
91
86
 
92
87
  let warnedNoStylesheet = false;
88
+ let warnedAllowEager = false;
93
89
 
94
90
  /**
95
91
  * image.css is the only thing that hides a marked image. Without it `data-polite-reveal` is inert:
@@ -114,6 +110,8 @@ function warnIfUnstyled(): void {
114
110
  // removed in this same task so nothing paints it and no scan above ever sees it.
115
111
  const probe = document.createElement('img');
116
112
  probe.setAttribute('data-polite-reveal', '');
113
+ // The stylesheet hides only lazy images, so an eager probe would read as unstyled everywhere.
114
+ probe.setAttribute('loading', 'lazy');
117
115
  document.body.appendChild(probe);
118
116
  const opacity = getComputedStyle(probe).opacity;
119
117
  probe.remove();
@@ -131,33 +129,52 @@ function warnIfUnstyled(): void {
131
129
  }
132
130
 
133
131
  /**
134
- * Reveals each matching image once it has decoded.
132
+ * Reveals each matching image once it has decoded. With no target it takes every
133
+ * marked image on the page.
135
134
  *
136
135
  * Returns a function that stops any reveals still pending, for a client-side
137
136
  * router tearing the page down before the images resolved.
138
137
  */
139
- export function revealImages(target: ImageTarget, options: RevealImagesOptions = {}): () => void {
138
+ export function revealImages(
139
+ target: ImageTarget = 'img[data-polite-reveal]',
140
+ options: RevealImagesOptions = {}
141
+ ): () => void {
140
142
  const controller = new AbortController();
141
143
  const { signal } = controller;
142
144
 
145
+ if (options.allowEager && !warnedAllowEager) {
146
+ warnedAllowEager = true;
147
+ console.warn(
148
+ 'polite-media: allowEager is ignored. To fade an eager image, put ' +
149
+ 'data-polite-reveal="eager" on it, where the stylesheet can see the choice.'
150
+ );
151
+ }
152
+
143
153
  scheduleUnmanagedCheck();
144
154
 
155
+ const claimed: HTMLImageElement[] = [];
156
+
145
157
  for (const image of resolveTargets(target)) {
146
158
  managed.add(image);
147
- // Eager images are revealed at once rather than skipped.
148
- //
149
- // Skipping looks like the cautious choice and is the opposite: image.css
150
- // has already hidden anything carrying data-polite-reveal, so declining to
151
- // manage it leaves it invisible permanently instead of merely unfaded.
152
- // Revealing immediately keeps the LCP candidate visible from its first
153
- // paint, which is the whole reason eager images are treated differently.
159
+ // Tells the stylesheet to leave this one alone. The failsafe there is for images nothing
160
+ // reveals, and firing it on one this module owns is worse than not firing it at all: opacity
161
+ // reaches 1 while the picture is still in flight, so when it arrives there is no fade left to
162
+ // run. Measured on a real page, eleven below-the-fold images were revealed that way five
163
+ // seconds in, none of them loaded.
164
+ image.setAttribute(MANAGED, '');
165
+ claimed.push(image);
166
+ // An eager image is never hidden by image.css, because LCP excludes elements at
167
+ // opacity 0 and a deferred module cannot reveal one before first paint. So it
168
+ // is marked ready at once, unless the markup opted it into the fade with the
169
+ // same value the stylesheet keys on.
154
170
  //
155
171
  // Tested against 'lazy' rather than for 'eager' deliberately. Engines
156
172
  // disagree on what an absent or invalid attribute reports: MDN documents
157
173
  // only 'eager' and 'lazy', while happy-dom returns 'auto'. Only "lazy" has
158
174
  // one agreed spelling, so asking whether it is lazy is answerable
159
175
  // everywhere, and everything else correctly falls into the eager branch.
160
- if (!options.allowEager && image.loading !== 'lazy') {
176
+ const optedIn = image.getAttribute('data-polite-reveal') === 'eager';
177
+ if (!optedIn && image.loading !== 'lazy') {
161
178
  markReady(image);
162
179
  continue;
163
180
  }
@@ -193,7 +210,15 @@ export function revealImages(target: ImageTarget, options: RevealImagesOptions =
193
210
  });
194
211
  }
195
212
 
196
- return () => controller.abort();
213
+ // Hands anything still unrevealed back to the stylesheet. A router tearing the page down before
214
+ // the images resolved would otherwise leave them owned by a module that has stopped listening,
215
+ // and with the failsafe suppressed that is hidden forever rather than merely unfaded.
216
+ return () => {
217
+ controller.abort();
218
+ for (const image of claimed) {
219
+ if (!image.hasAttribute(READY)) image.removeAttribute(MANAGED);
220
+ }
221
+ };
197
222
  }
198
223
 
199
224
  // Re-exported so `polite-media/image` carries the ElementEventMap and
package/src/layer.css CHANGED
@@ -50,8 +50,9 @@
50
50
  * object-fit needs the image for its own reason: it "Applies to: replaced
51
51
  * elements", and a `<picture>` is a container rather than one.
52
52
  */
53
- [data-polite-media] > :is(img, video),
54
- [data-polite-media] > picture > img {
53
+ [data-polite-media] > img,
54
+ [data-polite-media] > picture > img,
55
+ [data-polite-media] video {
55
56
  position: absolute;
56
57
  inset: 0;
57
58
  width: 100%;
package/src/video.css CHANGED
@@ -6,13 +6,13 @@
6
6
  * is why it works at any video size, and it would be undone by a single width
7
7
  * declaration here.
8
8
  *
9
- * `data-polite-media` goes on the container in your markup, not from script.
10
- * It has to be authored: a <video preload="none"> paints an empty box, and if
11
- * the base "hidden" state only arrived once JS ran, a page whose bundle failed
12
- * would show that empty box stacked over the poster. Authored, the safe state is
13
- * the default and the library only ever has to reveal.
9
+ * `data-polite-media` goes on the box in your markup, not from script. It has
10
+ * to be authored: a <video preload="none"> paints an empty box, and if the base
11
+ * "hidden" state only arrived once JS ran, a page whose bundle failed would
12
+ * show that empty box stacked over the poster. Authored, the safe state is the
13
+ * default and the library only ever has to reveal.
14
14
  *
15
- * Expected shape -- poster first, video second, sharing one box:
15
+ * Expected shape -- poster a direct child, video anywhere inside, one box:
16
16
  *
17
17
  * <div class="whatever-you-like" data-polite-media>
18
18
  * <img src="poster.avif" alt="">
@@ -21,9 +21,15 @@
21
21
  * </video>
22
22
  * </div>
23
23
  *
24
- * Attributes the library sets on that container:
25
- * data-polite-ready a frame has actually painted
26
- * data-polite-failed no source could be decoded; the poster is now permanent
24
+ * The video rules use a descendant combinator so a wrapper between box and
25
+ * video is fine; the poster rule stays a direct child so a logo deeper in the
26
+ * box is left alone. One box holds one video, and boxes must not nest: the
27
+ * ready state reveals every video inside the box.
28
+ *
29
+ * Attributes the library sets on that box:
30
+ * data-polite-ready a frame has actually painted
31
+ * data-polite-failed no source could be decoded; the poster is now permanent
32
+ * data-polite-blocked play() refused until a gesture; the library is retrying
27
33
  * And on <html>:
28
34
  * data-polite-paused the user stopped playback via a [data-polite-pause-control] control
29
35
  *
@@ -38,12 +44,12 @@
38
44
  * of an async decode landing. Set the property to override both at once.
39
45
  */
40
46
 
41
- [data-polite-media] > video {
47
+ [data-polite-media] video {
42
48
  opacity: 0;
43
49
  transition: opacity var(--polite-fade, 0s) ease;
44
50
  }
45
51
 
46
- [data-polite-media][data-polite-ready] > video {
52
+ [data-polite-media][data-polite-ready] video {
47
53
  opacity: 1;
48
54
  }
49
55
 
@@ -62,12 +68,12 @@
62
68
  * half-decoded frame stuck at opacity 1 is exactly the state a viewer would
63
69
  * report as broken.
64
70
  */
65
- [data-polite-media][data-polite-failed] > video {
71
+ [data-polite-media][data-polite-failed] video {
66
72
  opacity: 0;
67
73
  }
68
74
 
69
75
  @media (prefers-reduced-motion: reduce) {
70
- [data-polite-media] > video,
76
+ [data-polite-media] video,
71
77
  [data-polite-media] > :is(img, picture) {
72
78
  transition: none;
73
79
  }
package/src/video.ts CHANGED
@@ -24,6 +24,7 @@ export type { AtOnce, ConfigureOptions, RegisterOptions, VideoTarget } from './c
24
24
  // ElementEventMap and DocumentEventMap augmentation only reaches a consumer if
25
25
  // this module is part of their program.
26
26
  export {
27
+ POLITE_VIDEO_BLOCKED,
27
28
  POLITE_VIDEO_PAUSECHANGE,
28
29
  POLITE_VIDEO_FAILED,
29
30
  POLITE_VIDEO_READY,