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/dist/events.d.ts CHANGED
@@ -24,6 +24,12 @@
24
24
  export declare const POLITE_VIDEO_READY = "polite-video:ready";
25
25
  /** Dispatched on the video when no source could be decoded. Bubbles. */
26
26
  export declare const POLITE_VIDEO_FAILED = "polite-video:failed";
27
+ /**
28
+ * Dispatched on the video when the browser refused `play()` until a user gesture.
29
+ * Bubbles. The library keeps retrying on its own; this exists so a host can show
30
+ * a play affordance in the meantime, which the library cannot do for it.
31
+ */
32
+ export declare const POLITE_VIDEO_BLOCKED = "polite-video:blocked";
27
33
  /** Dispatched on the image once it has decoded. Bubbles. */
28
34
  export declare const POLITE_IMAGE_READY = "polite-image:ready";
29
35
  /**
@@ -58,11 +64,13 @@ declare global {
58
64
  interface ElementEventMap {
59
65
  'polite-video:ready': CustomEvent<PoliteVideoEventDetail>;
60
66
  'polite-video:failed': CustomEvent<PoliteVideoEventDetail>;
67
+ 'polite-video:blocked': CustomEvent<PoliteVideoEventDetail>;
61
68
  'polite-image:ready': CustomEvent<PoliteImageEventDetail>;
62
69
  }
63
70
  interface DocumentEventMap {
64
71
  'polite-video:ready': CustomEvent<PoliteVideoEventDetail>;
65
72
  'polite-video:failed': CustomEvent<PoliteVideoEventDetail>;
73
+ 'polite-video:blocked': CustomEvent<PoliteVideoEventDetail>;
66
74
  'polite-image:ready': CustomEvent<PoliteImageEventDetail>;
67
75
  'polite-video:pausechange': CustomEvent<PolitePauseEventDetail>;
68
76
  }
@@ -1 +1 @@
1
- {"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../src/events.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,2EAA2E;AAC3E,eAAO,MAAM,kBAAkB,uBAAuB,CAAC;AAEvD,wEAAwE;AACxE,eAAO,MAAM,mBAAmB,wBAAwB,CAAC;AAEzD,4DAA4D;AAC5D,eAAO,MAAM,kBAAkB,uBAAuB,CAAC;AAEvD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,wBAAwB,6BAA6B,CAAC;AAEnE,MAAM,WAAW,sBAAsB;IACrC;;;OAGG;IACH,KAAK,EAAE,gBAAgB,CAAC;CACzB;AAED,MAAM,WAAW,sBAAsB;IACrC,8CAA8C;IAC9C,KAAK,EAAE,gBAAgB,CAAC;CACzB;AAED,MAAM,WAAW,sBAAsB;IACrC,wEAAwE;IACxE,MAAM,EAAE,OAAO,CAAC;CACjB;AAED,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,eAAe;QACvB,oBAAoB,EAAE,WAAW,CAAC,sBAAsB,CAAC,CAAC;QAC1D,qBAAqB,EAAE,WAAW,CAAC,sBAAsB,CAAC,CAAC;QAC3D,oBAAoB,EAAE,WAAW,CAAC,sBAAsB,CAAC,CAAC;KAC3D;IAED,UAAU,gBAAgB;QACxB,oBAAoB,EAAE,WAAW,CAAC,sBAAsB,CAAC,CAAC;QAC1D,qBAAqB,EAAE,WAAW,CAAC,sBAAsB,CAAC,CAAC;QAC3D,oBAAoB,EAAE,WAAW,CAAC,sBAAsB,CAAC,CAAC;QAC1D,0BAA0B,EAAE,WAAW,CAAC,sBAAsB,CAAC,CAAC;KACjE;CACF"}
1
+ {"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../src/events.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,2EAA2E;AAC3E,eAAO,MAAM,kBAAkB,uBAAuB,CAAC;AAEvD,wEAAwE;AACxE,eAAO,MAAM,mBAAmB,wBAAwB,CAAC;AAEzD;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,yBAAyB,CAAC;AAE3D,4DAA4D;AAC5D,eAAO,MAAM,kBAAkB,uBAAuB,CAAC;AAEvD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,wBAAwB,6BAA6B,CAAC;AAEnE,MAAM,WAAW,sBAAsB;IACrC;;;OAGG;IACH,KAAK,EAAE,gBAAgB,CAAC;CACzB;AAED,MAAM,WAAW,sBAAsB;IACrC,8CAA8C;IAC9C,KAAK,EAAE,gBAAgB,CAAC;CACzB;AAED,MAAM,WAAW,sBAAsB;IACrC,wEAAwE;IACxE,MAAM,EAAE,OAAO,CAAC;CACjB;AAED,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,eAAe;QACvB,oBAAoB,EAAE,WAAW,CAAC,sBAAsB,CAAC,CAAC;QAC1D,qBAAqB,EAAE,WAAW,CAAC,sBAAsB,CAAC,CAAC;QAC3D,sBAAsB,EAAE,WAAW,CAAC,sBAAsB,CAAC,CAAC;QAC5D,oBAAoB,EAAE,WAAW,CAAC,sBAAsB,CAAC,CAAC;KAC3D;IAED,UAAU,gBAAgB;QACxB,oBAAoB,EAAE,WAAW,CAAC,sBAAsB,CAAC,CAAC;QAC1D,qBAAqB,EAAE,WAAW,CAAC,sBAAsB,CAAC,CAAC;QAC3D,sBAAsB,EAAE,WAAW,CAAC,sBAAsB,CAAC,CAAC;QAC5D,oBAAoB,EAAE,WAAW,CAAC,sBAAsB,CAAC,CAAC;QAC1D,0BAA0B,EAAE,WAAW,CAAC,sBAAsB,CAAC,CAAC;KACjE;CACF"}
package/dist/events.js CHANGED
@@ -24,6 +24,12 @@
24
24
  export const POLITE_VIDEO_READY = 'polite-video:ready';
25
25
  /** Dispatched on the video when no source could be decoded. Bubbles. */
26
26
  export const POLITE_VIDEO_FAILED = 'polite-video:failed';
27
+ /**
28
+ * Dispatched on the video when the browser refused `play()` until a user gesture.
29
+ * Bubbles. The library keeps retrying on its own; this exists so a host can show
30
+ * a play affordance in the meantime, which the library cannot do for it.
31
+ */
32
+ export const POLITE_VIDEO_BLOCKED = 'polite-video:blocked';
27
33
  /** Dispatched on the image once it has decoded. Bubbles. */
28
34
  export const POLITE_IMAGE_READY = 'polite-image:ready';
29
35
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"events.js","sourceRoot":"","sources":["../src/events.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,2EAA2E;AAC3E,MAAM,CAAC,MAAM,kBAAkB,GAAG,oBAAoB,CAAC;AAEvD,wEAAwE;AACxE,MAAM,CAAC,MAAM,mBAAmB,GAAG,qBAAqB,CAAC;AAEzD,4DAA4D;AAC5D,MAAM,CAAC,MAAM,kBAAkB,GAAG,oBAAoB,CAAC;AAEvD;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,wBAAwB,GAAG,0BAA0B,CAAC"}
1
+ {"version":3,"file":"events.js","sourceRoot":"","sources":["../src/events.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,2EAA2E;AAC3E,MAAM,CAAC,MAAM,kBAAkB,GAAG,oBAAoB,CAAC;AAEvD,wEAAwE;AACxE,MAAM,CAAC,MAAM,mBAAmB,GAAG,qBAAqB,CAAC;AAEzD;;;;GAIG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,sBAAsB,CAAC;AAE3D,4DAA4D;AAC5D,MAAM,CAAC,MAAM,kBAAkB,GAAG,oBAAoB,CAAC;AAEvD;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,wBAAwB,GAAG,0BAA0B,CAAC"}
package/dist/image.css CHANGED
@@ -1 +1 @@
1
- @media(scripting:enabled){img[data-polite-reveal]{opacity:0;transition:opacity var(--polite-fade, .35s) ease}img[data-polite-reveal]:not([data-polite-ready]){animation:polite-reveal-failsafe 0s var(--polite-failsafe, 5s) forwards}img[data-polite-reveal][data-polite-ready]{opacity:1}@keyframes polite-reveal-failsafe{to{opacity:1}}}@media(prefers-reduced-motion:reduce){img[data-polite-reveal]{transition:none}}
1
+ @media(scripting:enabled){img[data-polite-reveal][loading=lazy],img[data-polite-reveal=eager]{opacity:0;transition:opacity var(--polite-fade, .35s) ease}img[data-polite-reveal][loading=lazy]:not([data-polite-ready]):not([data-polite-managed]),img[data-polite-reveal=eager]:not([data-polite-ready]):not([data-polite-managed]){animation:polite-reveal-failsafe 0s var(--polite-failsafe, 5s) forwards}img[data-polite-reveal][data-polite-ready]{opacity:1}@keyframes polite-reveal-failsafe{to{opacity:1}}}@media(prefers-reduced-motion:reduce){img[data-polite-reveal]{transition:none}}
package/dist/image.d.ts CHANGED
@@ -20,26 +20,19 @@ import { type Target } from './targets.js';
20
20
  export type ImageTarget = Target<HTMLImageElement>;
21
21
  export interface RevealImagesOptions {
22
22
  /**
23
- * Manage images that are not `loading="lazy"`.
24
- *
25
- * Off by default because LCP excludes elements at `opacity: 0`, and revealing
26
- * one does not restore its candidacy -- so fading an eager, above-the-fold
27
- * image can forfeit the metric it was meant to improve. A lazy image was never
28
- * an LCP candidate, so the default is risk-free.
29
- *
30
- * Turning this on is legitimate and sometimes right: an eager grid that would
31
- * otherwise cut from its backdrop to the photo on whatever frame the async
32
- * decode lands looks worse without a fade. It just has to be a decision rather
33
- * than an accident.
23
+ * @deprecated Ignored since 0.5, and warns. image.css no longer hides an eager
24
+ * image at all, so the opt-in has to be in the markup where the stylesheet can
25
+ * see it: `data-polite-reveal="eager"` on the image.
34
26
  */
35
27
  allowEager?: boolean;
36
28
  }
37
29
  /**
38
- * Reveals each matching image once it has decoded.
30
+ * Reveals each matching image once it has decoded. With no target it takes every
31
+ * marked image on the page.
39
32
  *
40
33
  * Returns a function that stops any reveals still pending, for a client-side
41
34
  * router tearing the page down before the images resolved.
42
35
  */
43
- export declare function revealImages(target: ImageTarget, options?: RevealImagesOptions): () => void;
36
+ export declare function revealImages(target?: ImageTarget, options?: RevealImagesOptions): () => void;
44
37
  export { POLITE_IMAGE_READY, type PoliteImageEventDetail } from './events.js';
45
38
  //# sourceMappingURL=image.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"image.d.ts","sourceRoot":"","sources":["../src/image.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAGH,OAAO,EAAkB,KAAK,MAAM,EAAE,MAAM,cAAc,CAAC;AAE3D,kEAAkE;AAClE,MAAM,MAAM,WAAW,GAAG,MAAM,CAAC,gBAAgB,CAAC,CAAC;AAEnD,MAAM,WAAW,mBAAmB;IAClC;;;;;;;;;;;;OAYG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AA6FD;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,WAAW,EAAE,OAAO,GAAE,mBAAwB,GAAG,MAAM,IAAI,CA0D/F;AAKD,OAAO,EAAE,kBAAkB,EAAE,KAAK,sBAAsB,EAAE,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"image.d.ts","sourceRoot":"","sources":["../src/image.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAGH,OAAO,EAAkB,KAAK,MAAM,EAAE,MAAM,cAAc,CAAC;AAE3D,kEAAkE;AAClE,MAAM,MAAM,WAAW,GAAG,MAAM,CAAC,gBAAgB,CAAC,CAAC;AAEnD,MAAM,WAAW,mBAAmB;IAClC;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAmGD;;;;;;GAMG;AACH,wBAAgB,YAAY,CAC1B,MAAM,GAAE,WAAuC,EAC/C,OAAO,GAAE,mBAAwB,GAChC,MAAM,IAAI,CAiFZ;AAKD,OAAO,EAAE,kBAAkB,EAAE,KAAK,sBAAsB,EAAE,MAAM,aAAa,CAAC"}
package/dist/image.js CHANGED
@@ -18,6 +18,8 @@
18
18
  import { POLITE_IMAGE_READY } from './events.js';
19
19
  import { resolveTargets } from './targets.js';
20
20
  const READY = 'data-polite-ready';
21
+ /** Written while this module owns an image's reveal, so image.css can stand down. */
22
+ const MANAGED = 'data-polite-managed';
21
23
  function markReady(image) {
22
24
  image.setAttribute(READY, '');
23
25
  image.dispatchEvent(new CustomEvent(POLITE_IMAGE_READY, {
@@ -26,11 +28,12 @@ function markReady(image) {
26
28
  }));
27
29
  }
28
30
  /**
29
- * Every image this module has taken responsibility for.
30
- *
31
- * A WeakSet rather than an attribute: the stylesheet no longer needs to know
32
- * which images are managed, so writing it into the DOM would be state kept for
33
- * nobody. Weak so a released image is not pinned by the bookkeeping.
31
+ * Every image a `revealImages()` call has ever claimed, for the stray-image
32
+ * warning. Distinct from `data-polite-managed`, which marks an image only while
33
+ * this module still owns its reveal so the stylesheet's failsafe stands down: a
34
+ * teardown removes the attribute but the claim stays, since the warning is about
35
+ * selectors that never reached an image at all. Weak so a released image is not
36
+ * pinned by the bookkeeping.
34
37
  */
35
38
  const managed = new WeakSet();
36
39
  let unmanagedCheck;
@@ -59,6 +62,7 @@ function scheduleUnmanagedCheck() {
59
62
  }, 1000);
60
63
  }
61
64
  let warnedNoStylesheet = false;
65
+ let warnedAllowEager = false;
62
66
  /**
63
67
  * image.css is the only thing that hides a marked image. Without it `data-polite-reveal` is inert:
64
68
  * the image is never hidden, so it never fades and everything this module does is invisible.
@@ -82,6 +86,8 @@ function warnIfUnstyled() {
82
86
  // removed in this same task so nothing paints it and no scan above ever sees it.
83
87
  const probe = document.createElement('img');
84
88
  probe.setAttribute('data-polite-reveal', '');
89
+ // The stylesheet hides only lazy images, so an eager probe would read as unstyled everywhere.
90
+ probe.setAttribute('loading', 'lazy');
85
91
  document.body.appendChild(probe);
86
92
  const opacity = getComputedStyle(probe).opacity;
87
93
  probe.remove();
@@ -95,31 +101,43 @@ function warnIfUnstyled() {
95
101
  "images will not fade. Import 'polite-media/image.css' from wherever the attribute is emitted.");
96
102
  }
97
103
  /**
98
- * Reveals each matching image once it has decoded.
104
+ * Reveals each matching image once it has decoded. With no target it takes every
105
+ * marked image on the page.
99
106
  *
100
107
  * Returns a function that stops any reveals still pending, for a client-side
101
108
  * router tearing the page down before the images resolved.
102
109
  */
103
- export function revealImages(target, options = {}) {
110
+ export function revealImages(target = 'img[data-polite-reveal]', options = {}) {
104
111
  const controller = new AbortController();
105
112
  const { signal } = controller;
113
+ if (options.allowEager && !warnedAllowEager) {
114
+ warnedAllowEager = true;
115
+ console.warn('polite-media: allowEager is ignored. To fade an eager image, put ' +
116
+ 'data-polite-reveal="eager" on it, where the stylesheet can see the choice.');
117
+ }
106
118
  scheduleUnmanagedCheck();
119
+ const claimed = [];
107
120
  for (const image of resolveTargets(target)) {
108
121
  managed.add(image);
109
- // Eager images are revealed at once rather than skipped.
110
- //
111
- // Skipping looks like the cautious choice and is the opposite: image.css
112
- // has already hidden anything carrying data-polite-reveal, so declining to
113
- // manage it leaves it invisible permanently instead of merely unfaded.
114
- // Revealing immediately keeps the LCP candidate visible from its first
115
- // paint, which is the whole reason eager images are treated differently.
122
+ // Tells the stylesheet to leave this one alone. The failsafe there is for images nothing
123
+ // reveals, and firing it on one this module owns is worse than not firing it at all: opacity
124
+ // reaches 1 while the picture is still in flight, so when it arrives there is no fade left to
125
+ // run. Measured on a real page, eleven below-the-fold images were revealed that way five
126
+ // seconds in, none of them loaded.
127
+ image.setAttribute(MANAGED, '');
128
+ claimed.push(image);
129
+ // An eager image is never hidden by image.css, because LCP excludes elements at
130
+ // opacity 0 and a deferred module cannot reveal one before first paint. So it
131
+ // is marked ready at once, unless the markup opted it into the fade with the
132
+ // same value the stylesheet keys on.
116
133
  //
117
134
  // Tested against 'lazy' rather than for 'eager' deliberately. Engines
118
135
  // disagree on what an absent or invalid attribute reports: MDN documents
119
136
  // only 'eager' and 'lazy', while happy-dom returns 'auto'. Only "lazy" has
120
137
  // one agreed spelling, so asking whether it is lazy is answerable
121
138
  // everywhere, and everything else correctly falls into the eager branch.
122
- if (!options.allowEager && image.loading !== 'lazy') {
139
+ const optedIn = image.getAttribute('data-polite-reveal') === 'eager';
140
+ if (!optedIn && image.loading !== 'lazy') {
123
141
  markReady(image);
124
142
  continue;
125
143
  }
@@ -155,7 +173,16 @@ export function revealImages(target, options = {}) {
155
173
  image.addEventListener('error', () => markReady(image), { once: true, signal });
156
174
  });
157
175
  }
158
- return () => controller.abort();
176
+ // Hands anything still unrevealed back to the stylesheet. A router tearing the page down before
177
+ // the images resolved would otherwise leave them owned by a module that has stopped listening,
178
+ // and with the failsafe suppressed that is hidden forever rather than merely unfaded.
179
+ return () => {
180
+ controller.abort();
181
+ for (const image of claimed) {
182
+ if (!image.hasAttribute(READY))
183
+ image.removeAttribute(MANAGED);
184
+ }
185
+ };
159
186
  }
160
187
  // Re-exported so `polite-media/image` carries the ElementEventMap and
161
188
  // DocumentEventMap augmentation too: it only reaches a consumer whose program
package/dist/image.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"image.js","sourceRoot":"","sources":["../src/image.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EAAE,kBAAkB,EAA+B,MAAM,aAAa,CAAC;AAC9E,OAAO,EAAE,cAAc,EAAe,MAAM,cAAc,CAAC;AAsB3D,MAAM,KAAK,GAAG,mBAAmB,CAAC;AAElC,SAAS,SAAS,CAAC,KAAuB;IACxC,KAAK,CAAC,YAAY,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;IAC9B,KAAK,CAAC,aAAa,CACjB,IAAI,WAAW,CAAyB,kBAAkB,EAAE;QAC1D,OAAO,EAAE,IAAI;QACb,MAAM,EAAE,EAAE,KAAK,EAAE;KAClB,CAAC,CACH,CAAC;AACJ,CAAC;AAED;;;;;;GAMG;AACH,MAAM,OAAO,GAAG,IAAI,OAAO,EAAoB,CAAC;AAEhD,IAAI,cAAyD,CAAC;AAE9D;;;;;;;;;;GAUG;AACH,SAAS,sBAAsB;IAC7B,YAAY,CAAC,cAAc,CAAC,CAAC;IAC7B,cAAc,GAAG,UAAU,CAAC,GAAG,EAAE;QAC/B,KAAK,MAAM,KAAK,IAAI,QAAQ,CAAC,gBAAgB,CAAmB,yBAAyB,CAAC,EAAE,CAAC;YAC3F,IAAI,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC;gBAAE,SAAS;YACjC,OAAO,CAAC,IAAI,CACV,oFAAoF;gBAClF,sEAAsE,EACxE,KAAK,CACN,CAAC;YACF,OAAO;QACT,CAAC;QACD,cAAc,EAAE,CAAC;IACnB,CAAC,EAAE,IAAI,CAAC,CAAC;AACX,CAAC;AAED,IAAI,kBAAkB,GAAG,KAAK,CAAC;AAE/B;;;;;;;;;;;;;;;GAeG;AACH,SAAS,cAAc;IACrB,IAAI,kBAAkB;QAAE,OAAO;IAE/B,+FAA+F;IAC/F,iFAAiF;IACjF,MAAM,KAAK,GAAG,QAAQ,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;IAC5C,KAAK,CAAC,YAAY,CAAC,oBAAoB,EAAE,EAAE,CAAC,CAAC;IAC7C,QAAQ,CAAC,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC;IACjC,MAAM,OAAO,GAAG,gBAAgB,CAAC,KAAK,CAAC,CAAC,OAAO,CAAC;IAChD,KAAK,CAAC,MAAM,EAAE,CAAC;IAEf,kGAAkG;IAClG,gGAAgG;IAChG,+DAA+D;IAC/D,IAAI,OAAO,KAAK,GAAG;QAAE,OAAO;IAE5B,kBAAkB,GAAG,IAAI,CAAC;IAC1B,OAAO,CAAC,IAAI,CACV,0FAA0F;QACxF,+FAA+F,CAClG,CAAC;AACJ,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,YAAY,CAAC,MAAmB,EAAE,UAA+B,EAAE;IACjF,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;IACzC,MAAM,EAAE,MAAM,EAAE,GAAG,UAAU,CAAC;IAE9B,sBAAsB,EAAE,CAAC;IAEzB,KAAK,MAAM,KAAK,IAAI,cAAc,CAAC,MAAM,CAAC,EAAE,CAAC;QAC3C,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;QACnB,yDAAyD;QACzD,EAAE;QACF,yEAAyE;QACzE,2EAA2E;QAC3E,uEAAuE;QACvE,uEAAuE;QACvE,yEAAyE;QACzE,EAAE;QACF,sEAAsE;QACtE,yEAAyE;QACzE,2EAA2E;QAC3E,kEAAkE;QAClE,yEAAyE;QACzE,IAAI,CAAC,OAAO,CAAC,UAAU,IAAI,KAAK,CAAC,OAAO,KAAK,MAAM,EAAE,CAAC;YACpD,SAAS,CAAC,KAAK,CAAC,CAAC;YACjB,SAAS;QACX,CAAC;QAED,0EAA0E;QAC1E,6EAA6E;QAC7E,sEAAsE;QACtE,6CAA6C;QAC7C,IAAI,KAAK,CAAC,QAAQ,IAAI,KAAK,CAAC,YAAY,GAAG,CAAC,EAAE,CAAC;YAC7C,qBAAqB,CAAC,GAAG,EAAE;gBACzB,IAAI,CAAC,MAAM,CAAC,OAAO;oBAAE,SAAS,CAAC,KAAK,CAAC,CAAC;YACxC,CAAC,CAAC,CAAC;YACH,SAAS;QACX,CAAC;QAED,KAAK;aACF,MAAM,EAAE;aACR,IAAI,CAAC,GAAG,EAAE;YACT,IAAI,CAAC,MAAM,CAAC,OAAO;gBAAE,SAAS,CAAC,KAAK,CAAC,CAAC;QACxC,CAAC,CAAC;aACD,KAAK,CAAC,GAAG,EAAE;YACV,qEAAqE;YACrE,sEAAsE;YACtE,uEAAuE;YACvE,yEAAyE;YACzE,IAAI,MAAM,CAAC,OAAO;gBAAE,OAAO;YAC3B,IAAI,KAAK,CAAC,QAAQ,EAAE,CAAC;gBACnB,SAAS,CAAC,KAAK,CAAC,CAAC;gBACjB,OAAO;YACT,CAAC;YACD,KAAK,CAAC,gBAAgB,CAAC,MAAM,EAAE,GAAG,EAAE,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,CAAC;YAC/E,KAAK,CAAC,gBAAgB,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,CAAC;QAClF,CAAC,CAAC,CAAC;IACP,CAAC;IAED,OAAO,GAAG,EAAE,CAAC,UAAU,CAAC,KAAK,EAAE,CAAC;AAClC,CAAC;AAED,sEAAsE;AACtE,8EAA8E;AAC9E,wCAAwC;AACxC,OAAO,EAAE,kBAAkB,EAA+B,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"image.js","sourceRoot":"","sources":["../src/image.ts"],"names":[],"mappings":"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"}
package/dist/layer.css CHANGED
@@ -1 +1 @@
1
- [data-polite-media]{position:relative}[data-polite-media]>:is(img,video),[data-polite-media]>picture>img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
1
+ [data-polite-media]{position:relative}[data-polite-media]>img,[data-polite-media]>picture>img,[data-polite-media] video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
package/dist/video.css CHANGED
@@ -1 +1 @@
1
- [data-polite-media]>video{opacity:0;transition:opacity var(--polite-fade, 0s) ease}[data-polite-media][data-polite-ready]>video{opacity:1}[data-polite-media][data-polite-ready]>:is(img,picture){visibility:hidden;transition:visibility 0s linear var(--polite-fade, 0s)}[data-polite-media][data-polite-failed]>video{opacity:0}@media(prefers-reduced-motion:reduce){[data-polite-media]>video,[data-polite-media]>:is(img,picture){transition:none}}
1
+ [data-polite-media] video{opacity:0;transition:opacity var(--polite-fade, 0s) ease}[data-polite-media][data-polite-ready] video{opacity:1}[data-polite-media][data-polite-ready]>:is(img,picture){visibility:hidden;transition:visibility 0s linear var(--polite-fade, 0s)}[data-polite-media][data-polite-failed] video{opacity:0}@media(prefers-reduced-motion:reduce){[data-polite-media] video,[data-polite-media]>:is(img,picture){transition:none}}
package/dist/video.d.ts CHANGED
@@ -11,5 +11,5 @@
11
11
  */
12
12
  export { configure, pauseAll, register, registerAll, resumeAll, unregister, unregisterAll, } from './coordinator.js';
13
13
  export type { AtOnce, ConfigureOptions, RegisterOptions, VideoTarget } from './coordinator.js';
14
- export { POLITE_VIDEO_PAUSECHANGE, POLITE_VIDEO_FAILED, POLITE_VIDEO_READY, type PolitePauseEventDetail, type PoliteVideoEventDetail, } from './events.js';
14
+ export { POLITE_VIDEO_BLOCKED, POLITE_VIDEO_PAUSECHANGE, POLITE_VIDEO_FAILED, POLITE_VIDEO_READY, type PolitePauseEventDetail, type PoliteVideoEventDetail, } from './events.js';
15
15
  //# sourceMappingURL=video.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"video.d.ts","sourceRoot":"","sources":["../src/video.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,EACL,SAAS,EACT,QAAQ,EACR,QAAQ,EACR,WAAW,EACX,SAAS,EACT,UAAU,EACV,aAAa,GACd,MAAM,kBAAkB,CAAC;AAC1B,YAAY,EAAE,MAAM,EAAE,gBAAgB,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAK/F,OAAO,EACL,wBAAwB,EACxB,mBAAmB,EACnB,kBAAkB,EAClB,KAAK,sBAAsB,EAC3B,KAAK,sBAAsB,GAC5B,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"video.d.ts","sourceRoot":"","sources":["../src/video.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,EACL,SAAS,EACT,QAAQ,EACR,QAAQ,EACR,WAAW,EACX,SAAS,EACT,UAAU,EACV,aAAa,GACd,MAAM,kBAAkB,CAAC;AAC1B,YAAY,EAAE,MAAM,EAAE,gBAAgB,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAK/F,OAAO,EACL,oBAAoB,EACpB,wBAAwB,EACxB,mBAAmB,EACnB,kBAAkB,EAClB,KAAK,sBAAsB,EAC3B,KAAK,sBAAsB,GAC5B,MAAM,aAAa,CAAC"}
package/dist/video.js CHANGED
@@ -13,5 +13,5 @@ export { configure, pauseAll, register, registerAll, resumeAll, unregister, unre
13
13
  // Loaded for its `declare global` block as much as for the constants: the
14
14
  // ElementEventMap and DocumentEventMap augmentation only reaches a consumer if
15
15
  // this module is part of their program.
16
- export { POLITE_VIDEO_PAUSECHANGE, POLITE_VIDEO_FAILED, POLITE_VIDEO_READY, } from './events.js';
16
+ export { POLITE_VIDEO_BLOCKED, POLITE_VIDEO_PAUSECHANGE, POLITE_VIDEO_FAILED, POLITE_VIDEO_READY, } from './events.js';
17
17
  //# sourceMappingURL=video.js.map
package/dist/video.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"video.js","sourceRoot":"","sources":["../src/video.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,EACL,SAAS,EACT,QAAQ,EACR,QAAQ,EACR,WAAW,EACX,SAAS,EACT,UAAU,EACV,aAAa,GACd,MAAM,kBAAkB,CAAC;AAG1B,0EAA0E;AAC1E,+EAA+E;AAC/E,wCAAwC;AACxC,OAAO,EACL,wBAAwB,EACxB,mBAAmB,EACnB,kBAAkB,GAGnB,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"video.js","sourceRoot":"","sources":["../src/video.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,EACL,SAAS,EACT,QAAQ,EACR,QAAQ,EACR,WAAW,EACX,SAAS,EACT,UAAU,EACV,aAAa,GACd,MAAM,kBAAkB,CAAC;AAG1B,0EAA0E;AAC1E,+EAA+E;AAC/E,wCAAwC;AACxC,OAAO,EACL,oBAAoB,EACpB,wBAAwB,EACxB,mBAAmB,EACnB,kBAAkB,GAGnB,MAAM,aAAa,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polite-media",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Background video, image reveals and next-page image warming. Reveals on the frame that actually painted, plays only what is on screen, respects data and motion.",
5
5
  "keywords": [
6
6
  "video",
@@ -1,5 +1,6 @@
1
1
  import { connectionAllowsMedia, mediaQuery, motionAllowed } from './env.js';
2
2
  import {
3
+ POLITE_VIDEO_BLOCKED,
3
4
  POLITE_VIDEO_PAUSECHANGE,
4
5
  POLITE_VIDEO_FAILED,
5
6
  POLITE_VIDEO_READY,
@@ -333,7 +334,11 @@ export interface RegisterOptions {
333
334
  interface Entry {
334
335
  video: HTMLVideoElement;
335
336
  target: Element;
336
- /** Element carrying reveal state, so CSS can drive poster and video together. */
337
+ /**
338
+ * Element carrying reveal state, so CSS can drive poster and video together: the nearest
339
+ * `[data-polite-media]` ancestor, or the parent where none is authored so the warning below has
340
+ * an element to check.
341
+ */
337
342
  host: Element;
338
343
  /** How much of the target is visible; 0 when offscreen. */
339
344
  ratio: number;
@@ -543,12 +548,12 @@ function pauseAfterGrace(entry: Entry): void {
543
548
  * Events, so a host can react without observing attributes or forking. Bubbling
544
549
  * because the useful listener is usually on a container, not on each video.
545
550
  */
546
- function emit(entry: Entry, type: 'ready' | 'failed'): void {
551
+ function emit(
552
+ entry: Entry,
553
+ type: typeof POLITE_VIDEO_READY | typeof POLITE_VIDEO_FAILED | typeof POLITE_VIDEO_BLOCKED
554
+ ): void {
547
555
  entry.video.dispatchEvent(
548
- new CustomEvent<PoliteVideoEventDetail>(
549
- type === 'ready' ? POLITE_VIDEO_READY : POLITE_VIDEO_FAILED,
550
- { bubbles: true, detail: { video: entry.video } }
551
- )
556
+ new CustomEvent<PoliteVideoEventDetail>(type, { bubbles: true, detail: { video: entry.video } })
552
557
  );
553
558
  }
554
559
 
@@ -559,7 +564,7 @@ function emit(entry: Entry, type: 'ready' | 'failed'): void {
559
564
  */
560
565
  function markReady(entry: Entry): void {
561
566
  entry.host.setAttribute('data-polite-ready', '');
562
- emit(entry, 'ready');
567
+ emit(entry, POLITE_VIDEO_READY);
563
568
  }
564
569
 
565
570
  function clearReady(entry: Entry): void {
@@ -569,6 +574,9 @@ function clearReady(entry: Entry): void {
569
574
  }
570
575
 
571
576
  function armReveal(entry: Entry): void {
577
+ // Still revealed from before it scrolled away, so the frame is on screen and re-arming would
578
+ // only announce `ready` a second time. Only a retraction, which removes the attribute, re-arms.
579
+ if (entry.host.hasAttribute('data-polite-ready')) return;
572
580
  entry.cancelReveal?.();
573
581
  entry.cancelReveal = revealWhenPainted(entry.video, () => markReady(entry));
574
582
  }
@@ -644,11 +652,10 @@ let warnedNothingToReveal = false;
644
652
  /**
645
653
  * The one misconfiguration that is otherwise undetectable.
646
654
  *
647
- * `host` is derived as the video's parent, while video.css keys off
648
- * `data-polite-media` authored on that same element. Nothing forces the two to
649
- * agree, so putting the attribute one level too high leaves every rule
650
- * unmatched: the video is visible from the start, the poster never hides, and
651
- * the library looks installed while doing nothing at all.
655
+ * video.css hides a video only inside an authored `[data-polite-media]` box. With
656
+ * none above the video, `host` falls back to its parent, every rule misses, the
657
+ * video is visible from the start, the poster never hides, and the library looks
658
+ * installed while doing nothing at all.
652
659
  *
653
660
  * Checked here rather than at registration because stylesheets have certainly
654
661
  * applied by the time a video starts. The visual test is what separates a genuine
@@ -667,8 +674,9 @@ function warnIfNothingToReveal(entry: Entry): void {
667
674
 
668
675
  warnedNothingToReveal = true;
669
676
  console.warn(
670
- "polite-media: no data-polite-media on this video's parent, so revealing it does " +
671
- 'nothing. Put the attribute there, or hide the video with your own CSS.',
677
+ 'polite-media: no data-polite-media on any box around this video, so revealing it does ' +
678
+ 'nothing. Put the attribute on the box holding poster and video, or hide the video with ' +
679
+ 'your own CSS.',
672
680
  entry.video
673
681
  );
674
682
  }
@@ -679,11 +687,27 @@ function warnIfNothingToReveal(entry: Entry): void {
679
687
  */
680
688
  function markFailed(entry: Entry): void {
681
689
  clearReady(entry);
690
+ clearBlocked(entry);
682
691
  entry.host.setAttribute('data-polite-failed', '');
683
- emit(entry, 'failed');
692
+ emit(entry, POLITE_VIDEO_FAILED);
684
693
  unregister(entry.video);
685
694
  }
686
695
 
696
+ /**
697
+ * The browser refused to start playback until a gesture, which MDN reports as `NotAllowedError`.
698
+ * The retry rungs handle recovery; this tells the host, the only party that can offer a play
699
+ * affordance. The other rejection, nothing buffered yet, is transient and not announced.
700
+ */
701
+ function markBlocked(entry: Entry): void {
702
+ if (entry.host.hasAttribute('data-polite-blocked')) return;
703
+ entry.host.setAttribute('data-polite-blocked', '');
704
+ emit(entry, POLITE_VIDEO_BLOCKED);
705
+ }
706
+
707
+ function clearBlocked(entry: Entry): void {
708
+ entry.host.removeAttribute('data-polite-blocked');
709
+ }
710
+
687
711
  /**
688
712
  * Waits for the next user gesture, then re-runs the arbiter.
689
713
  *
@@ -734,20 +758,24 @@ function tryPlay(entry: Entry): void {
734
758
  // muted is the only condition it can rely on. Set rather than trusted, which
735
759
  // also covers markup whose property was changed after parse.
736
760
  entry.video.muted = true;
737
- void entry.video.play().catch(() => {
738
- armGestureRetry();
739
-
740
- if (entry.retryArmed) return;
741
- entry.retryArmed = true;
742
- entry.video.addEventListener(
743
- 'canplay',
744
- () => {
745
- entry.retryArmed = false;
746
- reconcile();
747
- },
748
- { once: true, signal: entry.listeners.signal }
749
- );
750
- });
761
+ void entry.video.play().then(
762
+ () => clearBlocked(entry),
763
+ (error: unknown) => {
764
+ if ((error as DOMException | null)?.name === 'NotAllowedError') markBlocked(entry);
765
+ armGestureRetry();
766
+
767
+ if (entry.retryArmed) return;
768
+ entry.retryArmed = true;
769
+ entry.video.addEventListener(
770
+ 'canplay',
771
+ () => {
772
+ entry.retryArmed = false;
773
+ reconcile();
774
+ },
775
+ { once: true, signal: entry.listeners.signal }
776
+ );
777
+ }
778
+ );
751
779
  }
752
780
 
753
781
  function onMediaError(entry: Entry): void {
@@ -1067,19 +1095,22 @@ function detachLifecycle(): void {
1067
1095
  }
1068
1096
 
1069
1097
  /**
1070
- * Keeps `aria-pressed` current on any pause control that already declares it.
1098
+ * Puts `<html>` and every declared pause control in step with `userPaused`.
1071
1099
  *
1072
- * Maintained rather than added, because MDN describes two valid patterns and
1073
- * setting it unconditionally would break one of them: `aria-pressed` is for a
1074
- * control whose label stays constant, while a control that swaps its label
1075
- * between "Pause" and "Play" should not carry it at all -- a screen reader would
1076
- * announce "Play, pressed". Declaring the attribute in markup is the author
1100
+ * `aria-pressed` is maintained rather than added, because MDN describes two valid
1101
+ * patterns and setting it unconditionally would break one of them: `aria-pressed`
1102
+ * is for a control whose label stays constant, while a control that swaps its
1103
+ * label between "Pause" and "Play" should not carry it at all -- a screen reader
1104
+ * would announce "Play, pressed". Declaring the attribute in markup is the author
1077
1105
  * saying which pattern they are in.
1078
1106
  *
1079
1107
  * Restricted to a button role because that is the only role `aria-pressed` is
1080
1108
  * valid on, so this cannot emit ARIA that a validator would reject.
1081
1109
  */
1082
1110
  function reflectPaused(): void {
1111
+ if (userPaused) document.documentElement.setAttribute('data-polite-paused', '');
1112
+ else document.documentElement.removeAttribute('data-polite-paused');
1113
+
1083
1114
  for (const control of document.querySelectorAll('[data-polite-pause-control][aria-pressed]')) {
1084
1115
  if (control.matches('button, [role="button"]')) {
1085
1116
  control.setAttribute('aria-pressed', String(userPaused));
@@ -1138,7 +1169,6 @@ function writeStoredPause(paused: boolean): void {
1138
1169
  function restorePaused(): void {
1139
1170
  if (userPaused || !readStoredPause()) return;
1140
1171
  userPaused = true;
1141
- document.documentElement.setAttribute('data-polite-paused', '');
1142
1172
  reflectPaused();
1143
1173
  }
1144
1174
 
@@ -1211,7 +1241,7 @@ export function register(video: HTMLVideoElement, options: RegisterOptions = {})
1211
1241
  const entry: Entry = {
1212
1242
  video,
1213
1243
  target,
1214
- host: video.parentElement ?? video,
1244
+ host: video.closest('[data-polite-media]') ?? video.parentElement ?? video,
1215
1245
  ratio: 0,
1216
1246
  gated: Boolean(options.until),
1217
1247
  hadGate: Boolean(options.until),
@@ -1227,6 +1257,9 @@ export function register(video: HTMLVideoElement, options: RegisterOptions = {})
1227
1257
  byTarget.set(target, entry);
1228
1258
  reflectActive();
1229
1259
  attachLifecycle();
1260
+ // A client-side router replaces <html>'s attributes and the control with the next page's while
1261
+ // this module, and a pause made before the swap, survive. So it is re-asserted per registration.
1262
+ if (userPaused) reflectPaused();
1230
1263
  getObserver().observe(target);
1231
1264
  getPrefetchObserver()?.observe(target);
1232
1265
 
@@ -1253,7 +1286,9 @@ export type VideoTarget = Target<HTMLVideoElement>;
1253
1286
 
1254
1287
  /**
1255
1288
  * Registers every video a target names, so the common case is one line and
1256
- * matches `revealImages` on the image side rather than being a second idea.
1289
+ * matches `revealImages` on the image side rather than being a second idea. With
1290
+ * no target it takes every video inside a `[data-polite-media]` box, which is
1291
+ * what every known caller passed by hand.
1257
1292
  *
1258
1293
  * `observe` is deliberately not accepted. Each observed element maps to exactly
1259
1294
  * one entry, so handing the same wrapper to several videos would silently
@@ -1264,7 +1299,7 @@ export type VideoTarget = Target<HTMLVideoElement>;
1264
1299
  * client-side router, where module scripts do not re-run.
1265
1300
  */
1266
1301
  export function registerAll(
1267
- target: VideoTarget,
1302
+ target: VideoTarget = '[data-polite-media] video',
1268
1303
  options: Omit<RegisterOptions, 'observe'> = {}
1269
1304
  ): void {
1270
1305
  for (const video of resolveTargets(target)) register(video, options);
@@ -1341,9 +1376,6 @@ function setPaused(paused: boolean): void {
1341
1376
  if (userPaused === paused) return;
1342
1377
  userPaused = paused;
1343
1378
 
1344
- if (paused) document.documentElement.setAttribute('data-polite-paused', '');
1345
- else document.documentElement.removeAttribute('data-polite-paused');
1346
-
1347
1379
  reflectPaused();
1348
1380
  reconcile();
1349
1381
 
package/src/events.ts CHANGED
@@ -27,6 +27,13 @@ export const POLITE_VIDEO_READY = 'polite-video:ready';
27
27
  /** Dispatched on the video when no source could be decoded. Bubbles. */
28
28
  export const POLITE_VIDEO_FAILED = 'polite-video:failed';
29
29
 
30
+ /**
31
+ * Dispatched on the video when the browser refused `play()` until a user gesture.
32
+ * Bubbles. The library keeps retrying on its own; this exists so a host can show
33
+ * a play affordance in the meantime, which the library cannot do for it.
34
+ */
35
+ export const POLITE_VIDEO_BLOCKED = 'polite-video:blocked';
36
+
30
37
  /** Dispatched on the image once it has decoded. Bubbles. */
31
38
  export const POLITE_IMAGE_READY = 'polite-image:ready';
32
39
 
@@ -66,12 +73,14 @@ declare global {
66
73
  interface ElementEventMap {
67
74
  'polite-video:ready': CustomEvent<PoliteVideoEventDetail>;
68
75
  'polite-video:failed': CustomEvent<PoliteVideoEventDetail>;
76
+ 'polite-video:blocked': CustomEvent<PoliteVideoEventDetail>;
69
77
  'polite-image:ready': CustomEvent<PoliteImageEventDetail>;
70
78
  }
71
79
 
72
80
  interface DocumentEventMap {
73
81
  'polite-video:ready': CustomEvent<PoliteVideoEventDetail>;
74
82
  'polite-video:failed': CustomEvent<PoliteVideoEventDetail>;
83
+ 'polite-video:blocked': CustomEvent<PoliteVideoEventDetail>;
75
84
  'polite-image:ready': CustomEvent<PoliteImageEventDetail>;
76
85
  'polite-video:pausechange': CustomEvent<PolitePauseEventDetail>;
77
86
  }
package/src/image.css CHANGED
@@ -9,6 +9,12 @@
9
9
  * them invisible forever rather than merely unfaded. Per image, an image is
10
10
  * hidden only if something is going to reveal it.
11
11
  *
12
+ * Only a lazy image is hidden. An eager one may be the Largest Contentful Paint
13
+ * element, LCP excludes anything at opacity 0, and a deferred module cannot
14
+ * reveal it before first paint, so the stylesheet never touches it and the
15
+ * module marks it ready at once. To fade an eager image anyway, say so where
16
+ * this file can see it: `data-polite-reveal="eager"`.
17
+ *
12
18
  * A REQUIREMENT rather than a suggestion: the container needs its own visible
13
19
  * backdrop, usually a background-color. Unlike video, an image has no poster
14
20
  * behind it, so a hidden image over a transparent container shows nothing.
@@ -33,7 +39,8 @@
33
39
  */
34
40
 
35
41
  @media (scripting: enabled) {
36
- img[data-polite-reveal] {
42
+ img[data-polite-reveal][loading='lazy'],
43
+ img[data-polite-reveal='eager'] {
37
44
  opacity: 0;
38
45
  transition: opacity var(--polite-fade, 350ms) ease;
39
46
  }
@@ -50,11 +57,9 @@
50
57
  * stylesheet applied stayed at 0 for the whole delay and then snapped to 1
51
58
  * unfaded, while Chromium and WebKit revealed it. Cancelling only the animation
52
59
  * on such an image put it at 1 immediately.
53
- *
54
- * Eager images hid the bug, because they are marked ready inside the same task
55
- * that applies the stylesheet, before this can take effect.
56
60
  */
57
- img[data-polite-reveal]:not([data-polite-ready]) {
61
+ img[data-polite-reveal][loading='lazy']:not([data-polite-ready]):not([data-polite-managed]),
62
+ img[data-polite-reveal='eager']:not([data-polite-ready]):not([data-polite-managed]) {
58
63
  animation: polite-reveal-failsafe 0s var(--polite-failsafe, 5s) forwards;
59
64
  }
60
65