webperf-snippets 0.2.0 → 0.4.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 (73) hide show
  1. package/README.md +142 -14
  2. package/package.json +1 -1
  3. package/snippets/CoreWebVitals/CLS.js +29 -15
  4. package/snippets/CoreWebVitals/INP.js +14 -7
  5. package/snippets/CoreWebVitals/LCP-Image-Entropy.js +29 -11
  6. package/snippets/CoreWebVitals/LCP-Subparts.js +4 -0
  7. package/snippets/CoreWebVitals/LCP-Trail.js +14 -3
  8. package/snippets/CoreWebVitals/LCP-Video-Candidate.js +20 -2
  9. package/snippets/CoreWebVitals/LCP.js +4 -2
  10. package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-read.js +9 -2
  11. package/snippets/Interaction/DOM-Size-and-Depth.js +134 -0
  12. package/snippets/Interaction/Forced-Synchronous-Layout.js +33 -7
  13. package/snippets/Interaction/Input-Latency-Breakdown.js +45 -1
  14. package/snippets/Interaction/Interactions.js +16 -5
  15. package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +44 -13
  16. package/snippets/Interaction/Long-Animation-Frames-Helpers.js +28 -1
  17. package/snippets/Interaction/Long-Animation-Frames-Script-Attribution.js +8 -0
  18. package/snippets/Interaction/Long-Animation-Frames.js +31 -2
  19. package/snippets/Interaction/LongTask.js +37 -5
  20. package/snippets/Interaction/Scroll-Performance.js +40 -0
  21. package/snippets/Interaction/WORKFLOWS.md +9 -0
  22. package/snippets/Loading/Back-Forward-Cache.js +174 -381
  23. package/snippets/Loading/CSS-Media-Queries-Analysis.js +76 -44
  24. package/snippets/Loading/Cache-Strategy-Analysis.js +141 -43
  25. package/snippets/Loading/Compression-Audit.js +172 -0
  26. package/snippets/Loading/Content-Visibility.js +8 -3
  27. package/snippets/Loading/Critical-CSS-Detection.js +8 -6
  28. package/snippets/Loading/Event-Processing-Time.js +15 -2
  29. package/snippets/Loading/FCP.js +39 -5
  30. package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +1 -1
  31. package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +7 -5
  32. package/snippets/Loading/First-And-Third-Party-Script-Info.js +58 -20
  33. package/snippets/Loading/First-And-Third-Party-Script-Timings.js +46 -13
  34. package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +53 -4
  35. package/snippets/Loading/Inline-CSS-Info-and-Size.js +8 -6
  36. package/snippets/Loading/Inline-Script-Info-and-Size.js +14 -7
  37. package/snippets/Loading/JS-Execution-Time-Breakdown.js +23 -10
  38. package/snippets/Loading/Priority-Hints-Audit.js +4 -1
  39. package/snippets/Loading/Resource-Hints-Validation.js +44 -9
  40. package/snippets/Loading/Resource-Hints.js +29 -8
  41. package/snippets/Loading/SSR-Hydration-Data-Analysis.js +77 -8
  42. package/snippets/Loading/Script-Loading.js +72 -24
  43. package/snippets/Loading/Server-Timing-Early-Hints.js +126 -0
  44. package/snippets/Loading/Service-Worker-Analysis.js +229 -53
  45. package/snippets/Loading/Speculation-Rules-Inspector.js +134 -0
  46. package/snippets/Loading/TTFB-Resources.js +86 -18
  47. package/snippets/Loading/TTFB-Sub-Parts.js +9 -2
  48. package/snippets/Loading/TTFB.js +3 -2
  49. package/snippets/Loading/Third-Party-Impact-by-Domain.js +244 -0
  50. package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +21 -3
  51. package/snippets/Loading/WORKFLOWS.md +47 -1
  52. package/snippets/Loading/Webfont-Usage-Analyzer.js +332 -0
  53. package/snippets/Media/Image-Element-Audit.js +74 -11
  54. package/snippets/Media/Oversized-Images.js +217 -0
  55. package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +18 -3
  56. package/snippets/Media/WORKFLOWS.md +22 -0
  57. package/snippets/Resources/Network-Bandwidth-Connection-Quality.js +7 -0
  58. package/snippets/SCHEMA.md +1179 -0
  59. package/snippets/_shared/helpers.js +61 -0
  60. package/src/bin.js +72 -45
  61. package/src/crux.js +84 -0
  62. package/src/decision-tree.js +55 -7
  63. package/src/interactions.js +13 -3
  64. package/src/load-snippet.js +1 -1
  65. package/src/report-to.js +46 -0
  66. package/src/reporters/human.js +43 -5
  67. package/src/reporters/markdown.js +27 -1
  68. package/src/runner.js +75 -21
  69. package/src/snippet-names.js +89 -0
  70. package/src/tracking.js +6 -0
  71. package/src/workflows/audit.js +17 -0
  72. package/src/workflows/interaction.js +30 -0
  73. package/src/workflows/loading.js +9 -0
@@ -0,0 +1,217 @@
1
+ // Oversized Images
2
+ // https://webperf-snippets.nucliweb.net
3
+
4
+ (async () => {
5
+ const OVERSIZED_RATIO = 1.5; // file has at least 1.5x the pixels it needs on each axis
6
+ const UNDERSIZED_RATIO = 0.9; // file has less than 90% of the pixels it needs
7
+ const SEVERE_OVERSIZED = 2;
8
+ const SEVERE_UNDERSIZED = 0.75;
9
+ const MAX_ITEMS = 50;
10
+ const LOAD_TIMEOUT_MS = 3000;
11
+
12
+ const dpr = window.devicePixelRatio || 1;
13
+
14
+ const round2 = (n) => Math.round(n * 100) / 100;
15
+ const px = (v) => parseFloat(v) || 0;
16
+
17
+ const shortUrl = (url) => (url.startsWith("data:") ? url.slice(0, 60) : url);
18
+ const fileName = (url) => url.split("?")[0].split("/").pop() || url;
19
+
20
+ function selectorOf(el) {
21
+ if (el.id) return `img#${el.id}`;
22
+ const cls = Array.from(el.classList).slice(0, 2);
23
+ return cls.length ? `img.${cls.join(".")}` : "img";
24
+ }
25
+
26
+ function isVector(url) {
27
+ return /^data:image\/svg/i.test(url) || /\.svg([?#]|$)/i.test(url);
28
+ }
29
+
30
+ // The size of the file in pixels. HTMLImageElement.naturalWidth is corrected by the srcset
31
+ // density (a 400px file picked as "2x" reports 200), so a fresh Image, which has no srcset,
32
+ // gives the real pixels. The file comes from the browser cache.
33
+ function loadPixels(url) {
34
+ return new Promise((resolve) => {
35
+ const probe = new Image();
36
+ const timer = setTimeout(() => resolve(null), LOAD_TIMEOUT_MS);
37
+ probe.onload = () => {
38
+ clearTimeout(timer);
39
+ resolve({ width: probe.naturalWidth, height: probe.naturalHeight });
40
+ };
41
+ probe.onerror = () => {
42
+ clearTimeout(timer);
43
+ resolve(null);
44
+ };
45
+ probe.src = url;
46
+ });
47
+ }
48
+
49
+ // Bytes of each image URL; null when the browser hides them (cross-origin without
50
+ // Timing-Allow-Origin).
51
+ const transferByUrl = new Map();
52
+ performance.getEntriesByType("resource").forEach((e) => {
53
+ if (e.initiatorType !== "img" && e.initiatorType !== "css" && e.initiatorType !== "other") return;
54
+ const bytes = e.transferSize > 0 ? e.transferSize : e.encodedBodySize > 0 ? e.encodedBodySize : null;
55
+ if (bytes !== null) transferByUrl.set(e.name, bytes);
56
+ });
57
+
58
+ // Scale from file pixels to CSS pixels on each axis, following object-fit.
59
+ function scaleOf(img, boxW, boxH, fileW, fileH) {
60
+ const fit = getComputedStyle(img).objectFit;
61
+ const sx = boxW / fileW;
62
+ const sy = boxH / fileH;
63
+ const contain = Math.min(sx, sy);
64
+ const none = img.naturalWidth / fileW; // CSS pixels per file pixel, density-corrected
65
+ if (fit === "cover") return { sx: Math.max(sx, sy), sy: Math.max(sx, sy) };
66
+ if (fit === "contain") return { sx: contain, sy: contain };
67
+ if (fit === "none") return { sx: none, sy: none };
68
+ if (fit === "scale-down") {
69
+ const s = Math.min(contain, none);
70
+ return { sx: s, sy: s };
71
+ }
72
+ return { sx, sy }; // fill
73
+ }
74
+
75
+ const images = Array.from(document.querySelectorAll("img"));
76
+ let skippedHidden = 0;
77
+ let skippedNotLoaded = 0;
78
+ let skippedVector = 0;
79
+
80
+ const candidates = [];
81
+ images.forEach((img) => {
82
+ const rect = img.getBoundingClientRect();
83
+ if (rect.width === 0 || rect.height === 0) return void skippedHidden++;
84
+ const url = img.currentSrc || img.src || "";
85
+ if (!img.complete || img.naturalWidth === 0 || !url) return void skippedNotLoaded++;
86
+ if (isVector(url)) return void skippedVector++;
87
+ candidates.push({ img, rect, url });
88
+ });
89
+
90
+ const pixelsByUrl = new Map();
91
+ await Promise.all(
92
+ [...new Set(candidates.map((c) => c.url))].map(async (url) => {
93
+ pixelsByUrl.set(url, await loadPixels(url));
94
+ }),
95
+ );
96
+
97
+ const flagged = [];
98
+ let checked = 0;
99
+ let wastedPixels = 0;
100
+ const wastedBytesByUrl = new Map();
101
+
102
+ candidates.forEach(({ img, rect, url }) => {
103
+ const file = pixelsByUrl.get(url);
104
+ if (!file || !file.width || !file.height) return void skippedNotLoaded++;
105
+ checked++;
106
+
107
+ const style = getComputedStyle(img);
108
+ const boxW = rect.width - px(style.paddingLeft) - px(style.paddingRight) - px(style.borderLeftWidth) - px(style.borderRightWidth);
109
+ const boxH = rect.height - px(style.paddingTop) - px(style.paddingBottom) - px(style.borderTopWidth) - px(style.borderBottomWidth);
110
+ if (boxW <= 0 || boxH <= 0) return;
111
+
112
+ const { sx, sy } = scaleOf(img, boxW, boxH, file.width, file.height);
113
+ const neededWidth = Math.round(file.width * sx * dpr);
114
+ const neededHeight = Math.round(file.height * sy * dpr);
115
+ // Pixels of the file per device pixel drawn, on the tighter axis
116
+ const ratio = Math.min(1 / (sx * dpr), 1 / (sy * dpr));
117
+
118
+ let verdict = null;
119
+ if (ratio >= OVERSIZED_RATIO) verdict = "oversized";
120
+ else if (ratio < UNDERSIZED_RATIO) verdict = "undersized";
121
+ if (!verdict) return;
122
+
123
+ const filePixels = file.width * file.height;
124
+ const wasted = verdict === "oversized" ? Math.max(0, filePixels - neededWidth * neededHeight) : 0;
125
+ const transferBytes = transferByUrl.get(url) ?? null;
126
+ const wastedBytes = transferBytes === null ? null : Math.round(transferBytes * (wasted / filePixels));
127
+
128
+ wastedPixels += wasted;
129
+ if (wastedBytes !== null) wastedBytesByUrl.set(url, Math.max(wastedBytesByUrl.get(url) ?? 0, wastedBytes));
130
+
131
+ flagged.push({
132
+ selector: selectorOf(img),
133
+ url: shortUrl(url),
134
+ verdict,
135
+ naturalWidth: file.width,
136
+ naturalHeight: file.height,
137
+ renderedWidth: Math.round(boxW),
138
+ renderedHeight: Math.round(boxH),
139
+ neededWidth,
140
+ neededHeight,
141
+ ratio: round2(ratio),
142
+ wastedPixels: wasted,
143
+ transferBytes,
144
+ wastedBytes,
145
+ hasSrcset: img.hasAttribute("srcset"),
146
+ hasSizes: img.hasAttribute("sizes"),
147
+ });
148
+ });
149
+
150
+ // Most waste first; undersized images (no waste) follow, the blurriest first
151
+ flagged.sort((a, b) => b.wastedPixels - a.wastedPixels || a.ratio - b.ratio);
152
+
153
+ const oversized = flagged.filter((f) => f.verdict === "oversized").length;
154
+ const undersized = flagged.length - oversized;
155
+ // An image used several times downloads once, so each URL counts once
156
+ const wastedBytes = [...wastedBytesByUrl.values()].reduce((a, b) => a + b, 0);
157
+
158
+ const issues = flagged.map((f) => {
159
+ const size = `${f.naturalWidth}x${f.naturalHeight}`;
160
+ const shown = `${f.neededWidth}x${f.neededHeight}`;
161
+ if (f.verdict === "oversized") {
162
+ const kb = f.wastedBytes === null ? "" : `, about ${Math.round(f.wastedBytes / 1024)} KB wasted`;
163
+ return {
164
+ severity: f.ratio >= SEVERE_OVERSIZED ? "warning" : "info",
165
+ message: `${fileName(f.url)}: ${size} file for a ${shown} slot at ${dpr}x (${f.ratio}x too large${kb})`,
166
+ };
167
+ }
168
+ return {
169
+ severity: f.ratio < SEVERE_UNDERSIZED ? "warning" : "info",
170
+ message: `${fileName(f.url)}: ${size} file for a ${shown} slot at ${dpr}x (blurry, needs ${round2(1 / f.ratio)}x more pixels)`,
171
+ };
172
+ });
173
+
174
+ const items = flagged.slice(0, MAX_ITEMS);
175
+
176
+ console.group("%cOversized Images", "font-weight: bold; font-size: 14px;");
177
+ console.log(` Images checked : ${checked} (devicePixelRatio ${dpr})`);
178
+ console.log(` Oversized : ${oversized}`);
179
+ console.log(` Undersized : ${undersized}`);
180
+ console.log(` Wasted pixels : ${wastedPixels.toLocaleString()}`);
181
+ console.log(` Wasted bytes : ${Math.round(wastedBytes / 1024)} KB (estimated)`);
182
+ if (items.length > 0) {
183
+ console.table(
184
+ items.map((f) => ({
185
+ image: fileName(f.url).slice(0, 40),
186
+ verdict: f.verdict,
187
+ file: `${f.naturalWidth}x${f.naturalHeight}`,
188
+ needed: `${f.neededWidth}x${f.neededHeight}`,
189
+ ratio: f.ratio,
190
+ "wasted KB": f.wastedBytes === null ? "n/a" : Math.round(f.wastedBytes / 1024),
191
+ })),
192
+ );
193
+ } else {
194
+ console.log("%cNo oversized or undersized images found.", "color: #22c55e; font-weight: bold;");
195
+ }
196
+ console.groupEnd();
197
+
198
+ return {
199
+ script: "Oversized-Images",
200
+ status: "ok",
201
+ count: flagged.length,
202
+ details: {
203
+ devicePixelRatio: dpr,
204
+ totalImages: images.length,
205
+ imagesChecked: checked,
206
+ oversized,
207
+ undersized,
208
+ wastedPixels,
209
+ wastedBytes,
210
+ skippedHidden,
211
+ skippedNotLoaded,
212
+ skippedVector,
213
+ },
214
+ items,
215
+ issues,
216
+ };
217
+ })();
@@ -1,7 +1,7 @@
1
1
  // SVG Embedded Bitmap Analysis
2
2
  // https://webperf-snippets.nucliweb.net
3
3
 
4
- void (async () => {
4
+ (async () => {
5
5
  function formatSize(bytes) {
6
6
  if (!bytes || bytes === 0) return "—";
7
7
  if (bytes < 1024) return `${bytes} B`;
@@ -20,7 +20,7 @@ void (async () => {
20
20
  }
21
21
 
22
22
  function compressionFromEntry(entry) {
23
- if (entry.transferSize === 0 && entry.encodedBodySize === 0) return "cached";
23
+ // Cross-origin resources without Timing-Allow-Origin report every size as 0
24
24
  if (entry.encodedBodySize === 0) return "unknown";
25
25
  if (entry.encodedBodySize < entry.decodedBodySize) return "compressed";
26
26
  return "none";
@@ -109,7 +109,14 @@ void (async () => {
109
109
 
110
110
  if (svgEntries.length === 0 && inlineSvgTotal === 0) {
111
111
  console.log("No SVG resources found on this page.");
112
- return { script: "SVG-Embedded-Bitmap-Analysis", status: "ok", count: 0, items: [], issues: [] };
112
+ return {
113
+ script: "SVG-Embedded-Bitmap-Analysis",
114
+ status: "ok",
115
+ count: 0,
116
+ details: { externalSvgCount: 0, inlineSvgTotal: 0, svgsWithUse: 0, standaloneInlineSvgs: 0, spriteOpportunity: false },
117
+ items: [],
118
+ issues: [],
119
+ };
113
120
  }
114
121
 
115
122
  console.group("%c🖼️ SVG Embedded Bitmap Analysis", "font-weight: bold; font-size: 14px;");
@@ -210,6 +217,14 @@ void (async () => {
210
217
  script: "SVG-Embedded-Bitmap-Analysis",
211
218
  status: "ok",
212
219
  count: withBitmaps.length,
220
+ details: {
221
+ externalSvgCount: svgEntries.length,
222
+ inlineSvgTotal,
223
+ svgsWithUse,
224
+ standaloneInlineSvgs,
225
+ // Five or more standalone inline SVGs are worth moving into a <symbol> sprite
226
+ spriteOpportunity: standaloneInlineSvgs >= 5,
227
+ },
213
228
  items: withBitmaps.map((r) => ({
214
229
  url: r.url || r.name,
215
230
  name: r.name,
@@ -19,6 +19,15 @@ When the user asks "optimize images" or "check image performance":
19
19
  - Find-Images-With-Lazy-and-Fetchpriority.js (contradictory attributes)
20
20
  - Priority-Hints-Audit.js (LCP image should have fetchpriority="high")
21
21
 
22
+ ### Oversized Images Audit
23
+
24
+ When the user asks about images larger than needed, wasted bytes, blurry images, or `srcset`/`sizes` problems:
25
+
26
+ 1. **Oversized-Images.js** - Compare each image file with the pixels drawn (oversized and undersized)
27
+ 2. **Image-Element-Audit.js** - Check format, lazy loading and `srcset` usage of the flagged images
28
+ 3. Cross-reference with **webperf-core-web-vitals** skill:
29
+ - LCP.js (check whether an oversized image is the LCP element)
30
+
22
31
  ### Video Performance Audit
23
32
 
24
33
  When the user asks "optimize videos" or "check video performance":
@@ -106,6 +115,19 @@ Use this decision tree to automatically run follow-up snippets based on results:
106
115
 
107
116
  - **If images missing alt text** → Accessibility issue, recommend adding descriptive alt text
108
117
 
118
+ ### After Oversized-Images.js
119
+
120
+ - **If oversized images found** → Recommend:
121
+ - `srcset` with several widths and an accurate `sizes` attribute
122
+ - Resizing at build time or with an image CDN
123
+ - Run **Image-Element-Audit.js** to check format and lazy loading of the same images
124
+
125
+ - **If an oversized image is the LCP element** → Run **webperf-core-web-vitals:LCP.js** and **LCP-Subparts.js** (large files inflate resource load duration)
126
+
127
+ - **If undersized (blurry) images found** → Recommend a source with at least the needed pixels and a `2x` candidate for high-density screens
128
+
129
+ - **If skipped images are lazy-loaded** → Scroll to the end of the page and run the snippet again
130
+
109
131
  ### After Video-Element-Audit.js
110
132
 
111
133
  - **If video is LCP candidate** → Run:
@@ -89,9 +89,16 @@
89
89
 
90
90
  console.groupEnd();
91
91
 
92
+ const connectionRating =
93
+ effectiveType === "4g" ? "good"
94
+ : effectiveType === "3g" ? "needs-improvement"
95
+ : effectiveType === "2g" || effectiveType === "slow-2g" ? "poor"
96
+ : undefined;
97
+
92
98
  return {
93
99
  script: "Network-Bandwidth-Connection-Quality",
94
100
  status: "ok",
101
+ ...(connectionRating !== undefined ? { rating: connectionRating } : {}),
95
102
  details: {
96
103
  effectiveType,
97
104
  downlink: connection.downlink,