webperf-snippets 1.12.0 → 1.13.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.
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, the version of the CLI and the machine: its CPU model, cores, architecture and memory, as the operating system reports them, and a benchmark index, a short CPU test in Node before Chromium starts, which only informs, since on the same machine it moves by up to 40 % with the load.
74
74
 
75
75
  ```json
76
- "conditions": { "viewport": "mobile", "workflow": "core-web-vitals", "waitMs": 3000, "cliVersion": "1.12.0", "machine": { "cpu": "Apple M4", "cores": 10, "arch": "arm64", "memoryGB": 16, "benchmarkIndex": 2371 } }
76
+ "conditions": { "viewport": "mobile", "workflow": "core-web-vitals", "waitMs": 3000, "cliVersion": "1.13.0", "machine": { "cpu": "Apple M4", "cores": 10, "arch": "arm64", "memoryGB": 16, "benchmarkIndex": 2371 } }
77
77
  ```
78
78
 
79
79
  Single snippet:
@@ -171,7 +171,7 @@ Without `--report-to` the CLI makes no external calls. With it, the results are
171
171
  "timestamp": "2026-05-06T10:00:00.000Z",
172
172
  "navMs": 1240,
173
173
  "results": [],
174
- "meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.12.0" }
174
+ "meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.13.0" }
175
175
  }
176
176
  ```
177
177
 
@@ -541,7 +541,7 @@ The `NPM_TOKEN` secret must be set in the repository settings with publish acces
541
541
  - **CLS in headless is conservative**: layout shifts that only happen on scroll are missed unless you script the scroll.
542
542
  - **First navigation only**: each `webperf-snippets` invocation runs one URL. SPAs need the post-route URL passed directly.
543
543
  - **Synthetic INP ≠ field INP**: `--interact-script` measures handler latency for a single scripted event. Real INP reflects the worst interaction across all user sessions; use RUM for field data.
544
- - **A click that paints nothing**: INP is under 16 ms with the `no-paint-measured` issue, as web-vitals reports it, so it rates `good` and passes `--budget-inp`. Check that the selector reaches an element that responds. `tracking` after a click means that the document recorded no interaction, as with a click inside an iframe: the run exits with code `0` unless it has a budget.
544
+ - **A click that paints nothing**: INP is under 16 ms with the `no-paint-measured` issue, as web-vitals reports it, so it rates `good` and passes `--budget-inp`. Check that the selector reaches an element that responds. When the document recorded no interaction at all, as with a click inside an iframe, the snippet is an error that says a click or key press was sent, and the run exits with code `1`.
545
545
 
546
546
  ## Roadmap
547
547
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webperf-snippets",
3
- "version": "1.12.0",
3
+ "version": "1.13.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",
@@ -21,6 +21,7 @@
21
21
  },
22
22
  "devDependencies": {
23
23
  "@modelcontextprotocol/client": "^2.3.1",
24
+ "@projectwallace/css-analyzer": "9.9.5",
24
25
  "vitest": "^5.0.5",
25
26
  "web-vitals": "^6.2.3"
26
27
  },
@@ -0,0 +1,562 @@
1
+ // CSS Complexity
2
+ // What in the CSS of the page costs bytes or style work and can go: embedded content, @import, empty
3
+ // rules, repeated selectors, unused @keyframes and complex selectors, read from the CSSOM of every
4
+ // stylesheet, and with
5
+ // fetch for a stylesheet of another origin whose rules the page cannot read
6
+ // https://webperf-snippets.net
7
+
8
+ (async () => {
9
+ const SCRIPT = "CSS-Complexity";
10
+ const MAX_ITEMS = 50;
11
+ // The 90th percentile of the embedded content of a homepage in The CSS Selection 2026 (Project
12
+ // Wallace): 90% of homepages embed less than this in all their CSS
13
+ const EMBEDDED_P90_BYTES = 36632;
14
+ // The 90th percentiles of !important (share of the declarations) and of @font-face rules
15
+ const IMPORTANT_P90_PERCENT = 13;
16
+ const FONT_FACE_P90 = 48;
17
+ // The 90th percentile of the most complex selector of a homepage
18
+ const SELECTOR_COMPLEXITY_P90 = 29;
19
+ const MAX_COMPLEX_SELECTORS = 5;
20
+ // Below this share of unique selectors, css-code-quality (Project Wallace) flags the duplication
21
+ const MIN_SELECTOR_UNIQUENESS = 0.66;
22
+
23
+ // @shared formatBytes
24
+ function formatBytes(bytes) {
25
+ if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
26
+ if (bytes === 0) return "0 B";
27
+ const units = ["B", "KB", "MB", "GB"];
28
+ const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
29
+ return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
30
+ }
31
+ // @end-shared formatBytes
32
+
33
+ // @shared ownRequestUrl
34
+ // The URL a snippet requests when it reads a file of the page itself (fetch, a HEAD request,
35
+ // new Image()). The fragment never reaches the server or the HTTP cache key, so the file still
36
+ // comes from the cache, but it stays in the name of the Resource Timing entry of the request,
37
+ // which is how isOwnRequest leaves these requests out of what the page loaded.
38
+ function ownRequestUrl(url) {
39
+ try {
40
+ const parsed = new URL(url, location.href);
41
+ if (!/^https?:$/.test(parsed.protocol)) return url;
42
+ parsed.hash = "webperf-snippets";
43
+ return parsed.href;
44
+ } catch {
45
+ return url;
46
+ }
47
+ }
48
+ // @end-shared ownRequestUrl
49
+
50
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
51
+
52
+ // Splits on a separator outside parentheses, brackets and strings: the declarations of a rule
53
+ // (";"), or the selectors of a list (",")
54
+ function splitTopLevel(text, separator) {
55
+ const parts = [];
56
+ let depth = 0;
57
+ let quote = null;
58
+ let start = 0;
59
+ for (let i = 0; i < text.length; i++) {
60
+ const c = text[i];
61
+ if (quote) {
62
+ if (c === "\\") i++;
63
+ else if (c === quote) quote = null;
64
+ } else if (c === '"' || c === "'") quote = c;
65
+ else if (c === "(" || c === "[") depth++;
66
+ else if (c === ")" || c === "]") depth--;
67
+ else if (c === separator && depth === 0) {
68
+ parts.push(text.slice(start, i));
69
+ start = i + 1;
70
+ }
71
+ }
72
+ parts.push(text.slice(start));
73
+ return parts.map((p) => p.trim()).filter(Boolean);
74
+ }
75
+
76
+ // The declarations as written, from cssText: the CSSOM lists a shorthand as all its longhands in
77
+ // style.length, and cssText joins them back
78
+ const declarationsOf = (style) => splitTopLevel(style.cssText, ";");
79
+
80
+ const FONT_TYPES = /^(font\/|application\/(x-)?font-|application\/vnd\.ms-fontobject)/;
81
+
82
+ // The data: URLs of a declaration block, as the CSSOM serializes them: url("data:…")
83
+ function embeddedOf(cssText, inFontFace) {
84
+ const found = [];
85
+ for (const [, raw] of cssText.matchAll(/url\("((?:[^"\\]|\\.)*)"\)/g)) {
86
+ const url = raw.replace(/\\(["\\])/g, "$1");
87
+ if (!/^data:/i.test(url)) continue;
88
+ const type = url.slice(5).split(/[;,]/)[0].toLowerCase() || "text/plain";
89
+ found.push({ type, bytes: url.length, font: inFontFace || FONT_TYPES.test(type) });
90
+ }
91
+ return found;
92
+ }
93
+
94
+ // --- Selector complexity, as css-analyzer counts it ------------------------------------------
95
+ // Every part of a selector counts 1: type, class, id, attribute, pseudo-class, pseudo-element,
96
+ // combinator, * and &. A vendor prefix and an attribute value count 1 more. :is(), :where(),
97
+ // :not(), :has(), :matches() and :-webkit-any() add the complexity of the selectors they hold,
98
+ // and :nth-child() that of its "of S" part, or 1 for its An+B. Those add every selector found in
99
+ // their argument, at any depth, so :is(:is(.a)) is 4. Another pseudo with an argument adds 1 for
100
+ // the list and the selectors in it, but :lang() and :nth-of-type() only 1.
101
+
102
+ const SELECTOR_FUNCTIONS = new Set(["is", "where", "not", "has", "matches", "-webkit-any", "-moz-any"]);
103
+ const total = (values) => values.reduce((a, b) => a + b, 0);
104
+ // The complexities of the selectors of a list, and of every selector nested in them
105
+ const selectorsIn = (text) => splitTopLevel(text, ",").flatMap((selector) => {
106
+ const { complexity, nested } = analyzeSelector(selector);
107
+ return [complexity, ...nested];
108
+ });
109
+ const complexityOf = (selectorText) => analyzeSelector(selectorText).complexity;
110
+
111
+ // The complexity of a selector, and the complexities of the selectors nested in its pseudo-classes
112
+ function analyzeSelector(selectorText) {
113
+ const s = selectorText.trim();
114
+ let complexity = 0;
115
+ const nested = [];
116
+ let i = 0;
117
+ let afterCombinator = false;
118
+ const readIdent = () => {
119
+ const start = i;
120
+ while (i < s.length) {
121
+ if (s[i] === "\\") i += 2;
122
+ else if (/[\w-]/.test(s[i]) || s.charCodeAt(i) > 127) i++;
123
+ else break;
124
+ }
125
+ return s.slice(start, i);
126
+ };
127
+ // The text between the parenthesis at i and its match, after which i points
128
+ const readGroup = (open, close) => {
129
+ const start = i + 1;
130
+ let depth = 0;
131
+ let quote = null;
132
+ for (; i < s.length; i++) {
133
+ const c = s[i];
134
+ if (quote) {
135
+ if (c === "\\") i++;
136
+ else if (c === quote) quote = null;
137
+ } else if (c === '"' || c === "'") quote = c;
138
+ else if (c === open) depth++;
139
+ else if (c === close && --depth === 0) return s.slice(start, i++);
140
+ }
141
+ return s.slice(start);
142
+ };
143
+ while (i < s.length) {
144
+ const c = s[i];
145
+ if (/\s/.test(c)) {
146
+ while (i < s.length && /\s/.test(s[i])) i++;
147
+ // Whitespace between two compounds is the descendant combinator
148
+ if (i < s.length && !">+~".includes(s[i]) && complexity > 0 && !afterCombinator) {
149
+ complexity++;
150
+ afterCombinator = true;
151
+ }
152
+ continue;
153
+ }
154
+ if (">+~".includes(c)) {
155
+ complexity++;
156
+ i++;
157
+ afterCombinator = true;
158
+ continue;
159
+ }
160
+ afterCombinator = false;
161
+ if (c === "*" || c === "&") {
162
+ complexity++;
163
+ i++;
164
+ } else if (c === "#" || c === ".") {
165
+ i++;
166
+ readIdent();
167
+ complexity++;
168
+ } else if (c === "[") {
169
+ const inner = readGroup("[", "]");
170
+ complexity += /=/.test(inner) ? 2 : 1;
171
+ } else if (c === ":") {
172
+ i += s[i + 1] === ":" ? 2 : 1;
173
+ const name = readIdent().toLowerCase();
174
+ complexity += name.startsWith("-") ? 2 : 1;
175
+ if (s[i] === "(") {
176
+ const arg = readGroup("(", ")").trim();
177
+ const of = name === "nth-child" || name === "nth-last-child" ? arg.match(/\sof\s([\s\S]+)$/i) : null;
178
+ if (SELECTOR_FUNCTIONS.has(name) || of) {
179
+ const found = selectorsIn(of ? of[1] : arg);
180
+ complexity += total(found);
181
+ nested.push(...found);
182
+ } else if (name === "nth-child" || name === "nth-last-child" || name === "nth-of-type" || name === "nth-last-of-type" || name === "lang") {
183
+ complexity += arg ? 1 : 0;
184
+ } else {
185
+ const found = selectorsIn(arg);
186
+ const own = splitTopLevel(arg, ",").map(complexityOf);
187
+ complexity += 1 + total(own);
188
+ nested.push(...found);
189
+ }
190
+ }
191
+ } else if (/[\w-\\]/.test(c) || s.charCodeAt(i) > 127) {
192
+ readIdent();
193
+ complexity++;
194
+ } else {
195
+ i++;
196
+ }
197
+ }
198
+ return { complexity, nested };
199
+ }
200
+
201
+ // The most frequent value, or the mean of the most frequent ones when they tie, as css-analyzer
202
+ function modeOf(values) {
203
+ const frequencies = new Map();
204
+ for (const v of values) frequencies.set(v, (frequencies.get(v) ?? 0) + 1);
205
+ let top = 0;
206
+ for (const n of frequencies.values()) if (n > top) top = n;
207
+ const tied = [...frequencies.entries()].filter(([, n]) => n === top).map(([v]) => v);
208
+ return tied.length ? tied.reduce((a, b) => a + b, 0) / tied.length : 0;
209
+ }
210
+
211
+ const describeHost = (el) => el.localName + (el.id ? `#${el.id}` : el.classList[0] ? `.${el.classList[0]}` : "");
212
+
213
+ // --- The stylesheets: of the document, adopted, of open shadow roots, and imported -----------
214
+
215
+ // Each entry is a stylesheet of the page, or the URL of one to fetch, imported by a fetched one
216
+ const sheets = [];
217
+ const seenSheets = new Set();
218
+ // A stylesheet a browser extension links to the page is not part of it. The <style> elements an
219
+ // extension injects carry no such mark, so the page tells to measure without extensions.
220
+ const EXTENSION_URL = /^(chrome|moz|safari-web)-extension:/i;
221
+ let extensionCount = 0;
222
+ const addSheet = (sheet, source) => {
223
+ if (!sheet || seenSheets.has(sheet)) return;
224
+ seenSheets.add(sheet);
225
+ if (sheet.href && EXTENSION_URL.test(sheet.href)) {
226
+ extensionCount++;
227
+ return;
228
+ }
229
+ sheets.push({ sheet, source });
230
+ };
231
+ const addUrl = (href) => {
232
+ if (seenSheets.has(href)) return;
233
+ if (EXTENSION_URL.test(href)) {
234
+ seenSheets.add(href);
235
+ extensionCount++;
236
+ return;
237
+ }
238
+ seenSheets.add(href);
239
+ sheets.push({ href, source: href });
240
+ };
241
+
242
+ // A stylesheet of another origin loaded without CORS: its rules throw, but fetch can read it when
243
+ // the server sends Access-Control-Allow-Origin, as a CDN often does. replaceSync parses it with
244
+ // the same CSSOM, and drops @import, so the imports are read from the text and left out of it.
245
+ async function fetchSheet(href) {
246
+ try {
247
+ const response = await fetch(ownRequestUrl(href));
248
+ if (!response.ok) return null;
249
+ const text = (await response.text()).replace(/\/\*[\s\S]*?\*\//g, "");
250
+ const statements = text.match(/@import\s[^;]*;/gi) ?? [];
251
+ const imports = statements
252
+ .map((statement) => statement.match(/@import\s+(?:url\(\s*)?(["']?)([^"')\s;]+)\1/i)?.[2])
253
+ .filter(Boolean)
254
+ .map((url) => new URL(url, href).href);
255
+ const sheet = new window.CSSStyleSheet({ baseURL: href });
256
+ sheet.replaceSync(statements.reduce((css, statement) => css.replace(statement, ""), text));
257
+ return { rules: sheet.cssRules, imports };
258
+ } catch {
259
+ return null;
260
+ }
261
+ }
262
+ let styleIndex = 0;
263
+ let adoptedIndex = 0;
264
+ for (const sheet of document.styleSheets) addSheet(sheet, sheet.href || `<style> #${++styleIndex}`);
265
+ for (const sheet of document.adoptedStyleSheets ?? []) addSheet(sheet, `adopted stylesheet #${++adoptedIndex}`);
266
+ for (const el of document.querySelectorAll("*")) {
267
+ const root = el.shadowRoot;
268
+ if (!root) continue;
269
+ const host = describeHost(el);
270
+ for (const sheet of root.styleSheets) addSheet(sheet, sheet.href || `<style> in the shadow root of ${host}`);
271
+ for (const sheet of root.adoptedStyleSheets ?? []) addSheet(sheet, `adopted stylesheet in the shadow root of ${host}`);
272
+ }
273
+
274
+ // --- Counting ----------------------------------------------------------------------------------
275
+
276
+ const imports = [];
277
+ const embeddedTypes = new Map();
278
+ // Every selector of every rule, without its context (@media, @supports), as css-analyzer counts them
279
+ const selectorCounts = new Map();
280
+ // The complexity of every selector, and of each distinct one
281
+ const complexities = [];
282
+ const complexityBySelector = new Map();
283
+ const keyframesDefined = new Set();
284
+ const animationsUsed = new Set();
285
+ let keyframesCount = 0;
286
+ let fontFaceCount = 0;
287
+
288
+ // An element of another namespace than HTML, SVG or MathML has a style attribute and no style
289
+ const noteAnimations = (style) => {
290
+ for (const name of (style?.animationName ?? "").split(",")) {
291
+ const n = name.trim();
292
+ if (n && n !== "none") animationsUsed.add(n);
293
+ }
294
+ };
295
+
296
+ // Counts the declarations of a block, its !important and the animations it names
297
+ function noteDeclarations(style, stats) {
298
+ const declarations = declarationsOf(style);
299
+ stats.declarationCount += declarations.length;
300
+ stats.importantCount += declarations.filter((d) => /!\s*important$/i.test(d)).length;
301
+ noteAnimations(style);
302
+ return declarations;
303
+ }
304
+
305
+ function walk(rules, stats, sheetUrl) {
306
+ for (const rule of rules) {
307
+ if (rule instanceof window.CSSImportRule) {
308
+ stats.importCount++;
309
+ let href = rule.href;
310
+ try {
311
+ href = new URL(rule.href, sheetUrl).href;
312
+ } catch {
313
+ // keep the href as written
314
+ }
315
+ imports.push({ from: stats.source, href });
316
+ addSheet(rule.styleSheet, href);
317
+ } else if (rule instanceof window.CSSStyleRule) {
318
+ const declarations = noteDeclarations(rule.style, stats);
319
+ const nested = rule.cssRules?.length ?? 0;
320
+ const selectors = splitTopLevel(rule.selectorText, ",");
321
+ stats.ruleCount++;
322
+ stats.selectorCount += selectors.length;
323
+ for (const selector of selectors) {
324
+ selectorCounts.set(selector, (selectorCounts.get(selector) ?? 0) + 1);
325
+ const complexity = complexityBySelector.get(selector) ?? complexityOf(selector);
326
+ complexityBySelector.set(selector, complexity);
327
+ complexities.push(complexity);
328
+ }
329
+ if (declarations.length === 0 && nested === 0) stats.emptyRuleCount++;
330
+ count(embeddedOf(rule.style.cssText, false), stats);
331
+ if (nested) walk(rule.cssRules, stats, sheetUrl);
332
+ } else if (window.CSSNestedDeclarations && rule instanceof window.CSSNestedDeclarations) {
333
+ // The declarations of a style rule that follow a nested rule, or sit in a nested @media
334
+ noteDeclarations(rule.style, stats);
335
+ count(embeddedOf(rule.style.cssText, false), stats);
336
+ } else if (rule instanceof window.CSSFontFaceRule) {
337
+ fontFaceCount++;
338
+ stats.declarationCount += declarationsOf(rule.style).length;
339
+ count(embeddedOf(rule.style.cssText, true), stats);
340
+ } else if (rule instanceof window.CSSKeyframesRule) {
341
+ keyframesCount++;
342
+ keyframesDefined.add(rule.name);
343
+ for (const keyframe of rule.cssRules) {
344
+ stats.declarationCount += declarationsOf(keyframe.style).length;
345
+ count(embeddedOf(keyframe.style.cssText, false), stats);
346
+ }
347
+ } else if (rule.cssRules) {
348
+ // @media, @supports, @layer, @container and the other rules that group rules
349
+ walk(rule.cssRules, stats, sheetUrl);
350
+ }
351
+ }
352
+ }
353
+
354
+ function count(embedded, stats) {
355
+ for (const { type, bytes, font } of embedded) {
356
+ stats.embeddedBytes += bytes;
357
+ stats.embeddedCount++;
358
+ if (font) {
359
+ stats.fontCount++;
360
+ stats.fontBytes += bytes;
361
+ }
362
+ const t = embeddedTypes.get(type) ?? { type, count: 0, bytes: 0 };
363
+ t.count++;
364
+ t.bytes += bytes;
365
+ embeddedTypes.set(type, t);
366
+ }
367
+ }
368
+
369
+ const perSheet = [];
370
+ // The list grows while it is read: each @import adds the sheet it imports
371
+ for (let i = 0; i < sheets.length; i++) {
372
+ const { sheet, href, source } = sheets[i];
373
+ let rules = null;
374
+ let read = "cssom";
375
+ let fetchedImports = [];
376
+ try {
377
+ rules = sheet?.cssRules ?? null;
378
+ } catch {
379
+ // A stylesheet of another origin, loaded without CORS
380
+ }
381
+ const url = href ?? sheet?.href;
382
+ if (!rules && url) {
383
+ const fetched = await fetchSheet(url);
384
+ if (fetched) {
385
+ rules = fetched.rules;
386
+ fetchedImports = fetched.imports;
387
+ read = "fetched";
388
+ }
389
+ }
390
+ if (!rules) {
391
+ perSheet.push({ source, read: "unreadable" });
392
+ continue;
393
+ }
394
+ const stats = { source, read, ruleCount: 0, selectorCount: 0, declarationCount: 0, importantCount: 0, emptyRuleCount: 0, embeddedBytes: 0, embeddedCount: 0, fontCount: 0, fontBytes: 0, importCount: 0 };
395
+ walk(rules, stats, url || location.href);
396
+ for (const imported of fetchedImports) {
397
+ stats.importCount++;
398
+ imports.push({ from: source, href: imported });
399
+ addUrl(imported);
400
+ }
401
+ perSheet.push(stats);
402
+ }
403
+
404
+ // The animations of style attributes, in the document and in open shadow roots
405
+ const roots = [document, ...[...document.querySelectorAll("*")].map((el) => el.shadowRoot).filter(Boolean)];
406
+ for (const root of roots) for (const el of root.querySelectorAll("[style]")) noteAnimations(el.style);
407
+
408
+ const read = perSheet.filter((s) => s.read !== "unreadable");
409
+ const unreadable = perSheet.filter((s) => s.read === "unreadable");
410
+ const sum = (key) => read.reduce((total, s) => total + s[key], 0);
411
+ // Unique selectors over all selectors; 1 without selectors, since none repeats
412
+ const selectorUniqueness = () => {
413
+ const total = [...selectorCounts.values()].reduce((a, b) => a + b, 0);
414
+ return total ? Math.round((selectorCounts.size / total) * 1000) / 1000 : 1;
415
+ };
416
+ const details = {
417
+ stylesheetCount: perSheet.length,
418
+ extensionCount,
419
+ unreadableCount: unreadable.length,
420
+ fetchedCount: perSheet.filter((s) => s.read === "fetched").length,
421
+ ruleCount: sum("ruleCount"),
422
+ selectorCount: sum("selectorCount"),
423
+ declarationCount: sum("declarationCount"),
424
+ embeddedBytes: sum("embeddedBytes"),
425
+ embeddedCount: sum("embeddedCount"),
426
+ embeddedTypes: [...embeddedTypes.values()].sort((a, b) => b.bytes - a.bytes || a.type.localeCompare(b.type)),
427
+ importCount: sum("importCount"),
428
+ emptyRuleCount: sum("emptyRuleCount"),
429
+ selectorUniqueness: selectorUniqueness(),
430
+ importantCount: sum("importantCount"),
431
+ importantPercent: 0,
432
+ fontFaceCount,
433
+ keyframesCount,
434
+ unusedKeyframes: [...keyframesDefined].filter((name) => !animationsUsed.has(name)).sort(),
435
+ // A loop, not Math.max(...complexities): a spread of hundreds of thousands of values overflows the stack
436
+ maxSelectorComplexity: complexities.reduce((max, c) => (c > max ? c : max), 0),
437
+ commonSelectorComplexity: modeOf(complexities),
438
+ complexSelectors: [...complexityBySelector.entries()]
439
+ .map(([selectorText, complexity]) => ({ selectorText, complexity }))
440
+ .sort((a, b) => b.complexity - a.complexity)
441
+ .slice(0, MAX_COMPLEX_SELECTORS),
442
+ };
443
+ details.importantPercent = details.declarationCount ? Math.round((details.importantCount / details.declarationCount) * 1000) / 10 : 0;
444
+
445
+ // --- Issues ------------------------------------------------------------------------------------
446
+
447
+ const issues = [];
448
+ for (const s of read.filter((s) => s.fontCount > 0)) {
449
+ issues.push({
450
+ code: "embedded-font",
451
+ severity: "warning",
452
+ message: `${s.source} embeds ${plural(s.fontCount, "font file")} as ${s.fontCount === 1 ? "a data: URL" : "data: URLs"} (${formatBytes(s.fontBytes)}). An embedded font downloads with the stylesheet, blocks rendering with it and cannot be cached apart from it: serve fonts as WOFF2 files, with font-display`,
453
+ target: s.source,
454
+ });
455
+ }
456
+ if (details.embeddedBytes > EMBEDDED_P90_BYTES) {
457
+ issues.push({
458
+ code: "embedded-content",
459
+ severity: "warning",
460
+ message: `The CSS embeds ${formatBytes(details.embeddedBytes)} in ${plural(details.embeddedCount, "data: URL")}, more than 90% of homepages (${formatBytes(EMBEDDED_P90_BYTES)}, The CSS Selection 2026). It downloads and parses with the stylesheet even when it is not shown: serve large images and fonts as files`,
461
+ });
462
+ }
463
+ for (const { from, href } of imports) {
464
+ issues.push({
465
+ code: "import",
466
+ severity: "warning",
467
+ message: `${from} imports ${href} with @import: the browser only requests it after it downloads and parses ${from}. Link it from the HTML, or bundle it`,
468
+ target: href,
469
+ });
470
+ }
471
+ if (details.emptyRuleCount > 0) {
472
+ issues.push({
473
+ code: "empty-rules",
474
+ severity: "info",
475
+ message: `${plural(details.emptyRuleCount, "empty rule")}, with no declarations: bytes to download and selectors to match that style nothing`,
476
+ });
477
+ }
478
+ if (details.selectorCount > 0 && details.selectorUniqueness < MIN_SELECTOR_UNIQUENESS) {
479
+ const repeated = [...selectorCounts.entries()].filter(([, n]) => n > 1).sort((a, b) => b[1] - a[1]);
480
+ issues.push({
481
+ code: "duplicate-selectors",
482
+ severity: "info",
483
+ message: `Only ${Math.round(details.selectorUniqueness * 100)}% of the ${details.selectorCount} selectors are unique: the same selectors repeat across rules, which adds CSS to download and match. Some repeat inside @media on purpose; merge the others. Most repeated: ${repeated.slice(0, 3).map(([sel, n]) => `${sel} (${n})`).join(", ")}`,
484
+ });
485
+ }
486
+ if (details.importantPercent > IMPORTANT_P90_PERCENT) {
487
+ issues.push({
488
+ code: "important-ratio",
489
+ severity: "info",
490
+ message: `${details.importantPercent}% of the declarations use !important, more than on 90% of homepages (${IMPORTANT_P90_PERCENT}%, The CSS Selection 2026): a sign of overrides on overrides, which make the CSS grow`,
491
+ });
492
+ }
493
+ if (fontFaceCount > FONT_FACE_P90) {
494
+ issues.push({
495
+ code: "font-face-count",
496
+ severity: "info",
497
+ message: `${fontFaceCount} @font-face rules, more than on 90% of homepages (${FONT_FACE_P90}, The CSS Selection 2026). Run Webfont-Usage-Analyzer to see which faces load and which are never used`,
498
+ });
499
+ }
500
+ for (const { selectorText, complexity } of details.complexSelectors.filter((c) => c.complexity > SELECTOR_COMPLEXITY_P90)) {
501
+ issues.push({
502
+ code: "selector-complexity",
503
+ severity: "info",
504
+ message: `${selectorText} has a complexity of ${complexity}, more than the most complex selector of 90% of homepages (${SELECTOR_COMPLEXITY_P90}, The CSS Selection 2026). Complexity counts the parts of a selector, not its cost: the CSS selector costs insight of the DevTools Performance panel measures how long matching takes`,
505
+ target: selectorText,
506
+ });
507
+ }
508
+ // A stylesheet that could not be read could use them
509
+ if (unreadable.length === 0) {
510
+ for (const name of details.unusedKeyframes) {
511
+ issues.push({
512
+ code: "unused-keyframes",
513
+ severity: "info",
514
+ message: `@keyframes ${name} is defined, and no animation of the stylesheets or of a style attribute uses it: CSS that never runs, unless JavaScript sets it later`,
515
+ target: name,
516
+ });
517
+ }
518
+ }
519
+ if (unreadable.length > 0) {
520
+ issues.push({
521
+ code: "unreadable-stylesheets",
522
+ severity: "info",
523
+ message: `${plural(unreadable.length, "cross-origin stylesheet")} could not be read, by the page or with fetch (no Access-Control-Allow-Origin), so the counts are a lower bound: ${unreadable.map((s) => s.source).join(", ")}`,
524
+ });
525
+ }
526
+
527
+ // --- Items, the heaviest sheets first ----------------------------------------------------------
528
+
529
+ const items = perSheet
530
+ .map((s) => (s.read === "unreadable" ? { source: s.source, read: s.read } : { source: s.source, read: s.read, ruleCount: s.ruleCount, embeddedBytes: s.embeddedBytes, importCount: s.importCount, importantCount: s.importantCount }))
531
+ .sort((a, b) => (b.embeddedBytes ?? -1) - (a.embeddedBytes ?? -1) || (b.ruleCount ?? -1) - (a.ruleCount ?? -1));
532
+
533
+ // --- Console -----------------------------------------------------------------------------------
534
+
535
+ console.group("%c🎨 CSS Complexity", "font-weight: bold; font-size: 14px;");
536
+ const readNotes = [details.fetchedCount && `${details.fetchedCount} read with fetch`, unreadable.length && `${unreadable.length} unreadable`].filter(Boolean);
537
+ console.log(`Stylesheets: ${details.stylesheetCount}${readNotes.length ? ` (${readNotes.join(", ")})` : ""}`);
538
+ console.log(`Rules: ${details.ruleCount} · Selectors: ${details.selectorCount} · Declarations: ${details.declarationCount}`);
539
+ console.log(`Embedded content: ${formatBytes(details.embeddedBytes)} in ${plural(details.embeddedCount, "data: URL")}`);
540
+ console.log(`@import: ${details.importCount} · Empty rules: ${details.emptyRuleCount}`);
541
+ console.log(`Unique selectors: ${Math.round(details.selectorUniqueness * 100)}% · !important: ${details.importantCount} (${details.importantPercent}%)`);
542
+ console.log(`Selector complexity: ${details.maxSelectorComplexity} at most, ${details.commonSelectorComplexity} the most common`);
543
+ console.log(`@font-face: ${fontFaceCount} · @keyframes: ${keyframesCount}${details.unusedKeyframes.length ? ` (unused: ${details.unusedKeyframes.join(", ")})` : ""}`);
544
+ if (items.length > 0) {
545
+ console.table(
546
+ items.map((i) => ({ Source: i.source, Read: i.read, Rules: i.ruleCount ?? "-", Embedded: formatBytes(i.embeddedBytes), "@import": i.importCount ?? "-", "!important": i.importantCount ?? "-" }))
547
+ );
548
+ }
549
+ if (issues.length > 0) issues.forEach((i) => console.log(`[${i.severity}] ${i.message}`));
550
+ else console.log("%c✅ No issues found", "color: #22c55e; font-weight: bold;");
551
+ console.groupEnd();
552
+
553
+ return {
554
+ script: SCRIPT,
555
+ status: "ok",
556
+ count: perSheet.length,
557
+ corsLimitedAnalysis: unreadable.length > 0,
558
+ details,
559
+ items: items.slice(0, MAX_ITEMS),
560
+ issues,
561
+ };
562
+ })();
@@ -198,6 +198,17 @@ Use this decision tree to automatically run follow-up snippets based on results:
198
198
  - **If no inline CSS but has blocking stylesheets** → Suggest critical CSS extraction
199
199
  - **If CSS > 50KB total** → Run **CSS-Media-Queries-Analysis.js** to find savings
200
200
 
201
+ ### After CSS-Complexity.js
202
+
203
+ - **If it ran in the user's browser** (Chrome DevTools MCP connected to their Chrome) → Extensions can inject CSS that the snippet counts: before reporting, check `items` for `<style>` or shadow root sources the page does not have, or measure again in a profile without extensions or with the CLI
204
+ - **If embedded-font** → Recommend serving the font as a WOFF2 file, and run **Webfont-Usage-Analyzer.js** to check its `font-display`
205
+ - **If embedded-content** → Run **Critical-CSS-Detection.js**: embedded bytes in render-blocking CSS delay the first paint
206
+ - **If import** → Recommend linking the imported stylesheet from the HTML or bundling it, and run **Find-render-blocking-resources.js** to see the chain
207
+ - **If font-face-count** → Run **Webfont-Usage-Analyzer.js** to find the faces that never load
208
+ - **If unused-keyframes** → Check that no script sets the animation before suggesting to remove it
209
+ - **If selector-complexity** → Treat it as maintainability: only a trace with CSS selector stats (Performance panel, CSS selector costs insight) shows whether matching it is slow
210
+ - **If corsLimitedAnalysis is true** → Report the counts as a lower bound, and suggest `crossorigin` on the `<link>` with `Access-Control-Allow-Origin` on stylesheets you control
211
+
201
212
  ### After Back-Forward-Cache.js
202
213
 
203
214
  - **If bfcache blocked** → Provide specific remediation based on blocking reasons
@@ -287,7 +287,7 @@ An interaction that paints nothing has no frame to be measured to, so the browse
287
287
  }
288
288
  ```
289
289
 
290
- Later interactions that short have no entry of any kind: they only count in `performance.interactionCount`, and INP does not depend on them. `tracking` after a click means that the document recorded no interaction at all, as with a click inside an iframe, which records its own; the CLI and the MCP server replace the `message` to say that a click or key press was sent.
290
+ Later interactions that short have no entry of any kind: they only count in `performance.interactionCount`, and INP does not depend on them. `tracking` after a click means that the document recorded no interaction at all, as with a click inside an iframe, which records its own; the CLI and the MCP server turn that result into `status: "error"`, with an `error` that says a click or key press was sent, so the CLI exits with code `1`.
291
291
 
292
292
  The phases describe the whole interaction, not one of its events. A tap fires `pointerdown`, `pointerup` and `click`, and a key press `keydown` and `keyup`, with one `interactionId`. INP, Interactions and Input-Latency-Breakdown group the entries by `interactionId`, also when they arrive in separate observer callbacks, and measure the interaction over its entries painted in the frame of the longest one: `inputDelay` runs from the first `startTime` to the first `processingStart`, `processingTime` to the last `processingEnd`, and `presentationDelay` from there to the paint (`startTime + duration`, never below zero). The interaction is reported under the event whose handler ran longest, or under the `click` or `keydown` when no handler took a millisecond more than theirs; `worstEvent`, the Interactions `type` and the Input-Latency-Breakdown event type follow that rule.
293
293
 
@@ -826,6 +826,49 @@ Issues keep `{ severity, message }`. The fix is the last sentence of the message
826
826
  }
827
827
  ```
828
828
 
829
+ #### CSS-Complexity
830
+
831
+ Reads the CSSOM of every stylesheet of the page: of the document, adopted, in open shadow roots and imported with `@import`. One item per stylesheet, the most embedded bytes first, with `read: "cssom"`; `read: "fetched"` for a stylesheet of another origin whose rules the page cannot read and `fetch` can, as with `Access-Control-Allow-Origin` and a `<link>` without `crossorigin`, parsed with the same CSSOM, its `@import` rules included; or `read: "unreadable"` and only its `source` when neither works, which sets `corsLimitedAnalysis: true`. `source` is the URL of the sheet, or a label such as `<style> #1` or `adopted stylesheet #1`. A stylesheet a browser extension links to the page (`chrome-extension:`, `moz-extension:`, `safari-web-extension:`) is left out and counted in `details.extensionCount`; the `<style>` elements and shadow roots an extension injects cannot be told apart from those of the page, so in a browser with extensions, as with Chrome DevTools MCP connected to the user's Chrome, the counts can include them.
832
+
833
+ `details` counts what the CSSOM keeps of the CSS as written, as [css-analyzer](https://github.com/projectwallace/css-analyzer) counts its text: rules (style rules, nested and inside at-rules), selectors (each one of a list), declarations (also those of `@font-face` and `@keyframes`), embedded content (`data:` URLs, in characters, and by MIME type), `@import` rules, empty rules, `!important` declarations, `@font-face` and `@keyframes` rules. `selectorUniqueness` is unique selectors over all selectors, without their context, so a selector repeated in two `@media` counts as repeated (1 without selectors). `unusedKeyframes` lists the `@keyframes` that no `animation` or `animation-name` of the stylesheets or of a `style` attribute names. `maxSelectorComplexity` and `commonSelectorComplexity` (the mode, or the mean of the tied values) give the complexity of the selectors as css-analyzer computes it, and `complexSelectors` the 5 most complex, as `{ selectorText, complexity }`: `selectorText` is the selector of a rule, not of an element of the page. The CSSOM drops comments, hacks, invalid declarations and rules with unsupported selectors, and writes a few shorthands as longhands, so these counts can differ slightly from those of the text.
834
+
835
+ ```json
836
+ {
837
+ "script": "CSS-Complexity",
838
+ "status": "ok",
839
+ "count": 2,
840
+ "corsLimitedAnalysis": false,
841
+ "details": {
842
+ "stylesheetCount": 2,
843
+ "extensionCount": 0,
844
+ "unreadableCount": 0,
845
+ "fetchedCount": 1,
846
+ "ruleCount": 812,
847
+ "selectorCount": 1104,
848
+ "declarationCount": 2210,
849
+ "embeddedBytes": 41210,
850
+ "embeddedCount": 12,
851
+ "embeddedTypes": [{ "type": "font/woff2", "count": 2, "bytes": 38090 }, { "type": "image/svg+xml", "count": 10, "bytes": 3120 }],
852
+ "importCount": 1,
853
+ "emptyRuleCount": 14,
854
+ "selectorUniqueness": 0.81,
855
+ "importantCount": 51,
856
+ "importantPercent": 2.3,
857
+ "fontFaceCount": 8,
858
+ "keyframesCount": 9,
859
+ "unusedKeyframes": ["spin-old"],
860
+ "maxSelectorComplexity": 18,
861
+ "commonSelectorComplexity": 1,
862
+ "complexSelectors": [{ "selectorText": ".nav > ul li:not(.active) a[href^=\"http\"]", "complexity": 11 }]
863
+ },
864
+ "items": [
865
+ { "source": "https://example.com/app.css", "read": "cssom", "ruleCount": 790, "embeddedBytes": 41210, "importCount": 1, "importantCount": 48 },
866
+ { "source": "https://cdn.example.com/vendor.css", "read": "fetched", "ruleCount": 22, "embeddedBytes": 0, "importCount": 0, "importantCount": 3 }
867
+ ],
868
+ "issues": [{ "code": "import", "severity": "warning", "message": "https://example.com/app.css imports https://cdn.example.com/vendor.css with @import: ...", "target": "https://cdn.example.com/vendor.css" }]
869
+ }
870
+ ```
871
+
829
872
  #### CSS-Media-Queries-Analysis
830
873
 
831
874
  One item per distinct media query that targets viewports wider than `details.minWidth`, largest first. `details` gives the totals, `totalUnique` and `uniquenessRatio`, and `bySource` breaks the rules down by stylesheet.
@@ -1265,6 +1308,21 @@ An issue about one thing among several, one issue per thing, also names it in `t
1265
1308
  | `could-defer` | info | Blocking scripts other than the last three of `<body>`, which could try `defer` |
1266
1309
  | `unknown-size` | info | Scripts whose size is hidden by a missing `Timing-Allow-Origin`, so the total is a lower bound |
1267
1310
 
1311
+ ### CSS-Complexity
1312
+
1313
+ | Code | Severity | When |
1314
+ |------|----------|------|
1315
+ | `embedded-font` | warning | A stylesheet embeds a font as a `data:` URL; `target` is the stylesheet |
1316
+ | `embedded-content` | warning | The `data:` URLs of all the CSS add up to more than 36.6 kB, the 90th percentile of homepages in The CSS Selection 2026 |
1317
+ | `import` | warning | An `@import` rule, one issue per rule; `target` is the URL it imports |
1318
+ | `empty-rules` | info | One or more style rules without declarations |
1319
+ | `duplicate-selectors` | info | Fewer than 66% of the selectors are unique, the threshold of css-code-quality |
1320
+ | `important-ratio` | info | More than 13% of the declarations use `!important`, the 90th percentile of homepages |
1321
+ | `font-face-count` | info | More than 48 `@font-face` rules, the 90th percentile of homepages |
1322
+ | `unused-keyframes` | info | A `@keyframes` that no animation of the stylesheets or of a `style` attribute uses, only when every stylesheet could be read; `target` is its name |
1323
+ | `selector-complexity` | info | A selector among the 5 most complex with a complexity above 29, the 90th percentile of the most complex selector of homepages; `target` is the selector of the rule |
1324
+ | `unreadable-stylesheets` | info | Cross-origin stylesheets whose rules the page cannot read and `fetch` cannot either |
1325
+
1268
1326
  ### CSS-Media-Queries-Analysis
1269
1327
 
1270
1328
  | Code | Severity | When |
package/src/bin.js CHANGED
@@ -40,7 +40,7 @@ Options:
40
40
  --workflow <name> Workflow to run (default: core-web-vitals)
41
41
  Workflows: core-web-vitals, audit, loading, interaction
42
42
  --snippet <name> Run a single snippet by name, alias or Category/Name path
43
- Any of the 56 snippets, such as Compression-Audit
43
+ Any of the 57 snippets, such as Compression-Audit
44
44
  Aliases: LCP, CLS, INP, LCP-Subparts, fonts,
45
45
  render-blocking, resource-hints, preload-scripts,
46
46
  priority-hints, critical-css, ttfb,
package/src/mcp/server.js CHANGED
@@ -664,7 +664,7 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
664
664
  {
665
665
  title: "Read the tracking snippets",
666
666
  description:
667
- `Returns what the tracking snippets installed by open_page have recorded on the page so far, as the objects they return. A snippet with nothing recorded yet, such as INP before any interaction, answers with status tracking and a message: interact with the page and read again. ${DETAIL_NOTE} ${PAGE_DATA}`,
667
+ `Returns what the tracking snippets installed by open_page have recorded on the page so far, as the objects they return. A snippet with nothing recorded yet, such as INP before any interaction, answers with status tracking and a message: interact with the page and read again. After a click or key press that the document did not record, as one inside an iframe, it is an error that says so: interact with an element of the page and read again. ${DETAIL_NOTE} ${PAGE_DATA}`,
668
668
  inputSchema: z.object({ sessionId: SessionId, detail: Detail }),
669
669
  outputSchema: z.object({ sessionId: z.string(), runId: SummaryRunId, detail: DetailOut, pageErrors: z.array(z.string()), results: z.array(SnippetResult) }),
670
670
  annotations: { readOnlyHint: true, openWorldHint: false },
package/src/runner.js CHANGED
@@ -22,8 +22,9 @@ const LATE_ENTRIES_MS = 2000;
22
22
  const POLL_MS = 100;
23
23
 
24
24
  // A snippet still tracking after that wait has seen no interaction at all, and the browser agrees: a
25
- // click that paints nothing is recorded too. The message of the snippet (click on the page, then call
26
- // the function again) would ask for what the run has just done.
25
+ // click that paints nothing is recorded too. The measurement failed, so the result is an error, which
26
+ // exits with 1, instead of the message of the snippet (click on the page, then call the function
27
+ // again), which would ask for what the run has just done.
27
28
  const NOTHING_RECORDED =
28
29
  "A click or key press was sent, and this document recorded no interaction. Check that it reaches an element of this document: an interaction inside an iframe is recorded by the iframe.";
29
30
 
@@ -90,8 +91,10 @@ export async function collectTrackingData(page, result, hasInput) {
90
91
  data = await read();
91
92
  }
92
93
  if (data && typeof data === "object") {
93
- const sentAndMissed = hasInput && data.status === "tracking";
94
- return { id: result.id, ...data, ...(sentAndMissed && { message: NOTHING_RECORDED }) };
94
+ if (hasInput && data.status === "tracking") {
95
+ return { id: result.id, script: data.script, status: "error", error: NOTHING_RECORDED };
96
+ }
97
+ return { id: result.id, ...data };
95
98
  }
96
99
  return { id: result.id, status: "error", error: `${result.getDataFn}() did not return an object` };
97
100
  } catch (err) {