webperf-snippets 0.1.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 (67) hide show
  1. package/README.md +139 -0
  2. package/package.json +54 -0
  3. package/snippets/CoreWebVitals/CLS.js +88 -0
  4. package/snippets/CoreWebVitals/INP.js +432 -0
  5. package/snippets/CoreWebVitals/LCP-Image-Entropy.js +198 -0
  6. package/snippets/CoreWebVitals/LCP-Subparts.js +286 -0
  7. package/snippets/CoreWebVitals/LCP-Trail.js +184 -0
  8. package/snippets/CoreWebVitals/LCP-Video-Candidate.js +228 -0
  9. package/snippets/CoreWebVitals/LCP.js +135 -0
  10. package/snippets/CoreWebVitals/WORKFLOWS.md +203 -0
  11. package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-inject.js +73 -0
  12. package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-read.js +191 -0
  13. package/snippets/Interaction/Forced-Synchronous-Layout.js +286 -0
  14. package/snippets/Interaction/Input-Latency-Breakdown.js +199 -0
  15. package/snippets/Interaction/Interactions.js +220 -0
  16. package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +266 -0
  17. package/snippets/Interaction/Long-Animation-Frames-Helpers.js +397 -0
  18. package/snippets/Interaction/Long-Animation-Frames-Script-Attribution.js +137 -0
  19. package/snippets/Interaction/Long-Animation-Frames.js +298 -0
  20. package/snippets/Interaction/LongTask.js +203 -0
  21. package/snippets/Interaction/Scroll-Performance.js +359 -0
  22. package/snippets/Interaction/WORKFLOWS.md +205 -0
  23. package/snippets/Loading/Back-Forward-Cache.js +473 -0
  24. package/snippets/Loading/CSS-Media-Queries-Analysis.js +512 -0
  25. package/snippets/Loading/Cache-Strategy-Analysis.js +808 -0
  26. package/snippets/Loading/Client-Side-Redirect-Detection.js +337 -0
  27. package/snippets/Loading/Content-Visibility.js +352 -0
  28. package/snippets/Loading/Critical-CSS-Detection.js +329 -0
  29. package/snippets/Loading/Event-Processing-Time.js +179 -0
  30. package/snippets/Loading/FCP.js +101 -0
  31. package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +264 -0
  32. package/snippets/Loading/Find-Images-With-Lazy-and-Fetchpriority.js +155 -0
  33. package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +300 -0
  34. package/snippets/Loading/Find-render-blocking-resources.js +161 -0
  35. package/snippets/Loading/First-And-Third-Party-Script-Info.js +269 -0
  36. package/snippets/Loading/First-And-Third-Party-Script-Timings.js +295 -0
  37. package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +317 -0
  38. package/snippets/Loading/Inline-CSS-Info-and-Size.js +234 -0
  39. package/snippets/Loading/Inline-Script-Info-and-Size.js +323 -0
  40. package/snippets/Loading/JS-Execution-Time-Breakdown.js +336 -0
  41. package/snippets/Loading/Prefetch-Resource-Validation.js +456 -0
  42. package/snippets/Loading/Priority-Hints-Audit.js +336 -0
  43. package/snippets/Loading/Resource-Hints-Validation.js +503 -0
  44. package/snippets/Loading/Resource-Hints.js +400 -0
  45. package/snippets/Loading/SSR-Hydration-Data-Analysis.js +364 -0
  46. package/snippets/Loading/Script-Loading.js +332 -0
  47. package/snippets/Loading/Service-Worker-Analysis.js +192 -0
  48. package/snippets/Loading/TTFB-Resources.js +115 -0
  49. package/snippets/Loading/TTFB-Sub-Parts.js +117 -0
  50. package/snippets/Loading/TTFB.js +37 -0
  51. package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +394 -0
  52. package/snippets/Loading/WORKFLOWS.md +163 -0
  53. package/snippets/Media/Image-Element-Audit.js +350 -0
  54. package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +225 -0
  55. package/snippets/Media/Video-Element-Audit.js +282 -0
  56. package/snippets/Media/WORKFLOWS.md +190 -0
  57. package/snippets/README.md +98 -0
  58. package/snippets/Resources/Network-Bandwidth-Connection-Quality.js +103 -0
  59. package/snippets/Resources/WORKFLOWS.md +173 -0
  60. package/src/bin.js +229 -0
  61. package/src/decision-tree.js +19 -0
  62. package/src/load-snippet.js +23 -0
  63. package/src/reporters/human.js +179 -0
  64. package/src/reporters/json.js +3 -0
  65. package/src/runner.js +61 -0
  66. package/src/workflows/audit.js +21 -0
  67. package/src/workflows/cwv.js +10 -0
@@ -0,0 +1,432 @@
1
+ // INP (Interaction to Next Paint) Tracking
2
+ // https://webperf-snippets.nucliweb.net
3
+
4
+ (() => {
5
+ const interactions = [];
6
+ let inpValue = 0;
7
+ let inpEntry = null;
8
+
9
+ const valueToRating = (ms) =>
10
+ ms <= 200 ? "good" : ms <= 500 ? "needs-improvement" : "poor";
11
+
12
+ const RATING = {
13
+ good: { icon: "🟢", color: "#0CCE6A" },
14
+ "needs-improvement": { icon: "🟔", color: "#FFA400" },
15
+ poor: { icon: "šŸ”“", color: "#FF4E42" },
16
+ };
17
+
18
+ const formatMs = (ms) => `${Math.round(ms)}ms`;
19
+
20
+ // Calculate INP (98th percentile of all interactions)
21
+ const calculateINP = () => {
22
+ if (interactions.length === 0) return { value: 0, entry: null };
23
+
24
+ // Sort by duration
25
+ const sorted = [...interactions].sort((a, b) => b.duration - a.duration);
26
+
27
+ // Get 98th percentile (or worst if < 50 interactions)
28
+ const index = interactions.length < 50
29
+ ? 0
30
+ : Math.floor(interactions.length * 0.02);
31
+
32
+ return {
33
+ value: sorted[index].duration,
34
+ entry: sorted[index],
35
+ };
36
+ };
37
+
38
+ // Format interaction name
39
+ const getInteractionName = (entry) => {
40
+ const target = entry.target;
41
+ if (!target) return entry.name;
42
+
43
+ let selector = target.tagName.toLowerCase();
44
+ if (target.id) selector += `#${target.id}`;
45
+ else if (target.className && typeof target.className === "string") {
46
+ const classes = target.className.trim().split(/\s+/).slice(0, 2).join(".");
47
+ if (classes) selector += `.${classes}`;
48
+ }
49
+
50
+ return `${entry.name} → ${selector}`;
51
+ };
52
+
53
+ // Get phase breakdown (requires LoAF support)
54
+ const getPhaseBreakdown = (entry) => {
55
+ const phases = {
56
+ inputDelay: 0,
57
+ processingTime: 0,
58
+ presentationDelay: 0,
59
+ };
60
+
61
+ if (entry.processingStart && entry.processingEnd) {
62
+ phases.inputDelay = entry.processingStart - entry.startTime;
63
+ phases.processingTime = entry.processingEnd - entry.processingStart;
64
+ phases.presentationDelay = entry.duration - phases.inputDelay - phases.processingTime;
65
+ }
66
+
67
+ return phases;
68
+ };
69
+
70
+ // Observer for interactions
71
+ const observer = new PerformanceObserver((list) => {
72
+ for (const entry of list.getEntries()) {
73
+ // Only track interactions with interactionId (meaningful interactions)
74
+ if (!entry.interactionId) continue;
75
+
76
+ // Avoid duplicate entries for the same interaction
77
+ const existing = interactions.find(
78
+ (i) => i.interactionId === entry.interactionId
79
+ );
80
+
81
+ if (!existing || entry.duration > existing.duration) {
82
+ // Remove old entry if exists
83
+ if (existing) {
84
+ const idx = interactions.indexOf(existing);
85
+ interactions.splice(idx, 1);
86
+ }
87
+
88
+ interactions.push({
89
+ name: entry.name,
90
+ duration: entry.duration,
91
+ startTime: entry.startTime,
92
+ interactionId: entry.interactionId,
93
+ target: entry.target,
94
+ processingStart: entry.processingStart,
95
+ processingEnd: entry.processingEnd,
96
+ formattedName: getInteractionName(entry),
97
+ phases: getPhaseBreakdown(entry),
98
+ entry,
99
+ });
100
+ }
101
+
102
+ // Recalculate INP
103
+ const result = calculateINP();
104
+ inpValue = result.value;
105
+ inpEntry = result.entry;
106
+ }
107
+ });
108
+
109
+ // Observe event timing
110
+ observer.observe({
111
+ type: "event",
112
+ buffered: true,
113
+ durationThreshold: 16, // Only interactions > 16ms (1 frame)
114
+ });
115
+
116
+ // Log INP summary
117
+ const logINP = () => {
118
+ const rating = valueToRating(inpValue);
119
+ const { icon, color } = RATING[rating];
120
+
121
+ console.group(
122
+ `%cINP: ${icon} ${formatMs(inpValue)} (${rating})`,
123
+ `color: ${color}; font-weight: bold; font-size: 14px;`
124
+ );
125
+
126
+ console.log("");
127
+ console.log(`%cšŸ“Š Analysis:`, "font-weight: bold;");
128
+ console.log(` Total interactions tracked: ${interactions.length}`);
129
+ console.log(` INP (98th percentile): ${formatMs(inpValue)}`);
130
+
131
+ if (inpEntry) {
132
+ console.log("");
133
+ console.log(`%cšŸŽÆ Worst Interaction (INP):`, "font-weight: bold; color: ${color};");
134
+ console.log(` Event: ${inpEntry.formattedName}`);
135
+ console.log(` Duration: ${formatMs(inpEntry.duration)}`);
136
+
137
+ // Element attribution
138
+ if (inpEntry.target) {
139
+ console.log(` Target Element:`, inpEntry.target);
140
+
141
+ // Get element path for better context
142
+ const getElementPath = (el) => {
143
+ if (!el) return "";
144
+ const parts = [];
145
+ let current = el;
146
+ while (current && current !== document.body && parts.length < 5) {
147
+ let selector = current.tagName.toLowerCase();
148
+ if (current.id) selector += `#${current.id}`;
149
+ else if (current.className && typeof current.className === "string") {
150
+ const classes = current.className.trim().split(/\s+/).slice(0, 2).join(".");
151
+ if (classes) selector += `.${classes}`;
152
+ }
153
+ parts.unshift(selector);
154
+ current = current.parentElement;
155
+ }
156
+ return parts.join(" > ");
157
+ };
158
+
159
+ const path = getElementPath(inpEntry.target);
160
+ if (path) {
161
+ console.log(` Element Path: ${path}`);
162
+ }
163
+ }
164
+
165
+ // Phase breakdown
166
+ const phases = inpEntry.phases;
167
+ if (phases.inputDelay > 0) {
168
+ console.log("");
169
+ console.log(`%cā±ļø Phase Breakdown:`, "font-weight: bold;");
170
+ console.log(` Input Delay: ${formatMs(phases.inputDelay)}`);
171
+ console.log(` Processing Time: ${formatMs(phases.processingTime)}`);
172
+ console.log(` Presentation Delay: ${formatMs(phases.presentationDelay)}`);
173
+
174
+ // Visual bar
175
+ const total = inpEntry.duration;
176
+ const barWidth = 40;
177
+ const inputBar = "ā–“".repeat(Math.round((phases.inputDelay / total) * barWidth));
178
+ const processBar = "ā–ˆ".repeat(Math.round((phases.processingTime / total) * barWidth));
179
+ const presentBar = "ā–‘".repeat(Math.round((phases.presentationDelay / total) * barWidth));
180
+ console.log(` ${inputBar}${processBar}${presentBar}`);
181
+ console.log(" ā–“ Input ā–ˆ Processing ā–‘ Presentation");
182
+ }
183
+
184
+ // Recommendations based on phases
185
+ if (inpValue > 200 && phases.inputDelay > 0) {
186
+ console.log("");
187
+ console.log("%cšŸ’” Recommendations:", "color: #3b82f6; font-weight: bold;");
188
+
189
+ if (phases.inputDelay > 100) {
190
+ console.log(" • High input delay - Break up long tasks before interaction");
191
+ }
192
+ if (phases.processingTime > 200) {
193
+ console.log(" • Long processing time - Optimize event handlers");
194
+ console.log(" • Consider debouncing, use requestIdleCallback for non-critical work");
195
+ }
196
+ if (phases.presentationDelay > 100) {
197
+ console.log(" • High presentation delay - Reduce render complexity");
198
+ console.log(" • Batch DOM updates, use content-visibility");
199
+ }
200
+ }
201
+ }
202
+
203
+ // Slow interactions breakdown
204
+ const slowInteractions = interactions
205
+ .filter((i) => i.duration > 200)
206
+ .sort((a, b) => b.duration - a.duration)
207
+ .slice(0, 10);
208
+
209
+ if (slowInteractions.length > 0) {
210
+ console.log("");
211
+ console.log(`%c🐌 Slow Interactions (> 200ms):`, "color: #ef4444; font-weight: bold;");
212
+ console.table(
213
+ slowInteractions.map((i) => ({
214
+ Event: i.formattedName,
215
+ "Duration (ms)": Math.round(i.duration),
216
+ "Start Time (ms)": Math.round(i.startTime),
217
+ }))
218
+ );
219
+
220
+ // Show element attribution for top 3
221
+ console.log("");
222
+ console.log(`%cšŸŽÆ Element Attribution (top 3):`, "font-weight: bold;");
223
+ slowInteractions.slice(0, 3).forEach((interaction, idx) => {
224
+ console.log(` ${idx + 1}. ${interaction.formattedName} (${Math.round(interaction.duration)}ms)`);
225
+ if (interaction.target) {
226
+ console.log(` Element:`, interaction.target);
227
+ } else {
228
+ console.log(` Element: (no target available)`);
229
+ }
230
+ });
231
+ }
232
+
233
+ // Interaction types breakdown
234
+ const byType = {};
235
+ interactions.forEach((i) => {
236
+ const type = i.name;
237
+ if (!byType[type]) {
238
+ byType[type] = { count: 0, totalDuration: 0, maxDuration: 0 };
239
+ }
240
+ byType[type].count++;
241
+ byType[type].totalDuration += i.duration;
242
+ byType[type].maxDuration = Math.max(byType[type].maxDuration, i.duration);
243
+ });
244
+
245
+ if (Object.keys(byType).length > 0) {
246
+ console.log("");
247
+ console.log(`%cšŸ“‹ By Interaction Type:`, "font-weight: bold;");
248
+ console.table(
249
+ Object.entries(byType).map(([type, stats]) => ({
250
+ Type: type,
251
+ Count: stats.count,
252
+ "Avg (ms)": Math.round(stats.totalDuration / stats.count),
253
+ "Max (ms)": Math.round(stats.maxDuration),
254
+ }))
255
+ );
256
+ }
257
+
258
+ // General recommendations if no phases available
259
+ if (inpValue > 200 && (!inpEntry || !inpEntry.phases || inpEntry.phases.inputDelay === 0)) {
260
+ console.log("");
261
+ console.log("%cšŸ’” Recommendations:", "color: #3b82f6; font-weight: bold;");
262
+ console.log(" • Break up long tasks using scheduler.yield() or setTimeout");
263
+ console.log(" • Optimize event handlers - reduce computation time");
264
+ console.log(" • Consider debouncing for frequent events");
265
+ console.log(" • Move heavy work to Web Workers");
266
+ console.log(" • Use requestIdleCallback for non-critical work");
267
+
268
+ console.log("");
269
+ console.log(" Run getINPDetails() for full interaction list");
270
+ console.log(" Use Long Animation Frames snippet to identify blocking scripts");
271
+ }
272
+
273
+ console.groupEnd();
274
+ };
275
+
276
+ // Expose function to check INP anytime
277
+ window.getINP = () => {
278
+ const result = calculateINP();
279
+ inpValue = result.value;
280
+ inpEntry = result.entry;
281
+ logINP();
282
+ const rating = valueToRating(inpValue);
283
+ const details = { totalInteractions: interactions.length };
284
+ if (inpEntry) {
285
+ details.worstEvent = inpEntry.formattedName;
286
+ details.phases = {
287
+ inputDelay: Math.round(inpEntry.phases.inputDelay),
288
+ processingTime: Math.round(inpEntry.phases.processingTime),
289
+ presentationDelay: Math.round(inpEntry.phases.presentationDelay),
290
+ };
291
+ }
292
+ if (interactions.length === 0) {
293
+ return {
294
+ script: "INP",
295
+ status: "error",
296
+ error: "No interactions recorded yet. Interact with the page and call getINP() again.",
297
+ getDataFn: "getINP",
298
+ details,
299
+ };
300
+ }
301
+ return {
302
+ script: "INP",
303
+ status: "ok",
304
+ metric: "INP",
305
+ value: Math.round(inpValue),
306
+ unit: "ms",
307
+ rating,
308
+ thresholds: { good: 200, needsImprovement: 500 },
309
+ details,
310
+ };
311
+ };
312
+
313
+ // Expose function to get all interactions
314
+ window.getINPDetails = () => {
315
+ console.group("%cšŸ“Š All Interactions Detail", "font-weight: bold; font-size: 14px;");
316
+
317
+ if (interactions.length === 0) {
318
+ console.log(" No interactions recorded yet.");
319
+ console.groupEnd();
320
+ return [];
321
+ }
322
+
323
+ const sorted = [...interactions].sort((a, b) => b.duration - a.duration);
324
+
325
+ console.log("");
326
+ console.log("%cInteraction Summary:", "font-weight: bold;");
327
+ console.table(
328
+ sorted.map((i, idx) => ({
329
+ "#": idx + 1,
330
+ Event: i.formattedName,
331
+ "Duration (ms)": Math.round(i.duration),
332
+ "Start (ms)": Math.round(i.startTime),
333
+ "Input Delay": Math.round(i.phases.inputDelay),
334
+ Processing: Math.round(i.phases.processingTime),
335
+ Presentation: Math.round(i.phases.presentationDelay),
336
+ }))
337
+ );
338
+
339
+ // Show element attribution for all interactions
340
+ console.log("");
341
+ console.log("%cšŸŽÆ Element Attribution:", "font-weight: bold;");
342
+
343
+ const maxToShow = Math.min(sorted.length, 15); // Show up to 15
344
+ sorted.slice(0, maxToShow).forEach((interaction, idx) => {
345
+ const phases = interaction.phases;
346
+ const hasPhases = phases.inputDelay > 0;
347
+
348
+ console.group(
349
+ `${idx + 1}. ${interaction.formattedName} - ${Math.round(interaction.duration)}ms`
350
+ );
351
+
352
+ if (interaction.target) {
353
+ console.log("Element:", interaction.target);
354
+
355
+ // Get element path for better identification
356
+ const getPath = (el) => {
357
+ if (!el) return "";
358
+ const parts = [];
359
+ let current = el;
360
+ while (current && current !== document.body && parts.length < 5) {
361
+ let selector = current.tagName.toLowerCase();
362
+ if (current.id) selector += `#${current.id}`;
363
+ else if (current.className && typeof current.className === "string") {
364
+ const classes = current.className.trim().split(/\s+/).slice(0, 2).join(".");
365
+ if (classes) selector += `.${classes}`;
366
+ }
367
+ parts.unshift(selector);
368
+ current = current.parentElement;
369
+ }
370
+ return parts.join(" > ");
371
+ };
372
+
373
+ const path = getPath(interaction.target);
374
+ if (path) {
375
+ console.log("Path:", path);
376
+ }
377
+ } else {
378
+ console.log("Element: (no target available)");
379
+ }
380
+
381
+ if (hasPhases) {
382
+ console.log(
383
+ `Phases: Input ${Math.round(phases.inputDelay)}ms | ` +
384
+ `Processing ${Math.round(phases.processingTime)}ms | ` +
385
+ `Presentation ${Math.round(phases.presentationDelay)}ms`
386
+ );
387
+ }
388
+
389
+ console.groupEnd();
390
+ });
391
+
392
+ if (sorted.length > maxToShow) {
393
+ console.log(` ... and ${sorted.length - maxToShow} more interactions`);
394
+ }
395
+
396
+ console.groupEnd();
397
+ return sorted;
398
+ };
399
+
400
+ // Log on page hide (final INP)
401
+ document.addEventListener("visibilitychange", () => {
402
+ if (document.visibilityState === "hidden") {
403
+ observer.takeRecords();
404
+ const result = calculateINP();
405
+ inpValue = result.value;
406
+ inpEntry = result.entry;
407
+
408
+ console.log("%cšŸ“Š Final INP (on page hide):", "font-weight: bold;");
409
+ logINP();
410
+ }
411
+ });
412
+
413
+ console.log("%c⚔ INP Tracking Active", "font-weight: bold; font-size: 14px;");
414
+ console.log(" Interactions with duration > 16ms will be tracked.");
415
+ console.log(
416
+ " Call %cgetINP()%c to see current INP value.",
417
+ "font-family: monospace; background: #f3f4f6; padding: 2px 4px;",
418
+ ""
419
+ );
420
+ console.log(
421
+ " Call %cgetINPDetails()%c for full interaction list.",
422
+ "font-family: monospace; background: #f3f4f6; padding: 2px 4px;",
423
+ ""
424
+ );
425
+
426
+ return {
427
+ script: "INP",
428
+ status: "tracking",
429
+ message: "INP tracking active. Interact with the page then call getINP() for results.",
430
+ getDataFn: "getINP",
431
+ };
432
+ })();
@@ -0,0 +1,198 @@
1
+ // LCP Image Entropy Check
2
+ // https://webperf-snippets.nucliweb.net
3
+
4
+ (() => {
5
+ const formatBytes = (bytes) => {
6
+ if (!bytes) return "-";
7
+ const k = 1024;
8
+ const sizes = ["B", "KB", "MB"];
9
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
10
+ return (bytes / Math.pow(k, i)).toFixed(1) + " " + sizes[i];
11
+ };
12
+
13
+ const LCP_THRESHOLD = 0.05; // Chrome's threshold for low-entropy
14
+
15
+ // Get current LCP element
16
+ let lcpElement = null;
17
+ let lcpUrl = null;
18
+
19
+ const lcpObserver = new PerformanceObserver((list) => {
20
+ const entries = list.getEntries();
21
+ const lastEntry = entries[entries.length - 1];
22
+ if (lastEntry) {
23
+ lcpElement = lastEntry.element;
24
+ lcpUrl = lastEntry.url;
25
+ }
26
+ });
27
+
28
+ lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });
29
+
30
+ // Wait a tick to ensure LCP is captured (for human console output)
31
+ setTimeout(() => {
32
+ lcpObserver.disconnect();
33
+
34
+ // Get all images
35
+ const images = [...document.images]
36
+ .filter((img) => {
37
+ const src = img.currentSrc || img.src;
38
+ return src && !src.startsWith("data:image");
39
+ })
40
+ .map((img) => {
41
+ const src = img.currentSrc || img.src;
42
+ const resource = performance.getEntriesByName(src)[0];
43
+ const fileSize = resource?.encodedBodySize || 0;
44
+ const pixels = img.naturalWidth * img.naturalHeight;
45
+ const bpp = pixels > 0 ? (fileSize * 8) / pixels : 0;
46
+
47
+ const isLowEntropy = bpp > 0 && bpp < LCP_THRESHOLD;
48
+ const isLCP = lcpElement === img || lcpUrl === src;
49
+
50
+ return {
51
+ element: img,
52
+ src,
53
+ shortSrc: src.split("/").pop()?.split("?")[0] || src,
54
+ width: img.naturalWidth,
55
+ height: img.naturalHeight,
56
+ fileSize,
57
+ bpp,
58
+ isLowEntropy,
59
+ isLCP,
60
+ lcpEligible: !isLowEntropy && bpp > 0,
61
+ };
62
+ })
63
+ .filter((img) => img.bpp > 0); // Only images with measurable BPP
64
+
65
+ console.group("%cšŸ–¼ļø Image Entropy Analysis", "font-weight: bold; font-size: 14px;");
66
+
67
+ if (images.length === 0) {
68
+ console.log(" No images with measurable entropy found.");
69
+ console.log(" (Data URLs and cross-origin images without CORS are excluded)");
70
+ console.groupEnd();
71
+ return;
72
+ }
73
+
74
+ // Summary
75
+ const lowEntropy = images.filter((img) => img.isLowEntropy);
76
+ const normalEntropy = images.filter((img) => !img.isLowEntropy);
77
+ const lcpImage = images.find((img) => img.isLCP);
78
+
79
+ console.log("");
80
+ console.log("%cSummary:", "font-weight: bold;");
81
+ console.log(` Total images analyzed: ${images.length}`);
82
+ console.log(` 🟢 Normal entropy (LCP eligible): ${normalEntropy.length}`);
83
+ console.log(` šŸ”“ Low entropy (LCP ineligible): ${lowEntropy.length}`);
84
+
85
+ if (lcpImage) {
86
+ const icon = lcpImage.isLowEntropy ? "āš ļø" : "āœ…";
87
+ console.log("");
88
+ console.log(`%c${icon} Current LCP image:`, "font-weight: bold;");
89
+ console.log(` ${lcpImage.shortSrc}`);
90
+ console.log(` BPP: ${lcpImage.bpp.toFixed(4)} ${lcpImage.isLowEntropy ? "(LOW - may be skipped!)" : "(OK)"}`);
91
+ }
92
+
93
+ // Table
94
+ console.log("");
95
+ console.log("%cAll Images:", "font-weight: bold;");
96
+
97
+ const tableData = images
98
+ .sort((a, b) => b.bpp - a.bpp)
99
+ .map((img) => ({
100
+ Image: img.shortSrc.length > 30 ? "..." + img.shortSrc.slice(-27) : img.shortSrc,
101
+ Dimensions: `${img.width}Ɨ${img.height}`,
102
+ Size: formatBytes(img.fileSize),
103
+ BPP: img.bpp.toFixed(4),
104
+ Entropy: img.isLowEntropy ? "šŸ”“ Low" : "🟢 Normal",
105
+ "LCP Eligible": img.lcpEligible ? "āœ…" : "āŒ",
106
+ "Is LCP": img.isLCP ? "šŸ‘ˆ" : "",
107
+ }));
108
+
109
+ console.table(tableData);
110
+
111
+ // Warnings
112
+ if (lowEntropy.length > 0) {
113
+ console.log("");
114
+ console.log("%cāš ļø Low Entropy Images:", "font-weight: bold; color: #f59e0b;");
115
+ console.log(" These images will NOT be considered for LCP in Chrome 112+:");
116
+ lowEntropy.forEach((img) => {
117
+ console.log(` • ${img.shortSrc} (BPP: ${img.bpp.toFixed(4)})`, img.element);
118
+ });
119
+ }
120
+
121
+ if (lcpImage && lcpImage.isLowEntropy) {
122
+ console.log("");
123
+ console.log("%c🚨 Warning:", "font-weight: bold; color: #ef4444;");
124
+ console.log(" Your LCP image has low entropy and may be skipped by Chrome!");
125
+ console.log(" Chrome will use the next largest element instead.");
126
+ console.log("");
127
+ console.log("%cšŸ’” Solutions:", "font-weight: bold; color: #3b82f6;");
128
+ console.log(" • Replace placeholder with actual content image");
129
+ console.log(" • Use a text element as LCP instead");
130
+ console.log(" • Ensure hero image loads with sufficient detail");
131
+ }
132
+
133
+ // Elements for inspection
134
+ console.log("");
135
+ console.log("%cšŸ”Ž Inspect elements:", "font-weight: bold;");
136
+ images.forEach((img, i) => {
137
+ const icon = img.isLowEntropy ? "šŸ”“" : "🟢";
138
+ const lcpMark = img.isLCP ? " šŸ‘ˆ LCP" : "";
139
+ console.log(` ${i + 1}. ${icon} ${img.shortSrc}${lcpMark}`, img.element);
140
+ });
141
+
142
+ console.groupEnd();
143
+ }, 100);
144
+
145
+ // Synchronous return for agent (buffered entries + DOM)
146
+ const lcpEntriesSync = performance.getEntriesByType("largest-contentful-paint");
147
+ const lcpEntrySync = lcpEntriesSync.at(-1);
148
+ const lcpElementSync = lcpEntrySync?.element ?? null;
149
+ const lcpUrlSync = lcpEntrySync?.url ?? null;
150
+ const imagesSync = [...document.images]
151
+ .filter((img) => { const src = img.currentSrc || img.src; return src && !src.startsWith("data:image"); })
152
+ .map((img) => {
153
+ const src = img.currentSrc || img.src;
154
+ const resource = performance.getEntriesByName(src)[0];
155
+ const fileSize = resource?.encodedBodySize || 0;
156
+ const pixels = img.naturalWidth * img.naturalHeight;
157
+ const bpp = pixels > 0 ? (fileSize * 8) / pixels : 0;
158
+ const isLowEntropy = bpp > 0 && bpp < LCP_THRESHOLD;
159
+ const isLCP = lcpElementSync === img || lcpUrlSync === src;
160
+ return {
161
+ url: src.split("/").pop()?.split("?")[0] || src,
162
+ width: img.naturalWidth,
163
+ height: img.naturalHeight,
164
+ fileSizeBytes: fileSize,
165
+ bpp: Math.round(bpp * 10000) / 10000,
166
+ isLowEntropy,
167
+ lcpEligible: !isLowEntropy && bpp > 0,
168
+ isLCP,
169
+ };
170
+ })
171
+ .filter((img) => img.bpp > 0);
172
+ const lowEntropyCount = imagesSync.filter((img) => img.isLowEntropy).length;
173
+ const lcpImageSync = imagesSync.find((img) => img.isLCP);
174
+ if (lcpElementSync) {
175
+ lcpElementSync.style.outline = "3px dashed lime";
176
+ lcpElementSync.style.outlineOffset = "2px";
177
+ }
178
+ const issuesSync = [];
179
+ if (lowEntropyCount > 0) {
180
+ issuesSync.push({ severity: "warning", message: `${lowEntropyCount} image(s) have low entropy and are LCP-ineligible in Chrome 112+` });
181
+ }
182
+ if (lcpImageSync?.isLowEntropy) {
183
+ issuesSync.push({ severity: "error", message: "Current LCP image has low entropy and may be skipped by Chrome" });
184
+ }
185
+ return {
186
+ script: "LCP-Image-Entropy",
187
+ status: "ok",
188
+ count: imagesSync.length,
189
+ details: {
190
+ totalImages: imagesSync.length,
191
+ lowEntropyCount,
192
+ lcpImageEligible: lcpImageSync ? !lcpImageSync.isLowEntropy : null,
193
+ lcpImage: lcpImageSync ? { url: lcpImageSync.url, bpp: lcpImageSync.bpp, isLowEntropy: lcpImageSync.isLowEntropy } : null,
194
+ },
195
+ items: imagesSync,
196
+ issues: issuesSync,
197
+ };
198
+ })();