polite-media 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/LICENSE +21 -0
  3. package/README.md +675 -0
  4. package/dist/coordinator.d.ts +231 -0
  5. package/dist/coordinator.d.ts.map +1 -0
  6. package/dist/coordinator.js +1017 -0
  7. package/dist/coordinator.js.map +1 -0
  8. package/dist/env.d.ts +30 -0
  9. package/dist/env.d.ts.map +1 -0
  10. package/dist/env.js +49 -0
  11. package/dist/env.js.map +1 -0
  12. package/dist/events.d.ts +70 -0
  13. package/dist/events.d.ts.map +1 -0
  14. package/dist/events.js +42 -0
  15. package/dist/events.js.map +1 -0
  16. package/dist/image.css +1 -0
  17. package/dist/image.d.ts +45 -0
  18. package/dist/image.d.ts.map +1 -0
  19. package/dist/image.js +127 -0
  20. package/dist/image.js.map +1 -0
  21. package/dist/layer.css +1 -0
  22. package/dist/reveal.d.ts +34 -0
  23. package/dist/reveal.d.ts.map +1 -0
  24. package/dist/reveal.js +72 -0
  25. package/dist/reveal.js.map +1 -0
  26. package/dist/sources.d.ts +22 -0
  27. package/dist/sources.d.ts.map +1 -0
  28. package/dist/sources.js +148 -0
  29. package/dist/sources.js.map +1 -0
  30. package/dist/targets.d.ts +20 -0
  31. package/dist/targets.d.ts.map +1 -0
  32. package/dist/targets.js +17 -0
  33. package/dist/targets.js.map +1 -0
  34. package/dist/video.css +1 -0
  35. package/dist/video.d.ts +15 -0
  36. package/dist/video.d.ts.map +1 -0
  37. package/dist/video.js +17 -0
  38. package/dist/video.js.map +1 -0
  39. package/dist/warm.d.ts +13 -0
  40. package/dist/warm.d.ts.map +1 -0
  41. package/dist/warm.js +12 -0
  42. package/dist/warm.js.map +1 -0
  43. package/dist/warming.d.ts +62 -0
  44. package/dist/warming.d.ts.map +1 -0
  45. package/dist/warming.js +136 -0
  46. package/dist/warming.js.map +1 -0
  47. package/package.json +96 -0
  48. package/src/coordinator.ts +1337 -0
  49. package/src/env.ts +56 -0
  50. package/src/events.ts +78 -0
  51. package/src/image.css +74 -0
  52. package/src/image.ts +160 -0
  53. package/src/layer.css +60 -0
  54. package/src/reveal.ts +75 -0
  55. package/src/sources.ts +164 -0
  56. package/src/targets.ts +27 -0
  57. package/src/video.css +74 -0
  58. package/src/video.ts +32 -0
  59. package/src/warm.ts +12 -0
  60. package/src/warming.ts +162 -0
package/src/targets.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * @module
3
+ * How both halves name the elements they act on, so `revealImages('.card img')`
4
+ * and `registerAll('[data-polite-media] video')` mean the same thing rather than
5
+ * being two similar-looking ideas.
6
+ */
7
+
8
+ /**
9
+ * Anything that names one or more elements: a selector, a single element, or any
10
+ * collection of them.
11
+ *
12
+ * `ArrayLike` is listed alongside `Iterable` deliberately. `NodeListOf` and
13
+ * `HTMLCollectionOf` are iterable at runtime, but their `[Symbol.iterator]` lives
14
+ * in `lib.dom.iterable`, so a consumer whose `lib` omits it cannot pass
15
+ * `document.querySelectorAll('img')` to an `Iterable`-only parameter even though
16
+ * it works. `ArrayLike` is structural and needs no `lib` support.
17
+ */
18
+ export type Target<T extends Element> = string | T | ArrayLike<T> | Iterable<T>;
19
+
20
+ /** Resolves a {@link Target} to the elements it names. */
21
+ export function resolveTargets<T extends Element>(target: Target<T>): T[] {
22
+ if (typeof target === 'string') return [...document.querySelectorAll<T>(target)];
23
+ // A single element is the obvious thing to pass when you already hold one, and
24
+ // it used to be rejected: `revealImages(myImg)` did not compile.
25
+ if (target instanceof Element) return [target as T];
26
+ return Array.from(target as ArrayLike<T>);
27
+ }
package/src/video.css ADDED
@@ -0,0 +1,74 @@
1
+ /*
2
+ * polite-media/video.css -- optional stylesheet.
3
+ *
4
+ * Timing only. This file sets no size, position or aspect ratio, because the
5
+ * library owns *when* media appears and the host owns *where*. That separation
6
+ * is why it works at any video size, and it would be undone by a single width
7
+ * declaration here.
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.
14
+ *
15
+ * Expected shape -- poster first, video second, sharing one box:
16
+ *
17
+ * <div class="whatever-you-like" data-polite-media>
18
+ * <img src="poster.avif" alt="">
19
+ * <video muted loop playsinline preload="none">
20
+ * <source src="hero.mp4" type="video/mp4">
21
+ * </video>
22
+ * </div>
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
27
+ * And on <html>:
28
+ * data-polite-paused the user stopped playback via a [data-polite-pause-control] control
29
+ *
30
+ * --polite-fade defaults to 0s here, meaning a cut. A frame-0 poster does not
31
+ * make a fade free: playback starts at the reveal, so the poster stays frozen
32
+ * while the video advances and the crossfade blends a still against a frame that
33
+ * has moved on, which reads as a double exposure. Cutting is safe because the
34
+ * reveal waits for a presented frame, so there is always something to cut to.
35
+ *
36
+ * image.css reads the same property but defaults to 350ms, because a lone image
37
+ * has no second moving picture to ghost against and its fade covers the flicker
38
+ * of an async decode landing. Set the property to override both at once.
39
+ */
40
+
41
+ [data-polite-media] > video {
42
+ opacity: 0;
43
+ transition: opacity var(--polite-fade, 0s) ease;
44
+ }
45
+
46
+ [data-polite-media][data-polite-ready] > video {
47
+ opacity: 1;
48
+ }
49
+
50
+ /*
51
+ * The poster is hidden only after the crossfade finishes. Removing it up front
52
+ * would expose whatever sits behind mid-dissolve; `visibility` rather than
53
+ * `display` so nothing reflows, and the delay must track --polite-fade.
54
+ */
55
+ [data-polite-media][data-polite-ready] > :is(img, picture) {
56
+ visibility: hidden;
57
+ transition: visibility 0s linear var(--polite-fade, 0s);
58
+ }
59
+
60
+ /*
61
+ * A failed video is never revealed, so this is belt and braces -- but a
62
+ * half-decoded frame stuck at opacity 1 is exactly the state a viewer would
63
+ * report as broken.
64
+ */
65
+ [data-polite-media][data-polite-failed] > video {
66
+ opacity: 0;
67
+ }
68
+
69
+ @media (prefers-reduced-motion: reduce) {
70
+ [data-polite-media] > video,
71
+ [data-polite-media] > :is(img, picture) {
72
+ transition: none;
73
+ }
74
+ }
package/src/video.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * @module
3
+ * Everything for background video. Imported on its own so an image-only page
4
+ * never pays for the IntersectionObserver, source selection or arbitration.
5
+ *
6
+ * The export list is deliberately small. Every name here is permanent once
7
+ * published, and adding one later is not a breaking change while removing one
8
+ * is -- so anything without a caller that can be named stays internal. The
9
+ * environment gates and the reveal primitive are used by this package and are
10
+ * not part of its interface.
11
+ */
12
+ export {
13
+ configure,
14
+ pauseAll,
15
+ register,
16
+ registerAll,
17
+ resumeAll,
18
+ unregister,
19
+ unregisterAll,
20
+ } from './coordinator.js';
21
+ export type { AtOnce, ConfigureOptions, RegisterOptions, VideoTarget } from './coordinator.js';
22
+
23
+ // Loaded for its `declare global` block as much as for the constants: the
24
+ // ElementEventMap and DocumentEventMap augmentation only reaches a consumer if
25
+ // this module is part of their program.
26
+ export {
27
+ POLITE_VIDEO_PAUSECHANGE,
28
+ POLITE_VIDEO_FAILED,
29
+ POLITE_VIDEO_READY,
30
+ type PolitePauseEventDetail,
31
+ type PoliteVideoEventDetail,
32
+ } from './events.js';
package/src/warm.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @module
3
+ * Warming a destination page's image before the visitor gets there. Imported on
4
+ * its own, so a page that only warms never pays for the video coordinator.
5
+ *
6
+ * A barrel for the same reason `video.ts` is one: this file is what the `./warm`
7
+ * subpath resolves to, so every name it exports is public and permanent once
8
+ * published. `resetWarmed` clears the dedup record for a test and has no caller
9
+ * outside the suite, so it stays in `warming.ts` where tests reach it directly.
10
+ */
11
+ export { warm, warmOnIntent } from './warming.js';
12
+ export type { WarmOptions, WarmSource } from './warming.js';
package/src/warming.ts ADDED
@@ -0,0 +1,162 @@
1
+ /**
2
+ * @module
3
+ * Warming a destination page's image before the visitor gets there, so the click
4
+ * lands on a picture that is already in the cache.
5
+ *
6
+ * Every document prefetcher fetches the HTML and stops: Astro's `data-astro-prefetch`,
7
+ * Next's `<Link>`, quicklink. The image inside that HTML is discovered only after
8
+ * the new document parses, which is exactly when it is too late to matter.
9
+ *
10
+ * The platform has the pieces but not in one place. `imagesrcset` and `imagesizes`
11
+ * do responsive selection, but MDN scopes them to `rel="preload"` with `as="image"`
12
+ * only, and scopes preload itself to resources "your page will need very soon". A
13
+ * speculative navigation wants `prefetch` semantics, where those attributes do not
14
+ * apply. So you can have the right selection or the right timing, not both, and
15
+ * anyone who wants both ends up re-implementing the browser's own rules in JS:
16
+ * parsing `sizes`, comparing `w` descriptors, guessing format support.
17
+ *
18
+ * This module does none of that. It builds the candidates as a detached
19
+ * `<picture>` and lets the browser choose, which is measurably the same algorithm
20
+ * the destination page will run (e2e/warm.spec.ts, all three engines). Nothing
21
+ * here parses a media query, so nothing here can disagree with one.
22
+ *
23
+ * A detached `<img>` also *fetches* what it selects, so no `<link>` is injected at
24
+ * all. That sidesteps the two documented failures of the link approach: Safari
25
+ * does not support `<link rel="prefetch">`, and Firefox aborts it with
26
+ * NS_BINDING_ABORTED when the response carries no explicit cache header.
27
+ *
28
+ * Not an image pipeline. It generates no URLs and knows no widths; it warms the
29
+ * candidates you already build.
30
+ */
31
+
32
+ import { connectionAllowsMedia } from './env.js';
33
+
34
+ /** One `<picture>` candidate: a format, and the variants available in it. */
35
+ export interface WarmSource {
36
+ /** A MIME type such as `image/avif`. Omitted means "always a candidate". */
37
+ type?: string;
38
+ srcset: string;
39
+ }
40
+
41
+ export interface WarmOptions {
42
+ /** Ordered, first supported wins, exactly as `<source>` children behave. */
43
+ sources?: WarmSource[];
44
+ srcset?: string;
45
+ /** A single URL, for an image with no variants. */
46
+ src?: string;
47
+ /** Handed to the browser verbatim. Nothing in this package parses it. */
48
+ sizes?: string;
49
+ }
50
+
51
+ const warmed = new Set<string>();
52
+
53
+ /**
54
+ * Images with a fetch still in flight.
55
+ *
56
+ * A detached element is reachable from nothing once this function returns, and a
57
+ * collected image would be an aborted fetch. Precautionary rather than a fix for
58
+ * something observed: no engine was seen dropping one. It is cheap, and the bug
59
+ * it forecloses would be silent and load dependent, which is the kind no test
60
+ * here would catch.
61
+ */
62
+ const inFlight = new Set<HTMLImageElement>();
63
+
64
+ /**
65
+ * Deduped on what the caller asked for rather than on the URL the browser picks,
66
+ * because the URL is not known until selection has already happened, and the
67
+ * repeat this exists to stop is the same link hovered twice.
68
+ */
69
+ function keyOf(options: WarmOptions): string {
70
+ const sources = (options.sources ?? []).map((source) => `${source.type ?? ''}|${source.srcset}`);
71
+ // Joined on NUL rather than a newline: a srcset spanning several lines is
72
+ // ordinary formatting, and would otherwise let two different candidate sets
73
+ // build the same key and silently drop the second warm.
74
+ return [...sources, options.srcset ?? '', options.src ?? '', options.sizes ?? ''].join('\0');
75
+ }
76
+
77
+ /** Warms one image: selects the variant this browser would request, and fetches it. */
78
+ export function warm(options: WarmOptions): void {
79
+ // Speculative bytes are the first thing to drop on a metered connection. Astro
80
+ // downgrades to its `tap` strategy here rather than skipping; for an image
81
+ // nobody has asked for yet, not spending them at all is the better trade.
82
+ if (!connectionAllowsMedia()) return;
83
+
84
+ const sources = options.sources ?? [];
85
+ // Checked before anything is built, so an empty call allocates nothing, and
86
+ // before `warmed` is touched, so it cannot make a later correct call a no-op.
87
+ if (sources.length === 0 && !options.srcset && !options.src) {
88
+ console.warn(
89
+ 'polite-media: warm() was given no src, srcset or sources, so nothing was warmed.'
90
+ );
91
+ return;
92
+ }
93
+
94
+ const key = keyOf(options);
95
+ if (warmed.has(key)) return;
96
+
97
+ const picture = document.createElement('picture');
98
+ for (const source of sources) {
99
+ const element = document.createElement('source');
100
+ if (source.type) element.type = source.type;
101
+ element.srcset = source.srcset;
102
+ picture.append(element);
103
+ }
104
+
105
+ const img = document.createElement('img');
106
+ picture.append(img);
107
+ // The visitor has not asked for this image and may never ask. It must not
108
+ // compete with the page they are actually looking at.
109
+ img.fetchPriority = 'low';
110
+ if (options.sizes) img.sizes = options.sizes;
111
+ if (options.srcset) img.srcset = options.srcset;
112
+ if (options.src) img.src = options.src;
113
+
114
+ warmed.add(key);
115
+ inFlight.add(img);
116
+ const settled = (): void => void inFlight.delete(img);
117
+ img.addEventListener('load', settled, { once: true });
118
+ img.addEventListener('error', settled, { once: true });
119
+ }
120
+
121
+ /**
122
+ * The events that mean "about to navigate". `pointerover` and `focusin` cover
123
+ * Astro's documented `hover` strategy, "when you hover over or focus on the
124
+ * link"; `touchstart` covers the touch case, where a hover never happens.
125
+ */
126
+ const INTENT_EVENTS = ['pointerover', 'focusin', 'touchstart'] as const;
127
+
128
+ /**
129
+ * Warms whatever `resolve` names when the visitor shows intent toward `selector`.
130
+ *
131
+ * Delegated on the document, so links added later are covered without rebinding
132
+ * and one listener serves a whole grid. `resolve` returns the candidates rather
133
+ * than the library inventing a `data-*` vocabulary for them: where an app keeps
134
+ * its srcsets is the app's business.
135
+ *
136
+ * Returns a teardown. Listeners on `document` survive a `<ClientRouter />` swap,
137
+ * so a caller that re-binds per navigation stacks duplicates without one.
138
+ */
139
+ export function warmOnIntent(
140
+ selector: string,
141
+ resolve: (element: Element) => WarmOptions | null | undefined
142
+ ): () => void {
143
+ const onIntent = (event: Event): void => {
144
+ const element = (event.target as Element | null)?.closest?.(selector);
145
+ if (!element) return;
146
+ const options = resolve(element);
147
+ if (options) warm(options);
148
+ };
149
+
150
+ for (const type of INTENT_EVENTS) {
151
+ document.addEventListener(type, onIntent, { passive: true });
152
+ }
153
+ return () => {
154
+ for (const type of INTENT_EVENTS) document.removeEventListener(type, onIntent);
155
+ };
156
+ }
157
+
158
+ /** Drops the dedup record so a test can warm the same thing twice. Not public API. */
159
+ export function resetWarmed(): void {
160
+ warmed.clear();
161
+ inFlight.clear();
162
+ }