webperf-snippets 1.4.0 → 1.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.
Files changed (48) hide show
  1. package/README.md +5 -5
  2. package/package.json +1 -1
  3. package/snippets/CoreWebVitals/LCP-Image-Entropy.js +8 -9
  4. package/snippets/CoreWebVitals/LCP-Video-Candidate.js +14 -13
  5. package/snippets/Interaction/DOM-Size-and-Depth.js +4 -0
  6. package/snippets/Interaction/Forced-Synchronous-Layout.js +2 -0
  7. package/snippets/Interaction/Input-Latency-Breakdown.js +2 -0
  8. package/snippets/Interaction/Interactions.js +2 -0
  9. package/snippets/Interaction/Scroll-Performance.js +2 -0
  10. package/snippets/Loading/Back-Forward-Cache.js +7 -0
  11. package/snippets/Loading/CSS-Media-Queries-Analysis.js +4 -2
  12. package/snippets/Loading/Cache-Strategy-Analysis.js +12 -0
  13. package/snippets/Loading/Client-Side-Redirect-Detection.js +5 -3
  14. package/snippets/Loading/Compression-Audit.js +3 -0
  15. package/snippets/Loading/Content-Visibility.js +2 -2
  16. package/snippets/Loading/Critical-CSS-Detection.js +6 -0
  17. package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +2 -2
  18. package/snippets/Loading/Find-Images-With-Lazy-and-Fetchpriority.js +1 -1
  19. package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +1 -1
  20. package/snippets/Loading/Find-render-blocking-resources.js +1 -1
  21. package/snippets/Loading/First-And-Third-Party-Script-Info.js +5 -2
  22. package/snippets/Loading/First-And-Third-Party-Script-Timings.js +4 -4
  23. package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +3 -3
  24. package/snippets/Loading/Inline-CSS-Info-and-Size.js +5 -1
  25. package/snippets/Loading/Inline-Script-Info-and-Size.js +5 -1
  26. package/snippets/Loading/JS-Execution-Time-Breakdown.js +4 -4
  27. package/snippets/Loading/Prefetch-Resource-Validation.js +12 -3
  28. package/snippets/Loading/Priority-Hints-Audit.js +10 -0
  29. package/snippets/Loading/Resource-Hints-Validation.js +3 -3
  30. package/snippets/Loading/Resource-Hints.js +7 -1
  31. package/snippets/Loading/SSR-Hydration-Data-Analysis.js +5 -2
  32. package/snippets/Loading/Script-Loading.js +6 -2
  33. package/snippets/Loading/Server-Timing-Early-Hints.js +3 -0
  34. package/snippets/Loading/Service-Worker-Analysis.js +12 -2
  35. package/snippets/Loading/Speculation-Rules-Inspector.js +7 -4
  36. package/snippets/Loading/TTFB-Resources.js +1 -1
  37. package/snippets/Loading/TTFB-Sub-Parts.js +1 -0
  38. package/snippets/Loading/Third-Party-Impact-by-Domain.js +6 -0
  39. package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +5 -0
  40. package/snippets/Loading/Webfont-Usage-Analyzer.js +13 -14
  41. package/snippets/Media/Image-Element-Audit.js +16 -13
  42. package/snippets/Media/Oversized-Images.js +4 -0
  43. package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +2 -1
  44. package/snippets/Media/Video-Element-Audit.js +13 -2
  45. package/snippets/SCHEMA.md +425 -3
  46. package/src/baseline.js +30 -6
  47. package/src/blocking.js +1 -1
  48. package/src/mcp/documents.js +41 -21
package/README.md CHANGED
@@ -73,7 +73,7 @@ npx webperf-snippets https://example.com --json
73
73
  The JSON output records how the page was measured in `conditions`, so a later run can tell whether it measured the same way: the viewport, the workflow (or the path of the snippet with `--snippet`), the wait and the version of the CLI.
74
74
 
75
75
  ```json
76
- "conditions": { "viewport": "mobile", "workflow": "core-web-vitals", "waitMs": 3000, "cliVersion": "1.4.0" }
76
+ "conditions": { "viewport": "mobile", "workflow": "core-web-vitals", "waitMs": 3000, "cliVersion": "1.5.0" }
77
77
  ```
78
78
 
79
79
  Single snippet:
@@ -158,7 +158,7 @@ Without `--report-to` the CLI makes no external calls. With it, the results are
158
158
  "timestamp": "2026-05-06T10:00:00.000Z",
159
159
  "navMs": 1240,
160
160
  "results": [],
161
- "meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.4.0" }
161
+ "meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.5.0" }
162
162
  }
163
163
  ```
164
164
 
@@ -265,7 +265,7 @@ LCP, FCP, TTFB, CLS and INP compare when both runs measured them, the median of
265
265
 
266
266
  `--threshold` changes one part of a threshold and keeps the other: `--threshold LCP=15%` the share, `--threshold LCP=200ms` the minimum, and `--threshold CLS=0.1` the only part of CLS. Repeat it for more metrics.
267
267
 
268
- The errors that compare are the ones that decide the exit code: the issues of severity `error` of the steps of the workflow and the snippets that failed. Two errors are the same when they share a code or, without one, when their messages differ only in their figures, so "Render-blocking CSS (69.21 KB)…" and "Render-blocking CSS (71.80 KB)…" are one error. An error that only this run has is new, and one that only the baseline had is fixed. Warnings and information do not compare, since they change too easily.
268
+ The errors that compare are the ones that decide the exit code: the issues of severity `error` of the steps of the workflow and the snippets that failed. Two errors are the same when they share their [code](https://github.com/nucliweb/webperf-snippets/blob/main/snippets/SCHEMA.md#issue-codes) and the resource they name, whatever their messages say; a resource of the page itself compares by its path and query, so the review app of a pull request, on another host, matches production. A snippet without codes on one side, as in a baseline of an earlier version, compares by message with the figures left out, so "Render-blocking CSS (69.21 KB)…" and "Render-blocking CSS (71.80 KB)…" are one error. An error that only this run has is new, and one that only the baseline had is fixed. Warnings and information do not compare, since they change too easily.
269
269
 
270
270
  The report adds the comparison after the usual one, and `--markdown` a table for the pull request comment. The JSON output carries it in `baseline`:
271
271
 
@@ -389,8 +389,8 @@ The server also offers resources, documents a client can attach to the context:
389
389
 
390
390
  | Resource | What it has |
391
391
  | -------------------------------- | ------------------------------------------------------------------------ |
392
- | `webperf://schema` | The contract every snippet result follows: base shape, statuses, how to read a result. |
393
- | `webperf://schema/{snippet}` | The fields one snippet returns, with an example. |
392
+ | `webperf://schema` | The contract every snippet result follows: base shape, statuses, how to read a result, the rules of the issue codes. |
393
+ | `webperf://schema/{snippet}` | The fields one snippet returns, with an example, and the codes of its issues. |
394
394
  | `webperf://workflows/{category}` | The workflows and the decision tree of a category. |
395
395
 
396
396
  And prompts, templates that chain the tools (in Claude Code, `/mcp__webperf__audit_lcp`): `audit_lcp`, `debug_inp` and `audit_page`, each with a `url` argument.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webperf-snippets",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "mcpName": "io.github.nucliweb/webperf-snippets",
5
5
  "description": "Run curated WebPerf Snippets headlessly via Playwright. Diagnose Core Web Vitals beyond what Lighthouse exposes.",
6
6
  "type": "module",
@@ -213,23 +213,22 @@
213
213
  }
214
214
  const issuesSync = [];
215
215
  if (lowEntropyCount > 0) {
216
- issuesSync.push({ severity: "warning", message: `${lowEntropyCount} image(s) have low entropy and are LCP-ineligible in Chrome 112+` });
216
+ issuesSync.push({ code: "low-entropy-images", severity: "warning", message: `${lowEntropyCount} image(s) have low entropy and are LCP-ineligible in Chrome 112+` });
217
217
  }
218
218
  if (lcpImageSync?.isLowEntropy) {
219
- issuesSync.push({ severity: "error", message: "Current LCP image has low entropy and may be skipped by Chrome" });
219
+ issuesSync.push({ code: "lcp-low-entropy", severity: "error", message: "Current LCP image has low entropy and may be skipped by Chrome" });
220
220
  }
221
221
  // Entropy applies to the LCP only when it is an <img> whose file size can be read; for text, or an
222
222
  // image this snippet cannot read, it says so. The images of the page are still listed.
223
223
  const lcpTag = lcpElementSync ? lcpElementSync.tagName.toLowerCase() : null;
224
224
  if (lcpTag && !lcpImageSync) {
225
- issuesSync.push({
226
- severity: "info",
227
- message: lcpTag === "img"
228
- ? "The LCP <img> has no file size to read, as with a data: URL or a cross-origin image without Timing-Allow-Origin, so its entropy cannot be computed."
225
+ issuesSync.push(
226
+ lcpTag === "img"
227
+ ? { code: "lcp-size-unknown", severity: "info", message: "The LCP <img> has no file size to read, as with a data: URL or a cross-origin image without Timing-Allow-Origin, so its entropy cannot be computed." }
229
228
  : lcpUrlSync
230
- ? `The LCP is the image of a <${lcpTag}>, such as a CSS background or a video poster, and this snippet reads <img> elements only. Run the LCP snippet to measure it.`
231
- : `The LCP element is <${lcpTag}>, text and not an image, so its entropy does not apply. Run the LCP snippet to measure it.`,
232
- });
229
+ ? { code: "lcp-not-img", severity: "info", message: `The LCP is the image of a <${lcpTag}>, such as a CSS background or a video poster, and this snippet reads <img> elements only. Run the LCP snippet to measure it.` }
230
+ : { code: "lcp-is-text", severity: "info", message: `The LCP element is <${lcpTag}>, text and not an image, so its entropy does not apply. Run the LCP snippet to measure it.` }
231
+ );
233
232
  }
234
233
  // The list keeps the 50 most relevant images (the LCP image, then low entropy, then lowest bits per pixel); count and details cover all of them
235
234
  const MAX_ITEMS = 50;
@@ -71,22 +71,22 @@
71
71
  // The value of a non-video LCP belongs to the LCP snippet; this one only says what the element is
72
72
  if (!element || element.tagName !== "VIDEO") {
73
73
  const tag = element ? element.tagName.toLowerCase() : null;
74
- const message = tag
75
- ? `The LCP element is <${tag}>, not a <video>. Run the LCP snippet to measure it, and LCP Subparts to see its phases.`
76
- : "The LCP element is no longer in the DOM, so it cannot be checked for a <video>. Run the LCP snippet to measure it.";
74
+ const issue = tag
75
+ ? { code: "lcp-not-video", severity: "info", message: `The LCP element is <${tag}>, not a <video>. Run the LCP snippet to measure it, and LCP Subparts to see its phases.` }
76
+ : { code: "lcp-element-removed", severity: "info", message: "The LCP element is no longer in the DOM, so it cannot be checked for a <video>. Run the LCP snippet to measure it." };
77
77
  console.log("%cLCP element is not a <video>", "font-weight: bold;");
78
78
  console.log("");
79
79
  console.log(` Tag : ${tag ? `<${tag}>` : "(element no longer in DOM)"}`);
80
80
  if (lcp.url) console.log(` URL : ${lcp.url}`);
81
81
  if (element) console.log(" Element :", element);
82
82
  console.log("");
83
- console.log(` ${message}`);
83
+ console.log(` ${issue.message}`);
84
84
  console.groupEnd();
85
85
  return {
86
86
  script: "LCP-Video-Candidate",
87
87
  status: "ok",
88
88
  details: { isVideo: false, lcpElement: tag, lcpUrl: lcp.url || null },
89
- issues: [{ severity: "info", message }],
89
+ issues: [issue],
90
90
  };
91
91
  }
92
92
 
@@ -140,34 +140,35 @@
140
140
 
141
141
  if (secondsAfterLoad > 10) {
142
142
  issues.push({
143
+ code: "late-candidate",
143
144
  s: "warning",
144
145
  msg: `The LCP candidate rendered ${secondsAfterLoad} s after the page loaded, because the page stayed open without interaction. Reload and run the snippet again to measure the LCP of the load`,
145
146
  });
146
147
  }
147
148
 
148
149
  if (lcpSource === "first-frame") {
149
- issues.push({ s: "info", msg: "LCP is the first video frame: adding a poster gives explicit control over the LCP image" });
150
+ issues.push({ code: "first-frame-lcp", s: "info", msg: "LCP is the first video frame: adding a poster gives explicit control over the LCP image" });
150
151
  }
151
152
  if (lcpSource === "first-frame" && (!autoplay || !muted)) {
152
- issues.push({ s: "warning", msg: "First-frame LCP requires autoplay + muted for the browser to render it immediately" });
153
+ issues.push({ code: "first-frame-not-autoplay-muted", s: "warning", msg: "First-frame LCP requires autoplay + muted for the browser to render it immediately" });
153
154
  }
154
155
 
155
156
  if (posterAttr && !posterPreload) {
156
- issues.push({ s: "warning", msg: 'No <link rel="preload" as="image"> for the poster: the browser discovers it late' });
157
+ issues.push({ code: "poster-not-preloaded", s: "warning", msg: 'No <link rel="preload" as="image"> for the poster: the browser discovers it late' });
157
158
  } else if (posterPreload && posterPreload.getAttribute("fetchpriority") !== "high") {
158
- issues.push({ s: "info", msg: 'Preload found but missing fetchpriority="high", so it may be deprioritised' });
159
+ issues.push({ code: "poster-preload-no-fetchpriority", s: "info", msg: 'Preload found but missing fetchpriority="high", so it may be deprioritised' });
159
160
  }
160
161
 
161
162
  if (posterAttr && !isModernFormat && posterFormat !== "unknown") {
162
- issues.push({ s: "info", msg: `Poster uses ${posterFormat}; AVIF or WebP would reduce file size and LCP load time` });
163
+ issues.push({ code: "poster-legacy-format", s: "info", msg: `Poster uses ${posterFormat}; AVIF or WebP would reduce file size and LCP load time` });
163
164
  }
164
165
 
165
166
  if (isCrossOrigin) {
166
- issues.push({ s: "info", msg: "renderTime is 0: the resource is cross-origin and the server does not send Timing-Allow-Origin" });
167
+ issues.push({ code: "render-time-hidden", s: "info", msg: "renderTime is 0: the resource is cross-origin and the server does not send Timing-Allow-Origin" });
167
168
  }
168
169
 
169
170
  if (!autoplay && preload === "none") {
170
- issues.push({ s: "warning", msg: 'preload="none" on a non-autoplay video may delay poster image loading in some browsers' });
171
+ issues.push({ code: "preload-none-delays-poster", s: "warning", msg: 'preload="none" on a non-autoplay video may delay poster image loading in some browsers' });
171
172
  }
172
173
 
173
174
  // Summary
@@ -265,6 +266,6 @@
265
266
  isCrossOrigin,
266
267
  videoAttributes: { autoplay, muted, playsinline, preload },
267
268
  },
268
- issues: issues.map((i) => ({ severity: i.s === "warning" ? "warning" : "info", message: i.msg })),
269
+ issues: issues.map((i) => ({ code: i.code, severity: i.s === "warning" ? "warning" : "info", message: i.msg })),
269
270
  };
270
271
  })();
@@ -80,23 +80,27 @@
80
80
  const issues = [];
81
81
  if (totalElements > THRESHOLDS.errorNodes) {
82
82
  issues.push({
83
+ code: "too-many-nodes",
83
84
  severity: "error",
84
85
  message: `${totalElements} nodes exceeds ${THRESHOLDS.errorNodes}; a large DOM slows style recalculation and layout on every interaction`,
85
86
  });
86
87
  } else if (totalElements > THRESHOLDS.warnNodes) {
87
88
  issues.push({
89
+ code: "too-many-nodes",
88
90
  severity: "warning",
89
91
  message: `${totalElements} nodes exceeds ${THRESHOLDS.warnNodes}; a large DOM increases style recalculation and layout cost`,
90
92
  });
91
93
  }
92
94
  if (maxDepth > THRESHOLDS.maxDepth) {
93
95
  issues.push({
96
+ code: "too-deep",
94
97
  severity: "warning",
95
98
  message: `DOM depth of ${maxDepth} exceeds ${THRESHOLDS.maxDepth}; deep trees make style and layout work costlier (deepest: ${selectorFor(deepest)})`,
96
99
  });
97
100
  }
98
101
  if (widest && widest.children > THRESHOLDS.maxChildren) {
99
102
  issues.push({
103
+ code: "too-many-children",
100
104
  severity: "warning",
101
105
  message: `${widestSelector} has ${widest.children} children, above ${THRESHOLDS.maxChildren}; consider virtualizing or paginating the list`,
102
106
  });
@@ -265,8 +265,10 @@
265
265
  .sort((a, b) => b[1] - a[1])
266
266
  .slice(0, 10)
267
267
  .map(([property, count]) => ({
268
+ code: "forced-layout",
268
269
  severity: count > 5 ? "error" : "warning",
269
270
  message: `${property} triggered ${count} forced synchronous layout${count > 1 ? "s" : ""}. Read layout properties before writing to the DOM.`,
271
+ target: property,
270
272
  })),
271
273
  };
272
274
  };
@@ -259,8 +259,10 @@
259
259
  const bottleneck = phases.reduce((a, c) => (a.value > c.value ? a : c));
260
260
  const rating = valueToRating(p75(b.durations));
261
261
  return {
262
+ code: "slow-event-type",
262
263
  severity: rating === "poor" ? "error" : "warning",
263
264
  message: `${type} P75 is ${Math.round(p75(b.durations))}ms (${rating}). Bottleneck: ${bottleneck.name}. Fix: ${BOTTLENECK_FIXES[bottleneck.name]}.`,
265
+ target: type,
264
266
  };
265
267
  });
266
268
 
@@ -264,8 +264,10 @@
264
264
  .sort((a, b) => b.duration - a.duration)
265
265
  .slice(0, 10)
266
266
  .map((i) => ({
267
+ code: "slow-interaction",
267
268
  severity: i.rating === "poor" ? "error" : "warning",
268
269
  message: `${i.type} interaction took ${Math.round(i.duration)}ms (${i.rating}), above the ${i.rating === "poor" ? "500ms" : "200ms"} threshold`,
270
+ target: i.type,
269
271
  })),
270
272
  };
271
273
  };
@@ -207,12 +207,14 @@
207
207
  const issues = [];
208
208
  if (nonPassiveListeners.length > 0) {
209
209
  issues.push({
210
+ code: "non-passive-listeners",
210
211
  severity: "warning",
211
212
  message: `${nonPassiveListeners.length} non-passive scroll or touch listener(s). Add { passive: true } so scrolling does not wait for JavaScript.`,
212
213
  });
213
214
  }
214
215
  if (css.willChangeElements.length > 0) {
215
216
  issues.push({
217
+ code: "will-change",
216
218
  severity: "info",
217
219
  message: `will-change is set on ${css.willChangeElements.length} element(s). Remove it from elements that do not animate, since each one keeps a compositor layer.`,
218
220
  });
@@ -82,6 +82,7 @@
82
82
  : [];
83
83
  confirmed.forEach(({ reason, frame }) => {
84
84
  issues.push({
85
+ code: 'not-restored',
85
86
  reason: `${reason} (${frame})`,
86
87
  severity: 'high',
87
88
  source: 'browser',
@@ -93,6 +94,7 @@
93
94
  // cannot be detected from JavaScript.
94
95
  if (window.onunload !== null && !confirmed.some(({ reason }) => reason === 'unload-listener')) {
95
96
  issues.push({
97
+ code: 'unload-handler',
96
98
  reason: 'window.onunload handler set',
97
99
  severity: 'high',
98
100
  source: 'detected',
@@ -108,6 +110,7 @@
108
110
  const cacheControl = res.headers.get('cache-control') || '';
109
111
  if (/no-store/i.test(cacheControl) && !confirmed.some(({ reason }) => reason === 'response-cache-control-no-store')) {
110
112
  issues.push({
113
+ code: 'cache-control-no-store',
111
114
  reason: 'Cache-Control: no-store on the page response',
112
115
  severity: 'medium',
113
116
  source: 'detected',
@@ -123,6 +126,7 @@
123
126
  const iframeCount = document.querySelectorAll('iframe').length;
124
127
  if (iframeCount > 0) {
125
128
  issues.push({
129
+ code: 'iframes',
126
130
  reason: `${iframeCount} iframe(s) on the page`,
127
131
  severity: 'info',
128
132
  source: 'note',
@@ -132,6 +136,7 @@
132
136
 
133
137
  if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
134
138
  issues.push({
139
+ code: 'service-worker',
135
140
  reason: 'Service Worker controls the page',
136
141
  severity: 'info',
137
142
  source: 'note',
@@ -255,8 +260,10 @@
255
260
  notRestoredReasons: results.notRestoredReasons,
256
261
  },
257
262
  issues: results.blockingReasons.map((i) => ({
263
+ code: i.code,
258
264
  severity: i.severity === 'high' ? 'error' : i.severity === 'medium' ? 'warning' : 'info',
259
265
  message: i.reason,
266
+ ...(i.code === 'not-restored' ? { target: i.reason } : {}),
260
267
  })),
261
268
  });
262
269
 
@@ -151,21 +151,23 @@ async function analyzeCSSMediaQueries(minWidth = 768) {
151
151
 
152
152
  const issues = [];
153
153
  if (rules.length === 0) {
154
- issues.push({ severity: "info", message: `No @media rules for viewports wider than ${minWidth}px` });
154
+ issues.push({ code: "no-wide-rules", severity: "info", message: `No @media rules for viewports wider than ${minWidth}px` });
155
155
  } else {
156
156
  issues.push({
157
+ code: "wide-rules",
157
158
  severity: totalBytes > WARNING_BYTES ? "warning" : "info",
158
159
  message: `${rules.length} @media rules for viewports wider than ${minWidth}px add ${formatBytes(totalBytes)} of CSS that narrower screens download and parse without using. Move them to a stylesheet with a media attribute, such as <link rel="stylesheet" href="wide.css" media="(min-width: ${minWidth + 1}px)">, which narrower screens fetch at low priority without blocking rendering`,
159
160
  });
160
161
  if (rules.length >= 20 && uniquenessRatio < 0.1) {
161
162
  issues.push({
163
+ code: "repeated-queries",
162
164
  severity: "info",
163
165
  message: `${queries.length} distinct queries repeat across ${rules.length} @media blocks, the pattern of component-scoped or compiled CSS. Grouping the rules of each query under one block shortens the stylesheets`,
164
166
  });
165
167
  }
166
168
  }
167
169
  if (corsBlockedCount > 0) {
168
- issues.push({ severity: "info", message: `${corsBlockedCount} cross-origin stylesheet(s) could not be read, so their @media rules are not counted` });
170
+ issues.push({ code: "unreadable-stylesheets", severity: "info", message: `${corsBlockedCount} cross-origin stylesheet(s) could not be read, so their @media rules are not counted` });
169
171
  }
170
172
 
171
173
  // --- Console output --------------------------------------------------------------------
@@ -337,6 +337,7 @@
337
337
 
338
338
  if (isStaticAsset(type) && cc["no-store"]) {
339
339
  entry.antiPatterns.push({
340
+ code: "static-no-store",
340
341
  id: "static-no-store",
341
342
  severity: "error",
342
343
  message: `${type} asset with no-store; static assets should be cacheable`,
@@ -345,6 +346,7 @@
345
346
 
346
347
  if (entry.hasHash && maxAge !== null && maxAge < 86400 && !cc["no-store"]) {
347
348
  entry.antiPatterns.push({
349
+ code: "versioned-short-cache",
348
350
  id: "versioned-short-cache",
349
351
  severity: "warning",
350
352
  message: `Versioned URL (hash detected) but max-age=${maxAge}s: consider long cache or immutable`,
@@ -353,6 +355,7 @@
353
355
 
354
356
  if (entry.hasHash && !cc["immutable"] && !cc["no-store"] && (maxAge === null || maxAge >= 86400)) {
355
357
  entry.antiPatterns.push({
358
+ code: "versioned-not-immutable",
356
359
  id: "versioned-not-immutable",
357
360
  severity: "info",
358
361
  message: `Versioned URL (hash detected) without Cache-Control: immutable`,
@@ -364,6 +367,7 @@
364
367
  ["none", "no-store", "no-cache"].includes(strategy)
365
368
  ) {
366
369
  entry.antiPatterns.push({
370
+ code: "large-no-cache",
367
371
  id: "large-no-cache",
368
372
  severity: "error",
369
373
  message: `Large resource (${formatBytes(entry.decodedBodySize)}) with no effective cache`,
@@ -372,6 +376,7 @@
372
376
 
373
377
  if (entry.headers["expires"] && !entry.headers["cache-control"]) {
374
378
  entry.antiPatterns.push({
379
+ code: "expires-without-cc",
375
380
  id: "expires-without-cc",
376
381
  severity: "warning",
377
382
  message: `Uses Expires header without Cache-Control (outdated pattern)`,
@@ -380,6 +385,7 @@
380
385
 
381
386
  if (isStaticAsset(type) && maxAge === 0) {
382
387
  entry.antiPatterns.push({
388
+ code: "maxage-zero-static",
383
389
  id: "maxage-zero-static",
384
390
  severity: "warning",
385
391
  message: `Static asset with max-age=0, which forces revalidation on every request`,
@@ -394,6 +400,7 @@
394
400
  !cc["no-store"]
395
401
  ) {
396
402
  entry.antiPatterns.push({
403
+ code: "short-cache-static",
397
404
  id: "short-cache-static",
398
405
  severity: "warning",
399
406
  message: `Static asset with max-age=${maxAge}s (< 1h): consider a longer cache duration`,
@@ -472,6 +479,7 @@
472
479
  // are anti-patterns like the header-based ones: they reach `items` and `issues`.
473
480
  for (const entry of uncompressedResources) {
474
481
  entry.antiPatterns.push({
482
+ code: "uncompressed",
475
483
  id: "uncompressed",
476
484
  severity: "warning",
477
485
  message: `${entry.type} sent uncompressed (${formatBytes(entry.encodedBodySize)} on the wire for ${formatBytes(entry.decodedBodySize)}): enable Brotli or gzip`,
@@ -480,6 +488,7 @@
480
488
  for (const duplicate of costlyDuplicates) {
481
489
  // One finding per URL, on the first request, not one per request
482
490
  entries.find((e) => e.url === duplicate.url).antiPatterns.push({
491
+ code: "duplicate-uncached",
483
492
  id: "duplicate-uncached",
484
493
  severity: "warning",
485
494
  message: `Loaded ${duplicate.count} times, ${duplicate.uncachedLoads} from the network: load it once or make it cacheable`,
@@ -551,6 +560,7 @@
551
560
  .join(", ");
552
561
  coverageIssue = {
553
562
  severity: "info",
563
+ code: "header-coverage",
554
564
  id: "header-coverage",
555
565
  message: `Cache headers were read for ${headersAnalyzed} of ${entries.length} resources (${headerCoveragePercent}%): the rest are on other origins or are not static assets, so their Cache-Control cannot be read from the page. Most of them are on ${topHosts}; check those hosts in the Network panel or with curl -I`,
556
566
  };
@@ -916,10 +926,12 @@
916
926
  })),
917
927
  issues: [
918
928
  ...allAntiPatterns.map((p) => ({
929
+ code: p.code,
919
930
  severity: p.severity,
920
931
  id: p.id,
921
932
  resource: p.resource,
922
933
  message: p.message,
934
+ target: p.url,
923
935
  })),
924
936
  ...(coverageIssue ? [coverageIssue] : []),
925
937
  ],
@@ -99,6 +99,7 @@
99
99
  if (sameOrigin && referrerPath !== currentPath && referrerPath !== '') {
100
100
  hasClientRedirect = true;
101
101
  indicators.push({
102
+ code: 'same-origin-navigation',
102
103
  type: 'Same-origin navigation',
103
104
  from: referrerPath,
104
105
  to: currentPath,
@@ -153,6 +154,7 @@
153
154
  // Only flag if it's BOTH fast AND small (likely a redirect page)
154
155
  if (sameOrigin && domContentLoaded < 500 && responseSize < 10000) {
155
156
  indicators.push({
157
+ code: 'fast-minimal-navigation',
156
158
  type: 'Fast minimal-content navigation',
157
159
  duration: domContentLoaded.toFixed(1) + 'ms',
158
160
  size: (responseSize / 1024).toFixed(1) + ' KB',
@@ -337,9 +339,9 @@
337
339
  referrerPath,
338
340
  },
339
341
  issues: [
340
- ...(serverRedirects > 0 ? [{ severity: "warning", message: `${serverRedirects} server-side redirect(s) detected (${Math.round(redirectTime)}ms)` }] : []),
341
- ...(documentNavigations.length > 0 ? [{ severity: "error", message: `${documentNavigations.length} same-origin document navigation(s) detected, likely a client-side redirect` }] : []),
342
- ...indicators.filter(i => i.severity === "warning").map(i => ({ severity: "warning", message: i.type })),
342
+ ...(serverRedirects > 0 ? [{ code: "server-redirects", severity: "warning", message: `${serverRedirects} server-side redirect(s) detected (${Math.round(redirectTime)}ms)` }] : []),
343
+ ...(documentNavigations.length > 0 ? [{ code: "document-navigation", severity: "error", message: `${documentNavigations.length} same-origin document navigation(s) detected, likely a client-side redirect` }] : []),
344
+ ...indicators.filter(i => i.severity === "warning").map(i => ({ code: i.code, severity: "warning", message: i.type })),
343
345
  ],
344
346
  };
345
347
  })();
@@ -119,18 +119,21 @@
119
119
  const issues = [];
120
120
  if (uncompressed.length > 0) {
121
121
  issues.push({
122
+ code: "uncompressed",
122
123
  severity: "warning",
123
124
  message: `${uncompressed.length} text resource(s) served without compression; enabling gzip or brotli could save about ${formatBytes(estimatedSavingsBytes)}`,
124
125
  });
125
126
  }
126
127
  if (corsLimitedAnalysis) {
127
128
  issues.push({
129
+ code: "unknown-size",
128
130
  severity: "info",
129
131
  message: `${sizeUnknownCount} cross-origin text resource(s) without Timing-Allow-Origin report zero sizes, so their compression is unknown`,
130
132
  });
131
133
  }
132
134
  if (!contentEncodingSupported && candidates.length > 0) {
133
135
  issues.push({
136
+ code: "content-encoding-unavailable",
134
137
  severity: "info",
135
138
  message: "PerformanceResourceTiming.contentEncoding is not available; compression is inferred from encodedBodySize versus decodedBodySize",
136
139
  });
@@ -348,9 +348,9 @@ window.analyzeContentVisibilityOpportunities = analyzeContentVisibilityOpportuni
348
348
  issues: [
349
349
  ...cvResults.autoElements
350
350
  .filter(el => el.containIntrinsicSize === "not set" || el.containIntrinsicSize === "none")
351
- .map(el => ({ severity: "warning", message: `${el.selector}: missing contain-intrinsic-size (CLS risk)` })),
351
+ .map(el => ({ code: "missing-intrinsic-size", severity: "warning", message: `${el.selector}: missing contain-intrinsic-size (CLS risk)`, target: el.selector })),
352
352
  ...(cvResults.autoElements.length + cvResults.hiddenElements.length === 0
353
- ? [{ severity: "info", message: "No content-visibility usage found. Consider content-visibility: auto on below-the-fold sections to reduce the initial render cost." }]
353
+ ? [{ code: "not-used", severity: "info", message: "No content-visibility usage found. Consider content-visibility: auto on below-the-fold sections to reduce the initial render cost." }]
354
354
  : []),
355
355
  ],
356
356
  };
@@ -221,6 +221,7 @@
221
221
 
222
222
  if (renderBlockingCSS.length > 0 && inlineStyles.length === 0) {
223
223
  issues.push({
224
+ code: "no-inline-critical-css",
224
225
  severity: "high",
225
226
  message: "No critical CSS inlined: all CSS loads via render-blocking requests",
226
227
  suggestion:
@@ -230,6 +231,7 @@
230
231
 
231
232
  if (totalRenderBlockingDecoded > criticalBudget) {
232
233
  issues.push({
234
+ code: "render-blocking-over-budget",
233
235
  severity: "high",
234
236
  message: `Render-blocking CSS (${formatBytes(totalRenderBlockingDecoded)}) exceeds the 14 KB critical budget`,
235
237
  suggestion:
@@ -242,6 +244,7 @@
242
244
  );
243
245
  if (largeUnpreloaded.length > 0) {
244
246
  issues.push({
247
+ code: "large-not-preloaded",
245
248
  severity: "medium",
246
249
  message: `${largeUnpreloaded.length} large render-blocking stylesheet(s) without preload`,
247
250
  details: largeUnpreloaded.map(
@@ -253,6 +256,7 @@
253
256
 
254
257
  if (totalInlineBytes > criticalBudget) {
255
258
  issues.push({
259
+ code: "inline-over-budget",
256
260
  severity: "high",
257
261
  message: `Inline CSS (${formatBytes(totalInlineBytes)}) exceeds the 14 KB critical budget`,
258
262
  suggestion:
@@ -263,6 +267,7 @@
263
267
  const bodyInlineStyles = inlineStyles.filter((s) => !s.inHead);
264
268
  if (bodyInlineStyles.length > 0) {
265
269
  issues.push({
270
+ code: "styles-in-body",
266
271
  severity: "medium",
267
272
  message: `${bodyInlineStyles.length} inline style block(s) found in <body>`,
268
273
  suggestion:
@@ -332,6 +337,7 @@
332
337
  corsBlocked: s.corsBlocked,
333
338
  })),
334
339
  issues: issues.map(i => ({
340
+ code: i.code,
335
341
  severity: i.severity === 'high' ? 'error' : 'warning',
336
342
  message: i.message,
337
343
  })),
@@ -265,8 +265,8 @@
265
265
  isLcpCandidate: image.isLcpCandidate,
266
266
  })),
267
267
  issues: [
268
- ...(results.summary.lcpAffected ? [{ severity: "error", message: 'LCP candidate image has lazy loading: remove loading="lazy" and add fetchpriority="high"' }] : []),
269
- ...(results.lazyImages.filter(i => !i.isLcpCandidate).length > 0 ? [{ severity: "warning", message: `${results.lazyImages.filter(i => !i.isLcpCandidate).length} above-fold image(s) have lazy loading: remove loading="lazy"` }] : []),
268
+ ...(results.summary.lcpAffected ? [{ code: "lcp-lazy", severity: "error", message: 'LCP candidate image has lazy loading: remove loading="lazy" and add fetchpriority="high"' }] : []),
269
+ ...(results.lazyImages.filter(i => !i.isLcpCandidate).length > 0 ? [{ code: "lazy-above-fold", severity: "warning", message: `${results.lazyImages.filter(i => !i.isLcpCandidate).length} above-fold image(s) have lazy loading: remove loading="lazy"` }] : []),
270
270
  ],
271
271
  };
272
272
  })();
@@ -149,7 +149,7 @@
149
149
  };
150
150
  }),
151
151
  issues: conflictingElements.length > 0
152
- ? [{ severity: "error", message: `${conflictingElements.length} element(s) have conflicting loading="lazy" and fetchpriority="high"` }]
152
+ ? [{ code: "lazy-and-high-priority", severity: "error", message: `${conflictingElements.length} element(s) have conflicting loading="lazy" and fetchpriority="high"` }]
153
153
  : [],
154
154
  };
155
155
  })();
@@ -304,7 +304,7 @@
304
304
  })),
305
305
  ],
306
306
  issues: totalImages > 0
307
- ? [{ severity: "warning", message: `${totalImages} image(s) outside the viewport are missing loading="lazy"` }]
307
+ ? [{ code: "eager-off-viewport", severity: "warning", message: `${totalImages} image(s) outside the viewport are missing loading="lazy"` }]
308
308
  : [],
309
309
  };
310
310
  })();
@@ -161,7 +161,7 @@
161
161
  sizeBytes: r.size,
162
162
  })),
163
163
  issues: blockingResources.length > 0
164
- ? [{ severity: "error", message: `${blockingResources.length} render-blocking resource(s) delay rendering until ${Math.round(lastBlockingEnd)}ms` }]
164
+ ? [{ code: "render-blocking", severity: "error", message: `${blockingResources.length} render-blocking resource(s) delay rendering until ${Math.round(lastBlockingEnd)}ms` }]
165
165
  : [],
166
166
  };
167
167
  })();
@@ -321,14 +321,15 @@
321
321
  })),
322
322
  issues: [
323
323
  ...(scripts.some((s) => !s.sizeKnown)
324
- ? [{ severity: "info", message: `${scripts.filter((s) => !s.sizeKnown).length} script(s) have an unknown size (missing Timing-Allow-Origin); size totals are a lower bound` }]
324
+ ? [{ code: "unknown-size", severity: "info", message: `${scripts.filter((s) => !s.sizeKnown).length} script(s) have an unknown size (missing Timing-Allow-Origin); size totals are a lower bound` }]
325
325
  : []),
326
326
  ...(!renderBlockingSupported && totalScripts > 0
327
- ? [{ severity: "info", message: "renderBlockingStatus is not supported in this browser (Chrome 107+), so which scripts block rendering is unknown; run the snippet in Chrome or Edge to check" }]
327
+ ? [{ code: "render-blocking-unsupported", severity: "info", message: "renderBlockingStatus is not supported in this browser (Chrome 107+), so which scripts block rendering is unknown; run the snippet in Chrome or Edge to check" }]
328
328
  : []),
329
329
  ...(thirdMetrics.blocking > 0
330
330
  ? [
331
331
  {
332
+ code: "third-party-blocking",
332
333
  severity: "error",
333
334
  message: `${thirdMetrics.blocking} render-blocking third-party script(s)`,
334
335
  },
@@ -337,6 +338,7 @@
337
338
  ...(thirdMetrics.hosts.length > 3
338
339
  ? [
339
340
  {
341
+ code: "many-third-party-hosts",
340
342
  severity: "warning",
341
343
  message: `${thirdMetrics.hosts.length} different third-party hosts require separate DNS lookups`,
342
344
  },
@@ -345,6 +347,7 @@
345
347
  ...(thirdMetrics.totalSize > 100 * 1024
346
348
  ? [
347
349
  {
350
+ code: "large-third-party",
348
351
  severity: "warning",
349
352
  message: `Third-party scripts total ${Math.round(thirdMetrics.totalSize / 1024)} KB`,
350
353
  },
@@ -328,10 +328,10 @@
328
328
  },
329
329
  items: slowestScripts.slice(0, MAX_ITEMS).map(s => ({ shortName: s.shortName, host: s.host, firstParty: s.firstParty, totalMs: Math.round(s.total), dnsMs: roundOrNull(s.dns), tcpMs: roundOrNull(s.tcp), requestMs: roundOrNull(s.request), responseMs: roundOrNull(s.response), hasTiming: s.hasTiming })),
330
330
  issues: [
331
- ...(slowScripts.length > 0 ? [{ severity: "warning", message: `${slowScripts.length} script(s) take over ${slowThreshold}ms to load` }] : []),
332
- ...(hasSlowDns ? [{ severity: "warning", message: "Slow DNS lookups detected (>100ms). Add dns-prefetch or preconnect." }] : []),
333
- ...(hasSlowTcp ? [{ severity: "warning", message: "Slow TCP connections detected (>100ms). Add preconnect for critical origins." }] : []),
334
- ...(hasSlowRequest ? [{ severity: "warning", message: "Slow server response times detected (>200ms). Consider CDN or self-hosting." }] : []),
331
+ ...(slowScripts.length > 0 ? [{ code: "slow-scripts", severity: "warning", message: `${slowScripts.length} script(s) take over ${slowThreshold}ms to load` }] : []),
332
+ ...(hasSlowDns ? [{ code: "slow-dns", severity: "warning", message: "Slow DNS lookups detected (>100ms). Add dns-prefetch or preconnect." }] : []),
333
+ ...(hasSlowTcp ? [{ code: "slow-connection", severity: "warning", message: "Slow TCP connections detected (>100ms). Add preconnect for critical origins." }] : []),
334
+ ...(hasSlowRequest ? [{ code: "slow-server", severity: "warning", message: "Slow server response times detected (>200ms). Consider CDN or self-hosting." }] : []),
335
335
  ],
336
336
  };
337
337
  })();
@@ -359,9 +359,9 @@
359
359
  items: uniqueLoadedFonts.slice(0, 50).map(f => ({ family: f.family, weight: f.weight, style: f.style, display: f.display })),
360
360
  usedFonts: usedFonts.slice(0, 50).map(f => ({ family: f.family, weight: f.weight, style: f.style, elements: f.elements })),
361
361
  issues: [
362
- ...preloadedNotUsed.map(f => ({ severity: "warning", message: `Preloaded but not used above fold: ${f.name}` })),
363
- ...usedNotPreloaded.map(f => ({ severity: "warning", message: `Used above fold but not preloaded: ${f.family} (${f.weight})` })),
364
- ...preloadedFonts.filter(f => !f.crossorigin).map(f => ({ severity: "error", message: `Font preloaded without crossorigin (double fetch): ${f.name}` })),
362
+ ...preloadedNotUsed.map(f => ({ code: "preloaded-unused", severity: "warning", message: `Preloaded but not used above fold: ${f.name}`, target: f.name })),
363
+ ...usedNotPreloaded.map(f => ({ code: "used-not-preloaded", severity: "warning", message: `Used above fold but not preloaded: ${f.family} (${f.weight})`, target: `${f.family} (${f.weight})` })),
364
+ ...preloadedFonts.filter(f => !f.crossorigin).map(f => ({ code: "preload-no-crossorigin", severity: "error", message: `Font preloaded without crossorigin (double fetch): ${f.name}`, target: f.name })),
365
365
  ],
366
366
  };
367
367
  })();