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,266 @@
1
+ // Layout Shift Tracking
2
+ // https://webperf-snippets.nucliweb.net
3
+
4
+ (() => {
5
+ // CLS thresholds
6
+ const valueToRating = (score) =>
7
+ score <= 0.1 ? "good" : score <= 0.25 ? "needs-improvement" : "poor";
8
+
9
+ const RATING_COLORS = {
10
+ good: "#0CCE6A",
11
+ "needs-improvement": "#FFA400",
12
+ poor: "#FF4E42",
13
+ };
14
+
15
+ const RATING_ICONS = {
16
+ good: "🟢",
17
+ "needs-improvement": "🟔",
18
+ poor: "šŸ”“",
19
+ };
20
+
21
+ // Track all shifts
22
+ let totalCLS = 0;
23
+ const allShifts = [];
24
+ const elementShifts = new Map(); // Track shifts per element
25
+
26
+ const getElementSelector = (element) => {
27
+ if (!element) return "(unknown)";
28
+ if (element.id) return `#${element.id}`;
29
+ if (element.className && typeof element.className === "string") {
30
+ const classes = element.className.trim().split(/\s+/).slice(0, 2).join(".");
31
+ if (classes) return `${element.tagName.toLowerCase()}.${classes}`;
32
+ }
33
+ return element.tagName?.toLowerCase() || "(unknown)";
34
+ };
35
+
36
+ const observer = new PerformanceObserver((list) => {
37
+ for (const entry of list.getEntries()) {
38
+ // Only count shifts without recent input for CLS
39
+ const countedForCLS = !entry.hadRecentInput;
40
+
41
+ if (countedForCLS) {
42
+ totalCLS += entry.value;
43
+ }
44
+
45
+ // Get affected elements
46
+ const sources = entry.sources || [];
47
+ const elements = sources.map((source) => ({
48
+ element: source.node,
49
+ selector: getElementSelector(source.node),
50
+ previousRect: source.previousRect,
51
+ currentRect: source.currentRect,
52
+ }));
53
+
54
+ // Track per-element shifts
55
+ elements.forEach((el) => {
56
+ if (!elementShifts.has(el.selector)) {
57
+ elementShifts.set(el.selector, { count: 0, totalShift: 0, element: el.element });
58
+ }
59
+ const data = elementShifts.get(el.selector);
60
+ data.count++;
61
+ data.totalShift += entry.value;
62
+ });
63
+
64
+ const shift = {
65
+ value: entry.value,
66
+ countedForCLS,
67
+ elements,
68
+ time: entry.startTime,
69
+ entry,
70
+ };
71
+ allShifts.push(shift);
72
+
73
+ // Log significant shifts (> 0.001)
74
+ if (entry.value > 0.001) {
75
+ const rating = valueToRating(totalCLS);
76
+ const icon = countedForCLS ? RATING_ICONS[rating] : "⚪";
77
+ const clsColor = RATING_COLORS[rating];
78
+
79
+ console.groupCollapsed(
80
+ `%c${icon} Layout Shift: ${entry.value.toFixed(4)}${countedForCLS ? "" : " (not counted - user input)"}`,
81
+ `font-weight: bold; color: ${countedForCLS ? clsColor : "#888"};`
82
+ );
83
+
84
+ console.log(`%cCumulative CLS: ${totalCLS.toFixed(4)}`, `color: ${clsColor}; font-weight: bold;`);
85
+ console.log(` Time: ${Math.round(entry.startTime)}ms`);
86
+ console.log(` Had recent input: ${entry.hadRecentInput ? "Yes (excluded from CLS)" : "No"}`);
87
+
88
+ if (elements.length > 0) {
89
+ console.log("");
90
+ console.log("%cElements that shifted:", "font-weight: bold;");
91
+
92
+ elements.forEach((el, i) => {
93
+ console.log(` ${i + 1}. ${el.selector}`, el.element);
94
+ if (el.previousRect && el.currentRect) {
95
+ const dx = el.currentRect.x - el.previousRect.x;
96
+ const dy = el.currentRect.y - el.previousRect.y;
97
+ const dw = el.currentRect.width - el.previousRect.width;
98
+ const dh = el.currentRect.height - el.previousRect.height;
99
+
100
+ const changes = [];
101
+ if (Math.abs(dx) > 0) changes.push(`x: ${dx > 0 ? "+" : ""}${Math.round(dx)}px`);
102
+ if (Math.abs(dy) > 0) changes.push(`y: ${dy > 0 ? "+" : ""}${Math.round(dy)}px`);
103
+ if (Math.abs(dw) > 0) changes.push(`width: ${dw > 0 ? "+" : ""}${Math.round(dw)}px`);
104
+ if (Math.abs(dh) > 0) changes.push(`height: ${dh > 0 ? "+" : ""}${Math.round(dh)}px`);
105
+
106
+ if (changes.length > 0) {
107
+ console.log(` Movement: ${changes.join(", ")}`);
108
+ }
109
+ }
110
+ });
111
+ } else {
112
+ console.log(" (No source elements captured)");
113
+ }
114
+
115
+ console.groupEnd();
116
+ }
117
+ }
118
+ });
119
+
120
+ observer.observe({ type: "layout-shift", buffered: true });
121
+
122
+ // Summary function
123
+ window.getLayoutShiftSummary = () => {
124
+ console.group("%cšŸ“Š Layout Shift Summary", "font-weight: bold; font-size: 14px;");
125
+
126
+ const rating = valueToRating(totalCLS);
127
+ const icon = RATING_ICONS[rating];
128
+ const color = RATING_COLORS[rating];
129
+
130
+ // CLS Score
131
+ console.log("");
132
+ console.log("%cCLS Score:", "font-weight: bold;");
133
+ console.log(
134
+ ` ${icon} %c${totalCLS.toFixed(4)} (${rating})`,
135
+ `color: ${color}; font-weight: bold; font-size: 16px;`
136
+ );
137
+
138
+ // Statistics
139
+ const countedShifts = allShifts.filter((s) => s.countedForCLS);
140
+ const excludedShifts = allShifts.filter((s) => !s.countedForCLS);
141
+
142
+ console.log("");
143
+ console.log("%cStatistics:", "font-weight: bold;");
144
+ console.log(` Total shift events: ${allShifts.length}`);
145
+ console.log(` Counted for CLS: ${countedShifts.length}`);
146
+ console.log(` Excluded (user input): ${excludedShifts.length}`);
147
+
148
+ if (countedShifts.length > 0) {
149
+ const largest = Math.max(...countedShifts.map((s) => s.value));
150
+ console.log(` Largest single shift: ${largest.toFixed(4)}`);
151
+ }
152
+
153
+ // Top shifting elements
154
+ if (elementShifts.size > 0) {
155
+ console.log("");
156
+ console.log("%cšŸŽÆ Top Shifting Elements:", "font-weight: bold; color: #ef4444;");
157
+
158
+ const sortedElements = Array.from(elementShifts.entries())
159
+ .sort((a, b) => b[1].totalShift - a[1].totalShift)
160
+ .slice(0, 5);
161
+
162
+ const tableData = sortedElements.map(([selector, data]) => ({
163
+ Element: selector,
164
+ "Shift Count": data.count,
165
+ "Total Impact": data.totalShift.toFixed(4),
166
+ }));
167
+
168
+ console.table(tableData);
169
+
170
+ console.log("");
171
+ console.log("%cšŸ”Ž Inspect elements:", "font-weight: bold;");
172
+ sortedElements.forEach(([selector, data], i) => {
173
+ console.log(` ${i + 1}. ${selector}`, data.element);
174
+ });
175
+ }
176
+
177
+ // Timeline
178
+ if (countedShifts.length > 0) {
179
+ console.log("");
180
+ console.log("%cā±ļø Shift Timeline:", "font-weight: bold;");
181
+
182
+ const significant = countedShifts.filter((s) => s.value > 0.001);
183
+ if (significant.length > 0) {
184
+ const timelineData = significant.map((s) => ({
185
+ "Time (ms)": Math.round(s.time),
186
+ Value: s.value.toFixed(4),
187
+ Elements: s.elements.map((e) => e.selector).join(", ") || "(unknown)",
188
+ }));
189
+ console.table(timelineData);
190
+ } else {
191
+ console.log(" No significant shifts (all < 0.001)");
192
+ }
193
+ }
194
+
195
+ // Recommendations
196
+ if (rating !== "good") {
197
+ console.log("");
198
+ console.log("%cšŸ’” Recommendations:", "font-weight: bold; color: #3b82f6;");
199
+ console.log(" • Add width/height attributes to images and videos");
200
+ console.log(" • Reserve space for ads, embeds, and dynamic content");
201
+ console.log(" • Use CSS aspect-ratio for responsive elements");
202
+ console.log(" • Avoid inserting content above existing content");
203
+ console.log(" • Use transform animations instead of layout-triggering properties");
204
+ }
205
+
206
+ console.groupEnd();
207
+
208
+ const topElementsData = Array.from(elementShifts.entries())
209
+ .sort((a, b) => b[1].totalShift - a[1].totalShift)
210
+ .slice(0, 5)
211
+ .map(([selector, data]) => ({ selector, shiftCount: data.count, totalImpact: Math.round(data.totalShift * 10000) / 10000 }));
212
+ return {
213
+ script: "Layout-Shift-Loading-and-Interaction",
214
+ status: "ok",
215
+ metric: "CLS",
216
+ value: Math.round(totalCLS * 10000) / 10000,
217
+ unit: "score",
218
+ rating,
219
+ thresholds: { good: 0.1, needsImprovement: 0.25 },
220
+ details: {
221
+ currentCLS: Math.round(totalCLS * 10000) / 10000,
222
+ shiftCount: allShifts.length,
223
+ countedShifts: countedShifts.length,
224
+ excludedShifts: excludedShifts.length,
225
+ topElements: topElementsData,
226
+ },
227
+ };
228
+ };
229
+
230
+ // Initial message
231
+ const rating = valueToRating(totalCLS);
232
+ const icon = RATING_ICONS[rating];
233
+
234
+ console.log("%cšŸ“ Layout Shift Tracking Active", "font-weight: bold; font-size: 14px;");
235
+ console.log(` Current CLS: ${icon} ${totalCLS.toFixed(4)}`);
236
+ console.log(" Interact with the page to see new shifts.");
237
+ console.log(
238
+ " Call %cgetLayoutShiftSummary()%c for full analysis.",
239
+ "font-family: monospace; background: #f3f4f6; padding: 2px 4px;",
240
+ ""
241
+ );
242
+
243
+ // Synchronous return for agent (buffered layout-shift entries)
244
+ const clsBufferedSync = performance.getEntriesByType("layout-shift")
245
+ .reduce((sum, e) => !e.hadRecentInput ? sum + e.value : sum, 0);
246
+ const countedSync = performance.getEntriesByType("layout-shift").filter((e) => !e.hadRecentInput).length;
247
+ const excludedSync = performance.getEntriesByType("layout-shift").filter((e) => e.hadRecentInput).length;
248
+ const clsRatingSync = valueToRating(clsBufferedSync);
249
+ return {
250
+ script: "Layout-Shift-Loading-and-Interaction",
251
+ status: "tracking",
252
+ metric: "CLS",
253
+ value: Math.round(clsBufferedSync * 10000) / 10000,
254
+ unit: "score",
255
+ rating: clsRatingSync,
256
+ thresholds: { good: 0.1, needsImprovement: 0.25 },
257
+ details: {
258
+ currentCLS: Math.round(clsBufferedSync * 10000) / 10000,
259
+ shiftCount: countedSync + excludedSync,
260
+ countedShifts: countedSync,
261
+ excludedShifts: excludedSync,
262
+ },
263
+ message: "Layout shift tracking active. Call getLayoutShiftSummary() for full element attribution.",
264
+ getDataFn: "getLayoutShiftSummary",
265
+ };
266
+ })();
@@ -0,0 +1,397 @@
1
+ // LoAF Helpers - Advanced Debugging
2
+ // https://webperf-snippets.nucliweb.net
3
+
4
+ (() => {
5
+ "use strict";
6
+
7
+ // Check browser support
8
+ if (
9
+ !("PerformanceObserver" in window) ||
10
+ !PerformanceObserver.supportedEntryTypes.includes("long-animation-frame")
11
+ ) {
12
+ console.warn("%cāš ļø Long Animation Frames API not supported", "font-weight: bold;");
13
+ console.warn(" Requires Chrome 123+");
14
+ return { script: "Long-Animation-Frames-Helpers", status: "unsupported", error: "Long Animation Frames API not supported. Requires Chrome 123+" };
15
+ }
16
+
17
+ // Storage for captured frames
18
+ const capturedFrames = [];
19
+
20
+ // Severity helpers
21
+ const getSeverity = (duration) => {
22
+ if (duration > 200) return { level: "critical", icon: "šŸ”“" };
23
+ if (duration > 150) return { level: "high", icon: "🟠" };
24
+ if (duration > 100) return { level: "medium", icon: "🟔" };
25
+ return { level: "low", icon: "🟢" };
26
+ };
27
+
28
+ // Start observing
29
+ const observer = new PerformanceObserver((list) => {
30
+ for (const entry of list.getEntries()) {
31
+ const frameData = {
32
+ startTime: entry.startTime,
33
+ duration: entry.duration,
34
+ renderStart: entry.renderStart,
35
+ styleAndLayoutStart: entry.styleAndLayoutStart,
36
+ firstUIEventTimestamp: entry.firstUIEventTimestamp,
37
+ blockingDuration: entry.blockingDuration,
38
+ scripts: entry.scripts.map((s) => ({
39
+ sourceURL: s.sourceURL || "",
40
+ sourceFunctionName: s.sourceFunctionName || "(anonymous)",
41
+ invoker: s.invoker || "",
42
+ invokerType: s.invokerType || "",
43
+ duration: s.duration,
44
+ executionStart: s.executionStart,
45
+ forcedStyleAndLayoutDuration: s.forcedStyleAndLayoutDuration || 0,
46
+ })),
47
+ };
48
+ capturedFrames.push(frameData);
49
+ }
50
+ });
51
+
52
+ try {
53
+ observer.observe({ type: "long-animation-frame", buffered: true });
54
+ } catch (e) {
55
+ console.error("Failed to start LoAF observer:", e);
56
+ return { script: "Long-Animation-Frames-Helpers", status: "error", error: e.message };
57
+ }
58
+
59
+ // Helper functions
60
+ window.loafHelpers = {
61
+ /**
62
+ * Show summary of all captured frames
63
+ */
64
+ summary() {
65
+ if (capturedFrames.length === 0) {
66
+ console.log("%cšŸ“Š No frames captured yet", "font-weight: bold;");
67
+ console.log(" Interact with the page to generate long frames.");
68
+ return;
69
+ }
70
+
71
+ const totalTime = capturedFrames.reduce((sum, f) => sum + f.duration, 0);
72
+ const totalBlocking = capturedFrames.reduce((sum, f) => sum + f.blockingDuration, 0);
73
+ const avgDuration = totalTime / capturedFrames.length;
74
+ const maxDuration = Math.max(...capturedFrames.map((f) => f.duration));
75
+
76
+ const severity = {
77
+ critical: capturedFrames.filter((f) => f.duration > 200).length,
78
+ high: capturedFrames.filter((f) => f.duration > 150 && f.duration <= 200).length,
79
+ medium: capturedFrames.filter((f) => f.duration > 100 && f.duration <= 150).length,
80
+ low: capturedFrames.filter((f) => f.duration <= 100).length,
81
+ };
82
+
83
+ console.group("%cšŸ“Š LoAF Summary", "font-weight: bold; font-size: 14px;");
84
+
85
+ console.log("");
86
+ console.log("%cStatistics:", "font-weight: bold;");
87
+ console.log(` Total frames: ${capturedFrames.length}`);
88
+ console.log(` Total duration: ${totalTime.toFixed(0)}ms`);
89
+ console.log(` Total blocking: ${totalBlocking.toFixed(0)}ms`);
90
+ console.log(` Average duration: ${avgDuration.toFixed(0)}ms`);
91
+ console.log(` Max duration: ${maxDuration.toFixed(0)}ms`);
92
+
93
+ console.log("");
94
+ console.log("%cBy severity:", "font-weight: bold;");
95
+ console.log(` šŸ”“ Critical (>200ms): ${severity.critical}`);
96
+ console.log(` 🟠 High (150-200ms): ${severity.high}`);
97
+ console.log(` 🟔 Medium (100-150ms): ${severity.medium}`);
98
+ console.log(` 🟢 Low (<100ms): ${severity.low}`);
99
+
100
+ console.groupEnd();
101
+ },
102
+
103
+ /**
104
+ * Show top N slowest scripts
105
+ */
106
+ topScripts(n = 10) {
107
+ if (capturedFrames.length === 0) {
108
+ console.log("%cšŸ“‹ No frames captured yet", "font-weight: bold;");
109
+ return;
110
+ }
111
+
112
+ const allScripts = capturedFrames.flatMap((f) => f.scripts);
113
+ if (allScripts.length === 0) {
114
+ console.log("%cšŸ“‹ No scripts found in captured frames", "font-weight: bold;");
115
+ return;
116
+ }
117
+
118
+ // Aggregate by source
119
+ const scriptStats = new Map();
120
+ allScripts.forEach((s) => {
121
+ const key = `${s.sourceURL}|${s.sourceFunctionName}`;
122
+ if (!scriptStats.has(key)) {
123
+ scriptStats.set(key, {
124
+ sourceURL: s.sourceURL,
125
+ functionName: s.sourceFunctionName,
126
+ totalDuration: 0,
127
+ count: 0,
128
+ maxDuration: 0,
129
+ totalForcedLayout: 0,
130
+ });
131
+ }
132
+ const stats = scriptStats.get(key);
133
+ stats.totalDuration += s.duration;
134
+ stats.count++;
135
+ stats.maxDuration = Math.max(stats.maxDuration, s.duration);
136
+ stats.totalForcedLayout += s.forcedStyleAndLayoutDuration;
137
+ });
138
+
139
+ const sorted = Array.from(scriptStats.values())
140
+ .sort((a, b) => b.totalDuration - a.totalDuration)
141
+ .slice(0, n);
142
+
143
+ console.group(`%cšŸ“‹ Top ${Math.min(n, sorted.length)} Scripts by Total Duration`, "font-weight: bold; font-size: 14px;");
144
+
145
+ const tableData = sorted.map((s) => {
146
+ let path = s.sourceURL;
147
+ try {
148
+ path = new URL(s.sourceURL || location.href).pathname;
149
+ if (path.length > 40) path = "..." + path.slice(-37);
150
+ } catch {}
151
+
152
+ return {
153
+ Script: path || "(inline)",
154
+ Function: s.functionName.length > 25 ? s.functionName.slice(0, 22) + "..." : s.functionName,
155
+ Count: s.count,
156
+ "Total": `${s.totalDuration.toFixed(0)}ms`,
157
+ "Max": `${s.maxDuration.toFixed(0)}ms`,
158
+ "Forced S&L": s.totalForcedLayout > 0 ? `${s.totalForcedLayout.toFixed(0)}ms` : "-",
159
+ };
160
+ });
161
+
162
+ console.table(tableData);
163
+ console.groupEnd();
164
+
165
+ return sorted;
166
+ },
167
+
168
+ /**
169
+ * Filter frames by criteria
170
+ */
171
+ filter(options = {}) {
172
+ if (capturedFrames.length === 0) {
173
+ console.log("%cšŸ” No frames captured yet", "font-weight: bold;");
174
+ return [];
175
+ }
176
+
177
+ let filtered = capturedFrames;
178
+
179
+ if (options.minDuration) {
180
+ filtered = filtered.filter((f) => f.duration >= options.minDuration);
181
+ }
182
+ if (options.maxDuration) {
183
+ filtered = filtered.filter((f) => f.duration <= options.maxDuration);
184
+ }
185
+
186
+ console.group(`%cšŸ” Filtered: ${filtered.length} of ${capturedFrames.length} frames`, "font-weight: bold;");
187
+
188
+ if (filtered.length > 0) {
189
+ const tableData = filtered.map((f) => {
190
+ const sev = getSeverity(f.duration);
191
+ return {
192
+ "": sev.icon,
193
+ Start: `${f.startTime.toFixed(0)}ms`,
194
+ Duration: `${f.duration.toFixed(0)}ms`,
195
+ Blocking: `${f.blockingDuration.toFixed(0)}ms`,
196
+ Scripts: f.scripts.length,
197
+ };
198
+ });
199
+ console.table(tableData);
200
+ }
201
+
202
+ console.groupEnd();
203
+ return filtered;
204
+ },
205
+
206
+ /**
207
+ * Find frames containing scripts that match a URL pattern
208
+ */
209
+ findByURL(search) {
210
+ if (capturedFrames.length === 0) {
211
+ console.log("%cšŸ”Ž No frames captured yet", "font-weight: bold;");
212
+ return [];
213
+ }
214
+
215
+ const matches = capturedFrames.filter((f) =>
216
+ f.scripts.some((s) => s.sourceURL.toLowerCase().includes(search.toLowerCase()))
217
+ );
218
+
219
+ console.group(`%cšŸ”Ž Found ${matches.length} frames matching "${search}"`, "font-weight: bold;");
220
+
221
+ if (matches.length > 0) {
222
+ const tableData = matches.map((f) => {
223
+ const matchingScript = f.scripts.find((s) =>
224
+ s.sourceURL.toLowerCase().includes(search.toLowerCase())
225
+ );
226
+ let scriptPath = matchingScript.sourceURL;
227
+ try {
228
+ scriptPath = new URL(scriptPath).pathname;
229
+ if (scriptPath.length > 35) scriptPath = "..." + scriptPath.slice(-32);
230
+ } catch {}
231
+
232
+ return {
233
+ "Frame Start": `${f.startTime.toFixed(0)}ms`,
234
+ "Frame Duration": `${f.duration.toFixed(0)}ms`,
235
+ "Script": scriptPath,
236
+ "Script Duration": `${matchingScript.duration.toFixed(0)}ms`,
237
+ };
238
+ });
239
+ console.table(tableData);
240
+ }
241
+
242
+ console.groupEnd();
243
+ return matches;
244
+ },
245
+
246
+ /**
247
+ * Calculate percentiles for RUM reporting
248
+ */
249
+ percentiles(pcts = [50, 75, 95, 99]) {
250
+ if (capturedFrames.length === 0) {
251
+ console.log("%cšŸ“Š No frames captured yet", "font-weight: bold;");
252
+ return {};
253
+ }
254
+
255
+ const durations = capturedFrames.map((f) => f.duration).sort((a, b) => a - b);
256
+ const result = {};
257
+
258
+ pcts.forEach((p) => {
259
+ const index = Math.ceil((p / 100) * durations.length) - 1;
260
+ const safeIndex = Math.max(0, Math.min(index, durations.length - 1));
261
+ result[`p${p}`] = durations[safeIndex];
262
+ });
263
+
264
+ console.group("%cšŸ“Š Frame Duration Percentiles", "font-weight: bold; font-size: 14px;");
265
+ console.log(` Frames analyzed: ${capturedFrames.length}`);
266
+ console.log("");
267
+
268
+ Object.entries(result).forEach(([key, value]) => {
269
+ const sev = getSeverity(value);
270
+ console.log(` ${sev.icon} ${key.toUpperCase()}: ${value.toFixed(0)}ms`);
271
+ });
272
+
273
+ console.log("");
274
+ console.log("%cšŸ’” Tip:", "font-weight: bold;");
275
+ console.log(" Use P75 for general performance, P95/P99 for tail latency");
276
+ console.groupEnd();
277
+
278
+ return result;
279
+ },
280
+
281
+ /**
282
+ * Export as JSON
283
+ */
284
+ exportJSON() {
285
+ if (capturedFrames.length === 0) {
286
+ console.log("%cšŸ“ No frames to export", "font-weight: bold;");
287
+ return;
288
+ }
289
+
290
+ const data = JSON.stringify(capturedFrames, null, 2);
291
+ const blob = new Blob([data], { type: "application/json" });
292
+ const url = URL.createObjectURL(blob);
293
+ const a = document.createElement("a");
294
+ a.href = url;
295
+ a.download = `loaf-data-${new Date().toISOString().slice(0, 10)}.json`;
296
+ a.click();
297
+ URL.revokeObjectURL(url);
298
+
299
+ console.log(`%cāœ… Exported ${capturedFrames.length} frames to JSON`, "color: #22c55e; font-weight: bold;");
300
+ },
301
+
302
+ /**
303
+ * Export as CSV
304
+ */
305
+ exportCSV() {
306
+ if (capturedFrames.length === 0) {
307
+ console.log("%cšŸ“ No frames to export", "font-weight: bold;");
308
+ return;
309
+ }
310
+
311
+ const rows = [
312
+ ["Frame Start", "Duration", "Blocking", "Script URL", "Function", "Script Duration", "Forced S&L"],
313
+ ];
314
+
315
+ capturedFrames.forEach((f) => {
316
+ if (f.scripts.length === 0) {
317
+ rows.push([
318
+ f.startTime.toFixed(2),
319
+ f.duration.toFixed(2),
320
+ f.blockingDuration.toFixed(2),
321
+ "", "", "", "",
322
+ ]);
323
+ } else {
324
+ f.scripts.forEach((s) => {
325
+ rows.push([
326
+ f.startTime.toFixed(2),
327
+ f.duration.toFixed(2),
328
+ f.blockingDuration.toFixed(2),
329
+ s.sourceURL,
330
+ s.sourceFunctionName,
331
+ s.duration.toFixed(2),
332
+ s.forcedStyleAndLayoutDuration.toFixed(2),
333
+ ]);
334
+ });
335
+ }
336
+ });
337
+
338
+ const csv = rows.map((row) => row.map((cell) => `"${cell}"`).join(",")).join("\n");
339
+ const blob = new Blob([csv], { type: "text/csv" });
340
+ const url = URL.createObjectURL(blob);
341
+ const a = document.createElement("a");
342
+ a.href = url;
343
+ a.download = `loaf-data-${new Date().toISOString().slice(0, 10)}.csv`;
344
+ a.click();
345
+ URL.revokeObjectURL(url);
346
+
347
+ console.log(`%cāœ… Exported ${capturedFrames.length} frames to CSV`, "color: #22c55e; font-weight: bold;");
348
+ },
349
+
350
+ /**
351
+ * Get raw data for custom analysis
352
+ */
353
+ getRawData() {
354
+ return capturedFrames;
355
+ },
356
+
357
+ /**
358
+ * Clear all captured data
359
+ */
360
+ clear() {
361
+ capturedFrames.length = 0;
362
+ console.log("%cāœ… Captured data cleared", "color: #22c55e; font-weight: bold;");
363
+ },
364
+
365
+ /**
366
+ * Show help
367
+ */
368
+ help() {
369
+ console.group("%cšŸ“š LoAF Helpers - Commands", "font-weight: bold; font-size: 14px;");
370
+ console.log("");
371
+ console.log("%csummary()%c - Overview of all captured frames", "font-weight: bold; color: #3b82f6;", "");
372
+ console.log("%ctopScripts(n)%c - Top N slowest scripts (default: 10)", "font-weight: bold; color: #3b82f6;", "");
373
+ console.log("%cfilter({ minDuration, maxDuration })%c - Filter frames by duration", "font-weight: bold; color: #3b82f6;", "");
374
+ console.log("%cfindByURL(search)%c - Find frames by script URL", "font-weight: bold; color: #3b82f6;", "");
375
+ console.log("%cpercentiles([50,75,95,99])%c - Calculate percentiles for RUM", "font-weight: bold; color: #3b82f6;", "");
376
+ console.log("%cexportJSON()%c - Download data as JSON", "font-weight: bold; color: #3b82f6;", "");
377
+ console.log("%cexportCSV()%c - Download data as CSV", "font-weight: bold; color: #3b82f6;", "");
378
+ console.log("%cgetRawData()%c - Get raw frame array", "font-weight: bold; color: #3b82f6;", "");
379
+ console.log("%cclear()%c - Clear captured data", "font-weight: bold; color: #3b82f6;", "");
380
+ console.groupEnd();
381
+ },
382
+ };
383
+
384
+ // Initial message
385
+ console.log("%cšŸ”§ LoAF Helpers Loaded", "font-weight: bold; font-size: 14px;");
386
+ console.log(" Observing long animation frames (>50ms)...");
387
+ console.log("");
388
+ console.log(" Quick start: %cloafHelpers.summary()%c", "font-family: monospace; background: #f3f4f6; padding: 2px 4px;", "");
389
+ console.log(" All commands: %cloafHelpers.help()%c", "font-family: monospace; background: #f3f4f6; padding: 2px 4px;", "");
390
+
391
+ return {
392
+ script: "Long-Animation-Frames-Helpers",
393
+ status: "tracking",
394
+ message: "LoAF Helpers loaded. Use loafHelpers.summary(), loafHelpers.topScripts(), etc. Call loafHelpers.getRawData() to get raw frame data.",
395
+ getDataFn: "loafHelpers.getRawData",
396
+ };
397
+ })();