diff --git a/.claude/skills/SCHEMA.md b/.claude/skills/SCHEMA.md
deleted file mode 100644
index 7328e5e..0000000
--- a/.claude/skills/SCHEMA.md
+++ /dev/null
@@ -1,643 +0,0 @@
-# Script Return Value Schema
-
-All scripts in the skills directory must return a structured JSON object as the IIFE return value. This allows agents using `mcp__chrome-devtools__evaluate_script` to read structured data directly from the return value, rather than parsing human-readable console output.
-
-## Why this matters
-
-`evaluate_script` captures **both** the console output **and** the return value of the evaluated expression. Console output (with `%c` CSS styling, emojis, tables) is meant for humans reading DevTools. The return value is meant for agents.
-
-```
-// Agent workflow
-result = evaluate_script(scriptCode) // return value → structured JSON for agent
-get_console_message() // console output → human debugging only
-```
-
----
-
-## Base Shape
-
-Every script must return an object matching this shape:
-
-```typescript
-{
- // Required in all scripts
- script: string; // Script name, e.g. "LCP", "TTFB", "Script-Loading"
- status: "ok" // Script ran, has data
- | "tracking" // Observer active, data accumulates over time
- | "error" // Failed or no data available
- | "unsupported"; // Browser API not supported
-
- // Metric scripts (LCP, CLS, INP, TTFB, FCP)
- metric?: string; // Short metric name: "LCP", "CLS", "INP", "TTFB", "FCP"
- value?: number; // Always a number, never a formatted string
- unit?: "ms" // Milliseconds
- | "score" // Unitless score (CLS)
- | "count" // Integer count
- | "bytes" // Raw bytes
- | "bpp" // Bits per pixel
- | "fps"; // Frames per second
- rating?: "good" | "needs-improvement" | "poor";
- thresholds?: {
- good: number; // Upper bound for "good"
- needsImprovement: number; // Upper bound for "needs-improvement"
- };
-
- // Audit/inspection scripts (render-blocking, images, scripts)
- count?: number; // Total number of items found
- items?: object[]; // Array of individual findings
-
- // Script-specific structured data
- details?: object;
-
- // Issues detected (for audit scripts)
- issues?: Array<{
- severity: "error" | "warning" | "info";
- message: string;
- }>;
-
- // Tracking scripts (status: "tracking")
- message?: string; // Human-readable status message
- getDataFn?: string; // window function name to call for data: evaluate_script(`${getDataFn}()`)
-
- // Error info (status: "error" or "unsupported")
- error?: string;
-}
-```
-
----
-
-## Execution Patterns
-
-### Pattern 1: Fully synchronous
-
-Scripts that read DOM or `performance.getEntriesByType()` directly. Return JSON at the end of the IIFE.
-
-```js
-// Example: TTFB.js
-(() => {
- const [nav] = performance.getEntriesByType("navigation");
- if (!nav) return { script: "TTFB", status: "error", error: "No navigation entry" };
-
- const value = Math.round(nav.responseStart);
- const rating = value <= 800 ? "good" : value <= 1800 ? "needs-improvement" : "poor";
-
- // Human output
- console.log(`TTFB: ${value}ms (${rating})`);
-
- // Agent output
- return { script: "TTFB", status: "ok", metric: "TTFB", value, unit: "ms", rating,
- thresholds: { good: 800, needsImprovement: 1800 } };
-})();
-```
-
-**Scripts using this pattern:** TTFB, TTFB-Sub-Parts, FCP, Find-render-blocking-resources, Script-Loading, LCP-Video-Candidate, Resource-Hints, Resource-Hints-Validation, Priority-Hints-Audit, Validate-Preload-Async-Defer-Scripts, Fonts-Preloaded, Service-Worker-Analysis, Back-Forward-Cache, Content-Visibility, Critical-CSS-Detection, Inline-CSS-Info-and-Size, Inline-Script-Info-and-Size, First-And-Third-Party-Script-Info, First-And-Third-Party-Script-Timings, JS-Execution-Time-Breakdown, CSS-Media-Queries-Analysis, Client-Side-Redirect-Detection, SSR-Hydration-Data-Analysis, Network-Bandwidth-Connection-Quality, Find-Above-The-Fold-Lazy-Loaded-Images, Find-Images-With-Lazy-and-Fetchpriority, Find-non-Lazy-Loaded-Images-outside-of-the-viewport, SVG-Embedded-Bitmap-Analysis, Prefetch-Resource-Validation, TTFB-Resources.
-
-### Pattern 2: PerformanceObserver → getEntriesByType
-
-Scripts using `PerformanceObserver` with `buffered: true` can read the same data synchronously via `performance.getEntriesByType()`. The observer stays for human console display; the return value is computed synchronously.
-
-```js
-// Example: LCP.js
-(() => {
- // Synchronous data for agent (computed at top)
- const entries = performance.getEntriesByType("largest-contentful-paint");
- const lastEntry = entries.at(-1);
- if (!lastEntry) {
- // Still set up the observer for human display
- // observer.observe(...)
- return { script: "LCP", status: "error", error: "No LCP entries yet" };
- }
-
- const activationStart = performance.getEntriesByType("navigation")[0]?.activationStart ?? 0;
- const value = Math.round(Math.max(0, lastEntry.startTime - activationStart));
- const rating = value <= 2500 ? "good" : value <= 4000 ? "needs-improvement" : "poor";
-
- // Human output via PerformanceObserver (unchanged)
- const observer = new PerformanceObserver(...);
- observer.observe({ type: "largest-contentful-paint", buffered: true });
-
- // Agent return value
- return {
- script: "LCP", status: "ok", metric: "LCP", value, unit: "ms", rating,
- thresholds: { good: 2500, needsImprovement: 4000 },
- details: { element: selector, elementType: type, url: lastEntry.url, sizePixels: lastEntry.size }
- };
-})();
-```
-
-**Scripts using this pattern:** LCP, CLS, LCP-Sub-Parts, LCP-Trail, LCP-Image-Entropy, Event-Processing-Time, Long-Animation-Frames (buffered LoAFs), LongTask (buffered tasks).
-
-### Pattern 3: Tracking observers
-
-Scripts that observe ongoing user interactions cannot return meaningful data synchronously. They return `status: "tracking"` immediately, and expose a `window.getXxx()` function for agents to call later.
-
-```js
-// Return at the end of the IIFE:
-return {
- script: "INP",
- status: "tracking",
- message: "INP tracking active. Interact with the page then call getINP() for results.",
- getDataFn: "getINP"
-};
-```
-
-**Agent workflow for tracking scripts:**
-```
-1. evaluate_script(INP.js) → { status: "tracking", getDataFn: "getINP" }
-2. (user interacts with the page)
-3. evaluate_script("getINP()") → { script: "INP", status: "ok", value: 350, rating: "needs-improvement", ... }
-```
-
-**The window function must also return a structured object** matching the same schema.
-
-**Scripts using this pattern:** INP, Interactions, Input-Latency-Breakdown, Layout-Shift-Loading-and-Interaction, Scroll-Performance, Long-Animation-Frames (ongoing tracking), LongTask (ongoing tracking), Long-Animation-Frames-Script-Attribution.
-
-### Pattern 4: Async scripts
-
-Scripts that use `async/await` or `setTimeout`. The IIFE returns a Promise, which `evaluate_script` can await (Chrome DevTools `awaitPromise`).
-
-Keep the existing `async () => {}` wrapper. Add a `return` statement with structured data at the end. The agent receives the resolved value.
-
-**Scripts using this pattern:** Image-Element-Audit (fetches content-type headers), Video-Element-Audit, Long-Animation-Frames-Script-Attribution (should be converted to return buffered data immediately instead of waiting 10s).
-
----
-
-## Script-Specific Schemas
-
-### Core Web Vitals
-
-#### LCP
-```json
-{
- "script": "LCP",
- "status": "ok",
- "metric": "LCP",
- "value": 1240,
- "unit": "ms",
- "rating": "good",
- "thresholds": { "good": 2500, "needsImprovement": 4000 },
- "details": {
- "element": "img.hero",
- "elementType": "Image",
- "url": "https://example.com/hero.jpg",
- "sizePixels": 756000
- }
-}
-```
-
-#### CLS
-```json
-{
- "script": "CLS",
- "status": "ok",
- "metric": "CLS",
- "value": 0.05,
- "unit": "score",
- "rating": "good",
- "thresholds": { "good": 0.1, "needsImprovement": 0.25 }
-}
-```
-
-#### INP (tracking)
-```json
-{
- "script": "INP",
- "status": "tracking",
- "message": "INP tracking active. Interact with the page then call getINP() for results.",
- "getDataFn": "getINP"
-}
-```
-`getINP()` returns:
-```json
-{
- "script": "INP",
- "status": "ok",
- "metric": "INP",
- "value": 350,
- "unit": "ms",
- "rating": "needs-improvement",
- "thresholds": { "good": 200, "needsImprovement": 500 },
- "details": {
- "totalInteractions": 5,
- "worstEvent": "click -> button.submit",
- "phases": { "inputDelay": 120, "processingTime": 180, "presentationDelay": 50 }
- }
-}
-```
-
-#### LCP-Sub-Parts
-```json
-{
- "script": "LCP-Sub-Parts",
- "status": "ok",
- "metric": "LCP",
- "value": 2100,
- "unit": "ms",
- "rating": "needs-improvement",
- "thresholds": { "good": 2500, "needsImprovement": 4000 },
- "details": {
- "element": "img.hero",
- "url": "hero.jpg",
- "subParts": {
- "ttfb": { "value": 450, "percent": 21, "overTarget": false },
- "resourceLoadDelay": { "value": 120, "percent": 6, "overTarget": false },
- "resourceLoadTime": { "value": 1200, "percent": 57, "overTarget": true },
- "elementRenderDelay": { "value": 330, "percent": 16, "overTarget": true }
- },
- "slowestPhase": "resourceLoadTime"
- }
-}
-```
-
-#### LCP-Trail
-```json
-{
- "script": "LCP-Trail",
- "status": "ok",
- "metric": "LCP",
- "value": 1240,
- "unit": "ms",
- "rating": "good",
- "thresholds": { "good": 2500, "needsImprovement": 4000 },
- "details": {
- "candidateCount": 2,
- "finalElement": "img.hero",
- "candidates": [
- { "index": 1, "selector": "h1", "time": 800, "elementType": "Text block" },
- { "index": 2, "selector": "img.hero", "time": 1240, "elementType": "Image", "url": "hero.jpg" }
- ]
- }
-}
-```
-
-#### LCP-Image-Entropy
-```json
-{
- "script": "LCP-Image-Entropy",
- "status": "ok",
- "count": 5,
- "details": {
- "totalImages": 5,
- "lowEntropyCount": 1,
- "lcpImageEligible": true,
- "lcpImage": {
- "url": "hero.jpg",
- "bpp": 1.65,
- "isLowEntropy": false
- }
- },
- "items": [
- { "url": "hero.jpg", "width": 1200, "height": 630, "fileSizeBytes": 156000, "bpp": 1.65, "isLowEntropy": false, "lcpEligible": true, "isLCP": true }
- ],
- "issues": []
-}
-```
-
-#### LCP-Video-Candidate
-```json
-{
- "script": "LCP-Video-Candidate",
- "status": "ok",
- "metric": "LCP",
- "value": 1800,
- "unit": "ms",
- "rating": "good",
- "thresholds": { "good": 2500, "needsImprovement": 4000 },
- "details": {
- "isVideo": true,
- "posterUrl": "https://example.com/hero.avif",
- "posterFormat": "avif",
- "posterPreloaded": true,
- "fetchpriorityOnPreload": "high",
- "isCrossOrigin": false,
- "videoAttributes": { "autoplay": true, "muted": true, "playsinline": true, "preload": "auto" }
- },
- "issues": []
-}
-```
-
-### Loading
-
-#### TTFB
-```json
-{
- "script": "TTFB",
- "status": "ok",
- "metric": "TTFB",
- "value": 245,
- "unit": "ms",
- "rating": "good",
- "thresholds": { "good": 800, "needsImprovement": 1800 }
-}
-```
-
-#### TTFB-Sub-Parts
-```json
-{
- "script": "TTFB-Sub-Parts",
- "status": "ok",
- "metric": "TTFB",
- "value": 245,
- "unit": "ms",
- "rating": "good",
- "thresholds": { "good": 800, "needsImprovement": 1800 },
- "details": {
- "subParts": {
- "redirectWait": { "value": 0, "unit": "ms" },
- "serviceWorkerCache": { "value": 0, "unit": "ms" },
- "dnsLookup": { "value": 5, "unit": "ms" },
- "tcpConnection": { "value": 30, "unit": "ms" },
- "sslTls": { "value": 45, "unit": "ms" },
- "serverResponse": { "value": 165, "unit": "ms" }
- },
- "slowestPhase": "serverResponse"
- }
-}
-```
-
-#### Find-render-blocking-resources
-```json
-{
- "script": "Find-render-blocking-resources",
- "status": "ok",
- "count": 3,
- "details": {
- "totalBlockingUntilMs": 450,
- "totalSizeBytes": 135000,
- "byType": { "link": 2, "script": 1 }
- },
- "items": [
- { "type": "link", "url": "https://example.com/style.css", "shortName": "style.css", "responseEndMs": 450, "durationMs": 200, "sizeBytes": 45000 }
- ]
-}
-```
-
-#### Script-Loading
-```json
-{
- "script": "Script-Loading",
- "status": "ok",
- "count": 8,
- "rating": "needs-improvement",
- "details": {
- "totalSizeBytes": 245000,
- "byStrategy": { "blocking": 2, "async": 4, "defer": 1, "module": 1 },
- "byParty": { "firstParty": 5, "thirdParty": 3 },
- "thirdPartyBlockingCount": 1
- },
- "items": [
- { "url": "https://example.com/app.js", "shortName": "app.js", "strategy": "blocking", "location": "head", "party": "first", "sizeBytes": 85000, "durationMs": 120 }
- ],
- "issues": [
- { "severity": "error", "message": "2 blocking scripts in
" },
- { "severity": "error", "message": "1 third-party blocking script" }
- ]
-}
-```
-
-### Interaction
-
-#### Interactions (tracking)
-```json
-{
- "script": "Interactions",
- "status": "tracking",
- "message": "Tracking interactions. Interact with the page then call getInteractionSummary() for results.",
- "getDataFn": "getInteractionSummary"
-}
-```
-
-#### Input-Latency-Breakdown (tracking)
-```json
-{
- "script": "Input-Latency-Breakdown",
- "status": "tracking",
- "message": "Tracking input latency by event type. Interact with the page then call getInputLatencyBreakdown().",
- "getDataFn": "getInputLatencyBreakdown"
-}
-```
-
-#### Layout-Shift-Loading-and-Interaction
-Immediately returns buffered CLS data, plus exposes summary function for ongoing tracking.
-```json
-{
- "script": "Layout-Shift-Loading-and-Interaction",
- "status": "tracking",
- "metric": "CLS",
- "value": 0.08,
- "unit": "score",
- "rating": "good",
- "thresholds": { "good": 0.1, "needsImprovement": 0.25 },
- "details": {
- "currentCLS": 0.08,
- "shiftCount": 3,
- "countedShifts": 3,
- "excludedShifts": 0
- },
- "message": "Layout shift tracking active. Call getLayoutShiftSummary() for full element attribution.",
- "getDataFn": "getLayoutShiftSummary"
-}
-```
-
-#### Long-Animation-Frames
-Returns buffered LoAF data immediately. Ongoing tracking continues.
-```json
-{
- "script": "Long-Animation-Frames",
- "status": "tracking",
- "count": 3,
- "details": {
- "totalLoAFs": 3,
- "withBlockingTime": 2,
- "totalBlockingTimeMs": 280,
- "worstBlockingMs": 180
- },
- "message": "Tracking long animation frames. Call getLoAFSummary() for full script attribution.",
- "getDataFn": "getLoAFSummary"
-}
-```
-
-#### Long-Animation-Frames-Script-Attribution
-Returns buffered LoAF data immediately (do not wait for a timer):
-```json
-{
- "script": "Long-Animation-Frames-Script-Attribution",
- "status": "ok",
- "details": {
- "frameCount": 5,
- "totalBlockingMs": 420,
- "byCategory": {
- "first-party": { "durationMs": 180, "count": 3 },
- "third-party": { "durationMs": 210, "count": 2 },
- "framework": { "durationMs": 30, "count": 1 }
- }
- },
- "items": [
- { "file": "app.js", "category": "first-party", "durationMs": 180, "count": 3 }
- ]
-}
-```
-
-#### Scroll-Performance (tracking)
-```json
-{
- "script": "Scroll-Performance",
- "status": "tracking",
- "details": {
- "nonPassiveListeners": 2,
- "cssAudit": {
- "smoothScrollElements": 1,
- "willChangeElements": 0,
- "contentVisibilityElements": 3
- }
- },
- "message": "Scroll performance tracking active. Scroll the page then call getScrollSummary() for FPS data.",
- "getDataFn": "getScrollSummary"
-}
-```
-
-#### LongTask
-Returns buffered long tasks immediately. Ongoing tracking continues.
-```json
-{
- "script": "LongTask",
- "status": "tracking",
- "count": 4,
- "details": {
- "totalBlockingTimeMs": 380,
- "worstTaskMs": 220,
- "bySeverity": { "critical": 1, "high": 1, "medium": 2, "low": 0 }
- },
- "message": "Tracking long tasks. Call getLongTaskSummary() for statistics.",
- "getDataFn": "getLongTaskSummary"
-}
-```
-
-### Media
-
-#### Image-Element-Audit (async)
-```json
-{
- "script": "Image-Element-Audit",
- "status": "ok",
- "count": 8,
- "details": {
- "totalImages": 8,
- "inViewport": 3,
- "offViewport": 5,
- "totalErrors": 2,
- "totalWarnings": 3,
- "totalInfos": 1,
- "lcpCandidate": {
- "selector": "img.hero",
- "format": "avif",
- "fetchpriority": "high",
- "loading": "(not set)",
- "preloaded": true
- }
- },
- "items": [
- {
- "selector": "img.hero",
- "url": "hero.avif",
- "format": "avif",
- "inViewport": true,
- "isLCP": true,
- "loading": "(not set)",
- "decoding": "sync",
- "fetchpriority": "high",
- "hasDimensions": true,
- "hasSrcset": false,
- "hasSizes": false,
- "inPicture": false,
- "issues": []
- }
- ],
- "issues": [
- { "severity": "warning", "message": "img.thumbnail: Missing width/height attributes (CLS risk)" }
- ]
-}
-```
-
-#### Video-Element-Audit
-Same shape as Image-Element-Audit but for video elements.
-
-#### SVG-Embedded-Bitmap-Analysis
-```json
-{
- "script": "SVG-Embedded-Bitmap-Analysis",
- "status": "ok",
- "count": 2,
- "items": [
- { "url": "icon.svg", "hasBitmap": true, "bitmapType": "image/png", "sizeBytes": 4500 }
- ],
- "issues": [
- { "severity": "warning", "message": "2 SVG files contain embedded bitmaps" }
- ]
-}
-```
-
-### Resources
-
-#### Network-Bandwidth-Connection-Quality
-```json
-{
- "script": "Network-Bandwidth-Connection-Quality",
- "status": "ok",
- "details": {
- "effectiveType": "4g",
- "downlink": 10,
- "rtt": 50,
- "saveData": false
- }
-}
-```
-
----
-
-## Guidelines for Agents
-
-### Reading results
-
-```
-// Prefer return value over console output
-result = evaluate_script(scriptCode)
-if result.status == "ok" → use result.value, result.rating, result.details, result.items
-if result.status == "tracking" → call evaluate_script(`${result.getDataFn}()`) after user interaction
-if result.status == "error" → check result.error, the browser may not have loaded the page yet
-if result.status == "unsupported" → browser does not support the required API (check: Chrome 107+?)
-```
-
-### Tracking scripts workflow
-
-```
-// 1. Start tracking
-result = evaluate_script(INP_js)
-// result = { status: "tracking", getDataFn: "getINP" }
-
-// 2. Wait for/trigger user interactions
-
-// 3. Collect data
-data = evaluate_script("getINP()")
-// data = { status: "ok", value: 350, rating: "needs-improvement", ... }
-```
-
-### Making decisions from return values
-
-- `rating === "good"` → no action needed for this metric
-- `rating === "needs-improvement"` → investigate, check `details` and `issues`
-- `rating === "poor"` → high priority fix, check `issues` for specific problems
-- `count > 0` and `issues.length > 0` → audit found actionable problems
-- `count === 0` → nothing to audit (no render-blocking resources, no images, etc.)
-
----
-
-## Implementation Rules
-
-1. **Numbers are numbers** — never `"245ms"`, always `245`. The agent formats as needed.
-2. **Consistent field names** — `value` for the metric, `unit` for its unit, `rating` for the threshold assessment.
-3. **Issues are actionable** — each issue message describes what to fix, not what was found.
-4. **Items are homogeneous** — all objects in `items[]` have the same fields.
-5. **No DOM references in return value** — elements can't be serialized to JSON.
-6. **Keep console output unchanged** — the return value is additive, not a replacement.
-7. **Window functions match the schema** — `getINP()`, `getLoAFSummary()`, etc. return the same structured shape.
diff --git a/.claude/skills/webperf-core-web-vitals/SKILL.md b/.claude/skills/webperf-core-web-vitals/SKILL.md
index 71d25fc..dfb535e 100644
--- a/.claude/skills/webperf-core-web-vitals/SKILL.md
+++ b/.claude/skills/webperf-core-web-vitals/SKILL.md
@@ -25,7 +25,6 @@ JavaScript snippets for measuring web performance in Chrome DevTools. Execute wi
- `scripts/LCP-Video-Candidate.js` — LCP Video Candidate
- `scripts/LCP.js` — Largest Contentful Paint (LCP)
-Descriptions and thresholds: `references/snippets.md`
## Common Workflows
@@ -54,7 +53,7 @@ When LCP is slow or the user asks "debug LCP" or "why is LCP slow":
When layout shifts are detected or the user asks "debug CLS" or "layout shift issues":
1. **CLS.js** - Measure overall CLS score
-2. **Layout-Shift-Loading-and-Interaction.js** (from Interaction skill) - Separate loading vs interaction shifts
+2. **Layout-Shift-Loading-and-Interaction.js** _(pending — available in webperf-interaction skill)_
3. Cross-reference with **webperf-loading** skill:
- Find-Above-The-Fold-Lazy-Loaded-Images.js (lazy images causing shifts)
- Fonts-Preloaded-Loaded-and-used-above-the-fold.js (font swap causing shifts)
@@ -63,11 +62,11 @@ When layout shifts are detected or the user asks "debug CLS" or "layout shift is
When interactions feel slow or the user asks "debug INP" or "slow interactions":
-1. **INP.js** - Measure overall INP value
-2. **Interactions.js** (from Interaction skill) - List all interactions with timing
-3. **Input-Latency-Breakdown.js** (from Interaction skill) - Break down input delay, processing, presentation
-4. **Long-Animation-Frames.js** (from Interaction skill) - Identify blocking animation frames
-5. **Long-Animation-Frames-Script-Attribution.js** (from Interaction skill) - Find scripts causing delays
+1. **INP.js** - Measure overall INP value; call `getINP()` after interactions, `getINPDetails()` for full list
+2. **Interactions.js** _(pending — available in webperf-interaction skill)_
+3. **Input-Latency-Breakdown.js** _(pending — available in webperf-interaction skill)_
+4. **Long-Animation-Frames.js** _(pending — available in webperf-interaction skill)_
+5. **Long-Animation-Frames-Script-Attribution.js** _(pending — available in webperf-interaction skill)_
### Video as LCP Investigation
@@ -146,7 +145,7 @@ Use this decision tree to automatically run follow-up snippets based on results:
### After CLS.js
-- **If CLS > 0.1** → Run **webperf-interaction:Layout-Shift-Loading-and-Interaction.js** to separate causes
+- **If CLS > 0.1** → Run **webperf-interaction:Layout-Shift-Loading-and-Interaction.js** _(pending — available in webperf-interaction skill)_
- **If CLS > 0.25 (poor)** → Run comprehensive shift investigation:
1. **webperf-loading:Find-Above-The-Fold-Lazy-Loaded-Images.js** (images without dimensions)
2. **webperf-loading:Fonts-Preloaded-Loaded-and-used-above-the-fold.js** (font loading strategy)
@@ -163,9 +162,20 @@ Use this decision tree to automatically run follow-up snippets based on results:
3. **webperf-interaction:Long-Animation-Frames.js** (blocking frames)
4. **webperf-interaction:Long-Animation-Frames-Script-Attribution.js** (culprit scripts)
- **If specific interaction type is slow (e.g., keyboard)** → Focus analysis on that interaction type
+- **After INP data is collected** → call `getINPDetails()` for the full sorted interaction list (useful for identifying patterns across multiple slow interactions)
+
+### Error Recovery
+
+- **If any script returns `status: "error"`** → Check if the page has finished loading:
+ - If early in load: wait and re-run the script
+ - If page is an SPA: user may need to navigate to the target route first
+- **If LCP.js / LCP-Sub-Parts.js returns `status: "error"`** → Tell the user: "LCP data is not available yet. Please ensure the page has fully loaded, then run the analysis again."
+- **If INP.js `getINP()` returns `status: "error"`** → The `getDataFn: "getINP"` field signals the agent can retry after user interaction. Prompt the user to click, type, or scroll, then call `getINP()` again.
### Cross-Skill Triggers
+> **Context fork note:** This skill runs with `context: fork`. Cross-skill triggers below are **recommendations to report back to the parent agent**, not direct calls this subagent can execute. When a cross-skill trigger fires, tell the user which skill and script to run next. Scripts marked _(pending)_ are not yet available — skip them and note the limitation.
+
These triggers recommend using snippets from other skills:
#### From LCP to Loading Skill
@@ -223,6 +233,4 @@ When multiple CWV metrics are poor, prioritize investigation:
## References
- `references/snippets.md` — Descriptions and thresholds for each script
-- `references/schema.md` — Return value schema for interpreting script output
-
-> Execute via `mcp__chrome-devtools__evaluate_script` → read with `mcp__chrome-devtools__get_console_message`.
\ No newline at end of file
+- `references/schema.md` — Return value schema for interpreting script output
\ No newline at end of file
diff --git a/.claude/skills/webperf-core-web-vitals/references/schema.md b/.claude/skills/webperf-core-web-vitals/references/schema.md
index 7328e5e..f7e8b16 100644
--- a/.claude/skills/webperf-core-web-vitals/references/schema.md
+++ b/.claude/skills/webperf-core-web-vitals/references/schema.md
@@ -186,6 +186,7 @@ Keep the existing `async () => {}` wrapper. Add a `return` statement with struct
```
#### CLS
+Returns buffered CLS immediately and keeps tracking. Always call `getCLS()` after interactions to get an updated value.
```json
{
"script": "CLS",
@@ -194,9 +195,12 @@ Keep the existing `async () => {}` wrapper. Add a `return` statement with struct
"value": 0.05,
"unit": "score",
"rating": "good",
- "thresholds": { "good": 0.1, "needsImprovement": 0.25 }
+ "thresholds": { "good": 0.1, "needsImprovement": 0.25 },
+ "message": "CLS tracking active. Call getCLS() for updated value after page interactions.",
+ "getDataFn": "getCLS"
}
```
+`getCLS()` returns the same shape with the latest accumulated value.
#### INP (tracking)
```json
@@ -224,6 +228,15 @@ Keep the existing `async () => {}` wrapper. Add a `return` statement with struct
}
}
```
+If no interactions yet, `getINP()` returns `status: "error"` with `getDataFn: "getINP"` — retry after user interaction.
+
+`getINPDetails()` returns the full sorted interaction list (array of up to 15 entries). Use when `getINP()` shows poor INP and you need to identify patterns across multiple slow interactions:
+```json
+[
+ { "formattedName": "click → button.submit", "duration": 450, "startTime": 1200,
+ "phases": { "inputDelay": 120, "processingTime": 280, "presentationDelay": 50 } }
+]
+```
#### LCP-Sub-Parts
```json
diff --git a/.claude/skills/webperf-core-web-vitals/scripts/CLS.js b/.claude/skills/webperf-core-web-vitals/scripts/CLS.js
index 66ee0ba..5bc9e12 100644
--- a/.claude/skills/webperf-core-web-vitals/scripts/CLS.js
+++ b/.claude/skills/webperf-core-web-vitals/scripts/CLS.js
@@ -1,2 +1,2 @@
-// snippets/CoreWebVitals/CLS.js | sha256:489073431f0be946 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/CoreWebVitals/CLS.js
+// snippets/CoreWebVitals/CLS.js | sha256:7ab8f5d4f643a861 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/CoreWebVitals/CLS.js
(()=>{let e=0;const t=e=>e<=.1?"good":e<=.25?"needs-improvement":"poor",n=()=>{t(e)},o=new PerformanceObserver(t=>{for(const n of t.getEntries())n.hadRecentInput||(e+=n.value);n()});o.observe({type:"layout-shift",buffered:!0}),document.addEventListener("visibilitychange",()=>{"hidden"===document.visibilityState&&(o.takeRecords(),n())}),window.getCLS=()=>{n();const o=t(e);return{script:"CLS",status:"ok",metric:"CLS",value:Math.round(1e4*e)/1e4,unit:"score",rating:o,thresholds:{good:.1,needsImprovement:.25}}};const r=performance.getEntriesByType("layout-shift").reduce((e,t)=>t.hadRecentInput?e:e+t.value,0);t(r);Math.round(1e4*r)})();
\ No newline at end of file
diff --git a/.claude/skills/webperf-core-web-vitals/scripts/INP.js b/.claude/skills/webperf-core-web-vitals/scripts/INP.js
index 2f5af59..dbe6bab 100644
--- a/.claude/skills/webperf-core-web-vitals/scripts/INP.js
+++ b/.claude/skills/webperf-core-web-vitals/scripts/INP.js
@@ -1,2 +1,2 @@
-// snippets/CoreWebVitals/INP.js | sha256:7d7935e7d6d4cabd | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/CoreWebVitals/INP.js
-(()=>{const t=[];let e=0,n=null;const r=t=>t<=200?"good":t<=500?"needs-improvement":"poor",s=()=>{if(0===t.length)return{value:0,entry:null};const e=[...t].sort((t,e)=>e.duration-t.duration),n=t.length<50?0:Math.floor(.02*t.length);return{value:e[n].duration,entry:e[n]}},a=t=>{const e=t.target;if(!e)return t.name;let n=e.tagName.toLowerCase();if(e.id)n+=`#${e.id}`;else if(e.className&&"string"==typeof e.className){const t=e.className.trim().split(/\s+/).slice(0,2).join(".");t&&(n+=`.${t}`)}return`${t.name} → ${n}`},i=t=>{const e={inputDelay:0,processingTime:0,presentationDelay:0};return t.processingStart&&t.processingEnd&&(e.inputDelay=t.processingStart-t.startTime,e.processingTime=t.processingEnd-t.processingStart,e.presentationDelay=t.duration-e.inputDelay-e.processingTime),e},o=new PerformanceObserver(r=>{for(const o of r.getEntries()){if(!o.interactionId)continue;const r=t.find(t=>t.interactionId===o.interactionId);if(!r||o.duration>r.duration){if(r){const e=t.indexOf(r);t.splice(e,1)}t.push({name:o.name,duration:o.duration,startTime:o.startTime,interactionId:o.interactionId,target:o.target,processingStart:o.processingStart,processingEnd:o.processingEnd,formattedName:a(o),phases:i(o),entry:o})}const c=s();e=c.value,n=c.entry}});o.observe({type:"event",buffered:!0,durationThreshold:16});const c=()=>{r(e);if(n){n.target&&(t=>{if(!t)return"";const e=[];let n=t;for(;n&&n!==document.body&&e.length<5;){let t=n.tagName.toLowerCase();if(n.id)t+=`#${n.id}`;else if(n.className&&"string"==typeof n.className){const e=n.className.trim().split(/\s+/).slice(0,2).join(".");e&&(t+=`.${e}`)}e.unshift(t),n=n.parentElement}e.join(" > ")})(n.target);const t=n.phases;if(t.inputDelay>0){const e=n.duration,r=40;"▓".repeat(Math.round(t.inputDelay/e*r)),"█".repeat(Math.round(t.processingTime/e*r)),"░".repeat(Math.round(t.presentationDelay/e*r))}}const s=t.filter(t=>t.duration>200).sort((t,e)=>e.duration-t.duration).slice(0,10);s.length>0&&s.slice(0,3).forEach((t,e)=>{});const a={};t.forEach(t=>{const e=t.name;a[e]||(a[e]={count:0,totalDuration:0,maxDuration:0}),a[e].count++,a[e].totalDuration+=t.duration,a[e].maxDuration=Math.max(a[e].maxDuration,t.duration)}),Object.keys(a)};window.getINP=()=>{const a=s();e=a.value,n=a.entry,c();const i=r(e),o={totalInteractions:t.length};return n&&(o.worstEvent=n.formattedName,o.phases={inputDelay:Math.round(n.phases.inputDelay),processingTime:Math.round(n.phases.processingTime),presentationDelay:Math.round(n.phases.presentationDelay)}),0===t.length?{script:"INP",status:"error",error:"No interactions recorded yet",metric:"INP",value:0,unit:"ms",rating:"good",thresholds:{good:200,needsImprovement:500},details:o}:{script:"INP",status:"ok",metric:"INP",value:Math.round(e),unit:"ms",rating:i,thresholds:{good:200,needsImprovement:500},details:o}},window.getINPDetails=()=>{if(0===t.length)return[];const e=[...t].sort((t,e)=>e.duration-t.duration),n=Math.min(e.length,15);return e.slice(0,n).forEach((t,e)=>{t.target&&(t=>{if(!t)return"";const e=[];let n=t;for(;n&&n!==document.body&&e.length<5;){let t=n.tagName.toLowerCase();if(n.id)t+=`#${n.id}`;else if(n.className&&"string"==typeof n.className){const e=n.className.trim().split(/\s+/).slice(0,2).join(".");e&&(t+=`.${e}`)}e.unshift(t),n=n.parentElement}e.join(" > ")})(t.target)}),e},document.addEventListener("visibilitychange",()=>{if("hidden"===document.visibilityState){o.takeRecords();const t=s();e=t.value,n=t.entry,c()}})})();
\ No newline at end of file
+// snippets/CoreWebVitals/INP.js | sha256:d38336ec07369461 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/CoreWebVitals/INP.js
+(()=>{const t=[];let e=0,n=null;const a=t=>t<=200?"good":t<=500?"needs-improvement":"poor",r=()=>{if(0===t.length)return{value:0,entry:null};const e=[...t].sort((t,e)=>e.duration-t.duration),n=t.length<50?0:Math.floor(.02*t.length);return{value:e[n].duration,entry:e[n]}},i=t=>{const e=t.target;if(!e)return t.name;let n=e.tagName.toLowerCase();if(e.id)n+=`#${e.id}`;else if(e.className&&"string"==typeof e.className){const t=e.className.trim().split(/\s+/).slice(0,2).join(".");t&&(n+=`.${t}`)}return`${t.name} → ${n}`},s=t=>{const e={inputDelay:0,processingTime:0,presentationDelay:0};return t.processingStart&&t.processingEnd&&(e.inputDelay=t.processingStart-t.startTime,e.processingTime=t.processingEnd-t.processingStart,e.presentationDelay=t.duration-e.inputDelay-e.processingTime),e},o=new PerformanceObserver(a=>{for(const o of a.getEntries()){if(!o.interactionId)continue;const a=t.find(t=>t.interactionId===o.interactionId);if(!a||o.duration>a.duration){if(a){const e=t.indexOf(a);t.splice(e,1)}t.push({name:o.name,duration:o.duration,startTime:o.startTime,interactionId:o.interactionId,target:o.target,processingStart:o.processingStart,processingEnd:o.processingEnd,formattedName:i(o),phases:s(o),entry:o})}const c=r();e=c.value,n=c.entry}});o.observe({type:"event",buffered:!0,durationThreshold:16});const c=()=>{a(e);if(n){n.target&&(t=>{if(!t)return"";const e=[];let n=t;for(;n&&n!==document.body&&e.length<5;){let t=n.tagName.toLowerCase();if(n.id)t+=`#${n.id}`;else if(n.className&&"string"==typeof n.className){const e=n.className.trim().split(/\s+/).slice(0,2).join(".");e&&(t+=`.${e}`)}e.unshift(t),n=n.parentElement}e.join(" > ")})(n.target);const t=n.phases;if(t.inputDelay>0){const e=n.duration,a=40;"▓".repeat(Math.round(t.inputDelay/e*a)),"█".repeat(Math.round(t.processingTime/e*a)),"░".repeat(Math.round(t.presentationDelay/e*a))}}const r=t.filter(t=>t.duration>200).sort((t,e)=>e.duration-t.duration).slice(0,10);r.length>0&&r.slice(0,3).forEach((t,e)=>{});const i={};t.forEach(t=>{const e=t.name;i[e]||(i[e]={count:0,totalDuration:0,maxDuration:0}),i[e].count++,i[e].totalDuration+=t.duration,i[e].maxDuration=Math.max(i[e].maxDuration,t.duration)}),Object.keys(i)};window.getINP=()=>{const i=r();e=i.value,n=i.entry,c();const s=a(e),o={totalInteractions:t.length};return n&&(o.worstEvent=n.formattedName,o.phases={inputDelay:Math.round(n.phases.inputDelay),processingTime:Math.round(n.phases.processingTime),presentationDelay:Math.round(n.phases.presentationDelay)}),0===t.length?{script:"INP",status:"error",error:"No interactions recorded yet. Interact with the page and call getINP() again.",getDataFn:"getINP",details:o}:{script:"INP",status:"ok",metric:"INP",value:Math.round(e),unit:"ms",rating:s,thresholds:{good:200,needsImprovement:500},details:o}},window.getINPDetails=()=>{if(0===t.length)return[];const e=[...t].sort((t,e)=>e.duration-t.duration),n=Math.min(e.length,15);return e.slice(0,n).forEach((t,e)=>{t.target&&(t=>{if(!t)return"";const e=[];let n=t;for(;n&&n!==document.body&&e.length<5;){let t=n.tagName.toLowerCase();if(n.id)t+=`#${n.id}`;else if(n.className&&"string"==typeof n.className){const e=n.className.trim().split(/\s+/).slice(0,2).join(".");e&&(t+=`.${e}`)}e.unshift(t),n=n.parentElement}e.join(" > ")})(t.target)}),e},document.addEventListener("visibilitychange",()=>{if("hidden"===document.visibilityState){o.takeRecords();const t=r();e=t.value,n=t.entry,c()}})})();
\ No newline at end of file
diff --git a/.claude/skills/webperf-core-web-vitals/scripts/LCP-Image-Entropy.js b/.claude/skills/webperf-core-web-vitals/scripts/LCP-Image-Entropy.js
index 1ccbba9..bf29ce5 100644
--- a/.claude/skills/webperf-core-web-vitals/scripts/LCP-Image-Entropy.js
+++ b/.claude/skills/webperf-core-web-vitals/scripts/LCP-Image-Entropy.js
@@ -1,2 +1,2 @@
-// snippets/CoreWebVitals/LCP-Image-Entropy.js | sha256:e072b4731bcc7fdf | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/CoreWebVitals/LCP-Image-Entropy.js
-(()=>{const e=e=>{if(!e)return"-";const t=Math.floor(Math.log(e)/Math.log(1024));return(e/Math.pow(1024,t)).toFixed(1)+" "+["B","KB","MB"][t]};let t=null,r=null;const i=new PerformanceObserver(e=>{const i=e.getEntries(),n=i[i.length-1];n&&(t=n.element,r=n.url)});i.observe({type:"largest-contentful-paint",buffered:!0}),setTimeout(()=>{i.disconnect();const n=[...document.images].filter(e=>{const t=e.currentSrc||e.src;return t&&!t.startsWith("data:image")}).map(e=>{const i=e.currentSrc||e.src,n=performance.getEntriesByName(i)[0],s=n?.encodedBodySize||0,o=e.naturalWidth*e.naturalHeight,l=o>0?8*s/o:0,a=l>0&&l<.05,p=t===e||r===i;return{element:e,src:i,shortSrc:i.split("/").pop()?.split("?")[0]||i,width:e.naturalWidth,height:e.naturalHeight,fileSize:s,bpp:l,isLowEntropy:a,isLCP:p,lcpEligible:!a&&l>0}}).filter(e=>e.bpp>0);if(0===n.length)return;const s=n.filter(e=>e.isLowEntropy);n.filter(e=>!e.isLowEntropy),n.find(e=>e.isLCP);n.sort((e,t)=>t.bpp-e.bpp).map(t=>({Image:t.shortSrc.length>30?"..."+t.shortSrc.slice(-27):t.shortSrc,Dimensions:`${t.width}×${t.height}`,Size:e(t.fileSize),BPP:t.bpp.toFixed(4),Entropy:t.isLowEntropy?"🔴 Low":"🟢 Normal","LCP Eligible":t.lcpEligible?"✅":"❌","Is LCP":t.isLCP?"👈":""})),s.length>0&&s.forEach(e=>{}),n.forEach((e,t)=>{})},100);const n=performance.getEntriesByType("largest-contentful-paint").at(-1),s=n?.element??null,o=n?.url??null,l=[...document.images].filter(e=>{const t=e.currentSrc||e.src;return t&&!t.startsWith("data:image")}).map(e=>{const t=e.currentSrc||e.src,r=performance.getEntriesByName(t)[0],i=r?.encodedBodySize||0,n=e.naturalWidth*e.naturalHeight,l=n>0?8*i/n:0,a=l>0&&l<.05,p=s===e||o===t;return{url:t.split("/").pop()?.split("?")[0]||t,width:e.naturalWidth,height:e.naturalHeight,fileSizeBytes:i,bpp:Math.round(1e4*l)/1e4,isLowEntropy:a,lcpEligible:!a&&l>0,isLCP:p}}).filter(e=>e.bpp>0),a=l.filter(e=>e.isLowEntropy).length,p=l.find(e=>e.isLCP),c=[];a>0&&c.push({severity:"warning",message:`${a} image(s) have low entropy and are LCP-ineligible in Chrome 112+`}),p?.isLowEntropy&&c.push({severity:"error",message:"Current LCP image has low entropy and may be skipped by Chrome"})})();
\ No newline at end of file
+// snippets/CoreWebVitals/LCP-Image-Entropy.js | sha256:378ac9be31342726 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/CoreWebVitals/LCP-Image-Entropy.js
+(()=>{const e=e=>{if(!e)return"-";const t=Math.floor(Math.log(e)/Math.log(1024));return(e/Math.pow(1024,t)).toFixed(1)+" "+["B","KB","MB"][t]};let t=null,r=null;const i=new PerformanceObserver(e=>{const i=e.getEntries(),n=i[i.length-1];n&&(t=n.element,r=n.url)});i.observe({type:"largest-contentful-paint",buffered:!0}),setTimeout(()=>{i.disconnect();const n=[...document.images].filter(e=>{const t=e.currentSrc||e.src;return t&&!t.startsWith("data:image")}).map(e=>{const i=e.currentSrc||e.src,n=performance.getEntriesByName(i)[0],s=n?.encodedBodySize||0,l=e.naturalWidth*e.naturalHeight,o=l>0?8*s/l:0,a=o>0&&o<.05,p=t===e||r===i;return{element:e,src:i,shortSrc:i.split("/").pop()?.split("?")[0]||i,width:e.naturalWidth,height:e.naturalHeight,fileSize:s,bpp:o,isLowEntropy:a,isLCP:p,lcpEligible:!a&&o>0}}).filter(e=>e.bpp>0);if(0===n.length)return;const s=n.filter(e=>e.isLowEntropy);n.filter(e=>!e.isLowEntropy),n.find(e=>e.isLCP);n.sort((e,t)=>t.bpp-e.bpp).map(t=>({Image:t.shortSrc.length>30?"..."+t.shortSrc.slice(-27):t.shortSrc,Dimensions:`${t.width}×${t.height}`,Size:e(t.fileSize),BPP:t.bpp.toFixed(4),Entropy:t.isLowEntropy?"🔴 Low":"🟢 Normal","LCP Eligible":t.lcpEligible?"✅":"❌","Is LCP":t.isLCP?"👈":""})),s.length>0&&s.forEach(e=>{}),n.forEach((e,t)=>{})},100);const n=performance.getEntriesByType("largest-contentful-paint").at(-1),s=n?.element??null,l=n?.url??null,o=[...document.images].filter(e=>{const t=e.currentSrc||e.src;return t&&!t.startsWith("data:image")}).map(e=>{const t=e.currentSrc||e.src,r=performance.getEntriesByName(t)[0],i=r?.encodedBodySize||0,n=e.naturalWidth*e.naturalHeight,o=n>0?8*i/n:0,a=o>0&&o<.05,p=s===e||l===t;return{url:t.split("/").pop()?.split("?")[0]||t,width:e.naturalWidth,height:e.naturalHeight,fileSizeBytes:i,bpp:Math.round(1e4*o)/1e4,isLowEntropy:a,lcpEligible:!a&&o>0,isLCP:p}}).filter(e=>e.bpp>0),a=o.filter(e=>e.isLowEntropy).length,p=o.find(e=>e.isLCP);s&&(s.style.outline="3px dashed lime",s.style.outlineOffset="2px");const c=[];a>0&&c.push({severity:"warning",message:`${a} image(s) have low entropy and are LCP-ineligible in Chrome 112+`}),p?.isLowEntropy&&c.push({severity:"error",message:"Current LCP image has low entropy and may be skipped by Chrome"})})();
\ No newline at end of file
diff --git a/.claude/skills/webperf-core-web-vitals/scripts/LCP-Sub-Parts.js b/.claude/skills/webperf-core-web-vitals/scripts/LCP-Sub-Parts.js
index 4953fa9..7a46192 100644
--- a/.claude/skills/webperf-core-web-vitals/scripts/LCP-Sub-Parts.js
+++ b/.claude/skills/webperf-core-web-vitals/scripts/LCP-Sub-Parts.js
@@ -1,2 +1,2 @@
-// snippets/CoreWebVitals/LCP-Sub-Parts.js | sha256:675014efb100840b | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/CoreWebVitals/LCP-Sub-Parts.js
-(()=>{const e=e=>e<=2500?"good":e<=4e3?"needs-improvement":"poor",t=[{name:"Time to First Byte",key:"ttfb",target:800},{name:"Resource Load Delay",key:"loadDelay",targetPercent:10},{name:"Resource Load Time",key:"loadTime",targetPercent:40},{name:"Element Render Delay",key:"renderDelay",targetPercent:10}],a=()=>{const e=performance.getEntriesByType("navigation")[0];return e?.responseStart>0&&e.responseStart{const n=r.getEntries().at(-1);if(!n)return;const s=a();if(!s)return;const o=performance.getEntriesByType("resource").find(e=>e.name===n.url),l=s.activationStart||0,i=Math.max(0,s.responseStart-l),m=Math.max(i,o?(o.requestStart||o.startTime)-l:0),u=Math.max(m,o?o.responseEnd-l:0),c=Math.max(u,n.startTime-l),p={ttfb:i,loadDelay:m-i,loadTime:u-m,renderDelay:c-u};e(c);if(n.element){const e=n.element;let t=e.tagName.toLowerCase();if(e.id)t=`#${e.id}`;else if(e.className&&"string"==typeof e.className){const a=e.className.trim().split(/\s+/).slice(0,2).join(".");a&&(t=`${e.tagName.toLowerCase()}.${a}`)}n.url&&n.url.split("/").pop()?.split("?"),e.style.outline="3px dashed lime",e.style.outlineOffset="2px"}const d=t.map(e=>({...e,value:p[e.key],percent:p[e.key]/c*100})),y=d.reduce((e,t)=>e.value>t.value?e:t),f=(d.map(e=>{const t=e.target?e.value>e.target:e.percent>e.targetPercent;return{"Sub-part":e.key===y.key?`⚠️ ${e.name}`:e.name,Time:(n=e.value,`${Math.round(n)}ms`),"%":(a=e.value,r=c,`${Math.round(a/r*100)}%`),Status:t?"🔴 Over target":"✅ OK"};var a,r,n}),d.map(e=>{const t=Math.max(1,Math.round(e.value/c*40));return{key:e.key,bar:t}}));"█".repeat(f[0].bar),"▓".repeat(f[1].bar),"▒".repeat(f[2].bar),"░".repeat(f[3].bar),t.forEach(e=>performance.clearMeasures(e.name)),d.forEach(e=>{const t={ttfb:0,loadDelay:i,loadTime:m,renderDelay:u};performance.measure(e.name,{start:t[e.key],end:t[e.key]+e.value})})});r.observe({type:"largest-contentful-paint",buffered:!0});const n=performance.getEntriesByType("largest-contentful-paint").at(-1);if(!n)return{script:"LCP-Sub-Parts",status:"error",error:"No LCP entries yet"};const s=a();if(!s)return{script:"LCP-Sub-Parts",status:"error",error:"No navigation entry"};const o=performance.getEntriesByType("resource").find(e=>e.name===n.url),l=s.activationStart||0,i=Math.max(0,s.responseStart-l),m=Math.max(i,o?(o.requestStart||o.startTime)-l:0),u=Math.max(m,o?o.responseEnd-l:0),c=Math.max(u,n.startTime-l),p=Math.round(c),d=(e(p),Math.round(i)),y=Math.round(m-i),f=Math.round(u-m),g=Math.round(c-u);[{key:"ttfb",value:d},{key:"resourceLoadDelay",value:y},{key:"resourceLoadTime",value:f},{key:"elementRenderDelay",value:g}].reduce((e,t)=>e.value>t.value?e:t);let h=null;if(n.element){const e=n.element;if(h=e.tagName.toLowerCase(),e.id)h=`#${e.id}`;else if(e.className&&"string"==typeof e.className){const t=e.className.trim().split(/\s+/).slice(0,2).join(".");t&&(h=`${e.tagName.toLowerCase()}.${t}`)}}n.url&&n.url.split("/").pop()?.split("?"),Math.round(d/p*100),Math.round(y/p*100),Math.round(f/p*100),Math.round(g/p*100)})();
\ No newline at end of file
+// snippets/CoreWebVitals/LCP-Sub-Parts.js | sha256:54b8831d20f3103a | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/CoreWebVitals/LCP-Sub-Parts.js
+(()=>{const e=e=>e<=2500?"good":e<=4e3?"needs-improvement":"poor",t=[{name:"Time to First Byte",key:"ttfb",target:800},{name:"Resource Load Delay",key:"resourceLoadDelay",targetPercent:10},{name:"Resource Load Time",key:"resourceLoadTime",targetPercent:40},{name:"Element Render Delay",key:"elementRenderDelay",targetPercent:10}],a=()=>{const e=performance.getEntriesByType("navigation")[0];return e?.responseStart>0&&e.responseStart{const n=r.getEntries().at(-1);if(!n)return;const s=a();if(!s)return;const o=performance.getEntriesByType("resource").find(e=>e.name===n.url),l=s.activationStart||0,u=Math.max(0,s.responseStart-l),m=Math.max(u,o?(o.requestStart||o.startTime)-l:0),i=Math.max(m,o?o.responseEnd-l:0),c=Math.max(i,n.startTime-l),p={ttfb:u,resourceLoadDelay:m-u,resourceLoadTime:i-m,elementRenderDelay:c-i};e(c);if(n.element){const e=n.element;let t=e.tagName.toLowerCase();if(e.id)t=`#${e.id}`;else if(e.className&&"string"==typeof e.className){const a=e.className.trim().split(/\s+/).slice(0,2).join(".");a&&(t=`${e.tagName.toLowerCase()}.${a}`)}n.url&&(()=>{try{const e=new URL(n.url);return e.hostname!==location.hostname?`${e.hostname}/…/${e.pathname.split("/").pop()?.split("?")[0]}`:e.pathname.split("/").pop()?.split("?")[0]||n.url}catch{return n.url}})(),e.style.outline="3px dashed lime",e.style.outlineOffset="2px"}const y=t.map(e=>({...e,value:p[e.key],percent:p[e.key]/c*100})),d=y.reduce((e,t)=>e.value>t.value?e:t),h=(y.map(e=>{const t=e.target?e.value>e.target:e.percent>e.targetPercent;return{"Sub-part":e.key===d.key?`⚠️ ${e.name}`:e.name,Time:(n=e.value,`${Math.round(n)}ms`),"%":(a=e.value,r=c,`${Math.round(a/r*100)}%`),Status:t?"🔴 Over target":"✅ OK"};var a,r,n}),y.map(e=>{const t=Math.max(1,Math.round(e.value/c*40));return{key:e.key,bar:t}}));"█".repeat(h[0].bar),"▓".repeat(h[1].bar),"▒".repeat(h[2].bar),"░".repeat(h[3].bar),t.forEach(e=>performance.clearMeasures(e.name)),y.forEach(e=>{const t={ttfb:0,resourceLoadDelay:u,resourceLoadTime:m,elementRenderDelay:i};performance.measure(e.name,{start:t[e.key],end:t[e.key]+e.value})})});r.observe({type:"largest-contentful-paint",buffered:!0});const n=performance.getEntriesByType("largest-contentful-paint").at(-1);if(!n)return{script:"LCP-Sub-Parts",status:"error",error:"No LCP entries yet"};const s=a();if(!s)return{script:"LCP-Sub-Parts",status:"error",error:"No navigation entry"};const o=performance.getEntriesByType("resource").find(e=>e.name===n.url),l=s.activationStart||0,u=Math.max(0,s.responseStart-l),m=Math.max(u,o?(o.requestStart||o.startTime)-l:0),i=Math.max(m,o?o.responseEnd-l:0),c=Math.max(i,n.startTime-l),p=Math.round(c),y=(e(p),Math.round(u)),d=Math.round(m-u),h=Math.round(i-m),f=Math.round(c-i);[{key:"ttfb",value:y},{key:"resourceLoadDelay",value:d},{key:"resourceLoadTime",value:h},{key:"elementRenderDelay",value:f}].reduce((e,t)=>e.value>t.value?e:t);let g=null;if(n.element){const e=n.element;if(g=e.tagName.toLowerCase(),e.id)g=`#${e.id}`;else if(e.className&&"string"==typeof e.className){const t=e.className.trim().split(/\s+/).slice(0,2).join(".");t&&(g=`${e.tagName.toLowerCase()}.${t}`)}}n.url&&(()=>{try{const e=new URL(n.url);return e.hostname!==location.hostname?`${e.hostname}/…/${e.pathname.split("/").pop()?.split("?")[0]}`:e.pathname.split("/").pop()?.split("?")[0]||n.url}catch{return n.url}})(),Math.round(y/p*100),Math.round(d/p*100),Math.round(h/p*100),Math.round(f/p*100)})();
\ No newline at end of file
diff --git a/.claude/skills/webperf-core-web-vitals/scripts/LCP-Trail.js b/.claude/skills/webperf-core-web-vitals/scripts/LCP-Trail.js
index 05671ff..c50f4ca 100644
--- a/.claude/skills/webperf-core-web-vitals/scripts/LCP-Trail.js
+++ b/.claude/skills/webperf-core-web-vitals/scripts/LCP-Trail.js
@@ -1,2 +1,2 @@
-// snippets/CoreWebVitals/LCP-Trail.js | sha256:ba3b9a7b4ff63567 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/CoreWebVitals/LCP-Trail.js
-(()=>{const e=[{color:"#EF4444",name:"Red"},{color:"#F97316",name:"Orange"},{color:"#22C55E",name:"Green"},{color:"#3B82F6",name:"Blue"},{color:"#A855F7",name:"Purple"},{color:"#EC4899",name:"Pink"}],t=e=>e<=2500?"good":e<=4e3?"needs-improvement":"poor",r=()=>{const e=performance.getEntriesByType("navigation")[0];return e?.activationStart||0},n=e=>{if(e.id)return`#${e.id}`;if(e.className&&"string"==typeof e.className){const t=e.className.trim().split(/\s+/).slice(0,2).join(".");if(t)return`${e.tagName.toLowerCase()}.${t}`}return e.tagName.toLowerCase()},o=(e,t)=>{const r=e.tagName.toLowerCase();return"img"===r?{type:"Image",url:t.url||e.src}:"video"===r?{type:"Video poster",url:t.url||e.poster}:e.style?.backgroundImage?{type:"Background image",url:t.url}:{type:"h1"===r||"p"===r?"Text block":r}},s=[];new PerformanceObserver(a=>{const l=r(),i=new Set(s.map(e=>e.element));for(const t of a.getEntries()){const{element:r}=t;if(!r||i.has(r))continue;const{color:o,name:a}=e[s.length%e.length];r.style.outline=`3px dashed ${o}`,r.style.outlineOffset="2px",s.push({index:s.length+1,element:r,selector:n(r),color:o,name:a,time:Math.max(0,t.startTime-l),entry:t}),i.add(r)}(()=>{const e=s[s.length-1];e&&(t(e.time),o(e.element,e.entry),s.forEach(({})=>{}))})()}).observe({type:"largest-contentful-paint",buffered:!0});const a=performance.getEntriesByType("largest-contentful-paint");if(0===a.length)return{script:"LCP-Trail",status:"error",error:"No LCP entries yet"};const l=r(),i=new Set,c=[];for(const e of a){const t=e.element;if(!t||i.has(t))continue;i.add(t);const r=n(t),s=Math.round(Math.max(0,e.startTime-l)),{type:a,url:m}=o(t,e);c.push({index:c.length+1,selector:r,time:s,elementType:a,...m?{url:m.split("/").pop()?.split("?")[0]||m}:{}})}if(0===c.length)return{script:"LCP-Trail",status:"error",error:"No LCP elements in DOM"};const m=c.at(-1);t(m.time)})();
\ No newline at end of file
+// snippets/CoreWebVitals/LCP-Trail.js | sha256:2b4fdf0368699ad2 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/CoreWebVitals/LCP-Trail.js
+(()=>{const e=[{color:"#EF4444",name:"Red"},{color:"#F97316",name:"Orange"},{color:"#22C55E",name:"Green"},{color:"#3B82F6",name:"Blue"},{color:"#A855F7",name:"Purple"},{color:"#EC4899",name:"Pink"}],t=e=>e<=2500?"good":e<=4e3?"needs-improvement":"poor",n=()=>{const e=performance.getEntriesByType("navigation")[0];return e?.activationStart||0},r=e=>{if(e.id)return`#${e.id}`;if(e.className&&"string"==typeof e.className){const t=e.className.trim().split(/\s+/).slice(0,2).join(".");if(t)return`${e.tagName.toLowerCase()}.${t}`}return e.tagName.toLowerCase()},o=(e,t)=>{const n=e.tagName.toLowerCase();return"img"===n?{type:"Image",url:t.url||e.src}:"video"===n?{type:"Video poster",url:t.url||e.poster}:"none"!==window.getComputedStyle(e).backgroundImage?{type:"Background image",url:t.url}:{type:"h1"===n||"p"===n?"Text block":n}},a=[];new PerformanceObserver(s=>{const l=n(),i=new Set(a.map(e=>e.element));for(const t of s.getEntries()){const{element:n}=t;if(!n||i.has(n))continue;const{color:o,name:s}=e[a.length%e.length];n.style.outline=`3px dashed ${o}`,n.style.outlineOffset="2px",a.push({index:a.length+1,element:n,selector:r(n),color:o,name:s,time:Math.max(0,t.startTime-l),entry:t}),i.add(n)}(()=>{const e=a[a.length-1];e&&(t(e.time),o(e.element,e.entry),a.forEach(({})=>{}))})()}).observe({type:"largest-contentful-paint",buffered:!0});const s=performance.getEntriesByType("largest-contentful-paint");if(0===s.length)return{script:"LCP-Trail",status:"error",error:"No LCP entries yet"};const l=n(),i=new Set,c=[];for(const e of s){const t=e.element;if(!t||i.has(t))continue;i.add(t);const n=r(t),a=Math.round(Math.max(0,e.startTime-l)),{type:s,url:m}=o(t,e);c.push({index:c.length+1,selector:n,time:a,elementType:s,...m?{url:(()=>{try{const e=new URL(m);return e.hostname!==location.hostname?`${e.hostname}/…/${e.pathname.split("/").pop()?.split("?")[0]}`:e.pathname.split("/").pop()?.split("?")[0]||m}catch{return m}})()}:{}})}if(0===c.length)return{script:"LCP-Trail",status:"error",error:"No LCP elements in DOM"};const m=c.at(-1);t(m.time)})();
\ No newline at end of file
diff --git a/.claude/skills/webperf-core-web-vitals/scripts/LCP-Video-Candidate.js b/.claude/skills/webperf-core-web-vitals/scripts/LCP-Video-Candidate.js
index a5c03db..72a9d80 100644
--- a/.claude/skills/webperf-core-web-vitals/scripts/LCP-Video-Candidate.js
+++ b/.claude/skills/webperf-core-web-vitals/scripts/LCP-Video-Candidate.js
@@ -1,2 +1,2 @@
-// snippets/CoreWebVitals/LCP-Video-Candidate.js | sha256:1df9db0d6cd61f13 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/CoreWebVitals/LCP-Video-Candidate.js
-(()=>{const e=performance.getEntriesByType("largest-contentful-paint");if(0===e.length)return{script:"LCP-Video-Candidate",status:"error",error:"No LCP entries found"};const t=e[e.length-1],r=t.element;function i(e){return e<=2500?"good":e<=4e3?"needs-improvement":"poor"}function n(e){try{return new URL(e,location.origin).href}catch{return e}}if(!r||"VIDEO"!==r.tagName){r&&r.tagName.toLowerCase();const e=i(t.startTime);return{script:"LCP-Video-Candidate",status:"ok",metric:"LCP",value:Math.round(t.startTime),unit:"ms",rating:e,thresholds:{good:2500,needsImprovement:4e3},details:{isVideo:!1},issues:[]}}const o=r.getAttribute("poster")||"",s=o?n(o):"",a=t.url||"",u=(i(t.startTime),function(e){if(!e)return"unknown";const t=e.toLowerCase().split("?")[0].match(/\.(avif|webp|jxl|png|gif|jpg|jpeg|svg)(?:[?#]|$)/);return t?"jpeg"===t[1]?"jpg":t[1]:"unknown"}(a||s)),g=["avif","webp","jxl"].includes(u),l=0===t.renderTime&&t.loadTime>0,d=Array.from(document.querySelectorAll('link[rel="preload"][as="image"]')).find(e=>{const t=e.getAttribute("href");if(!t)return!1;try{return n(t)===s||n(t)===a}catch{return!1}})??null,m=r.getAttribute("preload"),p=r.hasAttribute("autoplay"),h=(r.hasAttribute("muted"),r.hasAttribute("playsinline"),[]);o||h.push({s:"error",msg:"No poster attribute — the browser has no image to use as LCP candidate"}),o&&!d?h.push({s:"warning",msg:'No for the poster — browser discovers it late'}):d&&"high"!==d.getAttribute("fetchpriority")&&h.push({s:"info",msg:'Preload found but missing fetchpriority="high" — may be deprioritised'}),o&&!g&&"unknown"!==u&&h.push({s:"info",msg:`Poster uses ${u} — AVIF or WebP would reduce file size and LCP load time`}),l&&h.push({s:"info",msg:"renderTime is 0 — poster is cross-origin and the server does not send Timing-Allow-Origin"}),p||"none"!==m||h.push({s:"warning",msg:'preload="none" on a non-autoplay video may delay poster image loading in some browsers'}),d&&d.getAttribute("fetchpriority"),h.length>0&&(h.filter(e=>"error"===e.s),h.filter(e=>"warning"===e.s),h.filter(e=>"info"===e.s),h.forEach(e=>{})),Math.round(t.startTime),d?.getAttribute("fetchpriority"),h.map(e=>({severity:"error"===e.s?"error":"warning"===e.s?"warning":"info",message:e.msg}))})();
\ No newline at end of file
+// snippets/CoreWebVitals/LCP-Video-Candidate.js | sha256:661ecd2463bde4a5 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/CoreWebVitals/LCP-Video-Candidate.js
+(()=>{const e=performance.getEntriesByType("largest-contentful-paint");if(0===e.length)return{script:"LCP-Video-Candidate",status:"error",error:"No LCP entries found"};const t=e[e.length-1],r=t.element;function n(e){return e<=2500?"good":e<=4e3?"needs-improvement":"poor"}function i(e){try{return new URL(e,location.origin).href}catch{return e}}const o=(()=>{const e=performance.getEntriesByType("navigation")[0];return e?.activationStart||0})(),s=Math.round(Math.max(0,t.startTime-o));if(!r||"VIDEO"!==r.tagName)return r&&r.tagName.toLowerCase(),{script:"LCP-Video-Candidate",status:"ok",metric:"LCP",value:s,unit:"ms",rating:n(s),thresholds:{good:2500,needsImprovement:4e3},details:{isVideo:!1},issues:[]};const a=r.getAttribute("poster")||"",u=a?i(a):"",g=t.url||"",l=(n(s),function(e){if(!e)return"unknown";const t=e.toLowerCase().split("?")[0].match(/\.(avif|webp|jxl|png|gif|jpg|jpeg|svg)(?:[?#]|$)/);return t?"jpeg"===t[1]?"jpg":t[1]:"unknown"}(g||u)),d=["avif","webp","jxl"].includes(l),p=0===t.renderTime&&t.loadTime>0,m=Array.from(document.querySelectorAll('link[rel="preload"][as="image"]')).find(e=>{const t=e.getAttribute("href");if(!t)return!1;try{return i(t)===u||i(t)===g}catch{return!1}})??null,c=r.getAttribute("preload"),f=r.hasAttribute("autoplay"),h=(r.hasAttribute("muted"),r.hasAttribute("playsinline"),[]);a||h.push({s:"error",msg:"No poster attribute — the browser has no image to use as LCP candidate"}),a&&!m?h.push({s:"warning",msg:'No for the poster — browser discovers it late'}):m&&"high"!==m.getAttribute("fetchpriority")&&h.push({s:"info",msg:'Preload found but missing fetchpriority="high" — may be deprioritised'}),a&&!d&&"unknown"!==l&&h.push({s:"info",msg:`Poster uses ${l} — AVIF or WebP would reduce file size and LCP load time`}),p&&h.push({s:"info",msg:"renderTime is 0 — poster is cross-origin and the server does not send Timing-Allow-Origin"}),f||"none"!==c||h.push({s:"warning",msg:'preload="none" on a non-autoplay video may delay poster image loading in some browsers'}),m&&m.getAttribute("fetchpriority"),h.length>0&&(h.filter(e=>"error"===e.s),h.filter(e=>"warning"===e.s),h.filter(e=>"info"===e.s),h.forEach(e=>{})),m?.getAttribute("fetchpriority"),h.map(e=>({severity:"error"===e.s?"error":"warning"===e.s?"warning":"info",message:e.msg}))})();
\ No newline at end of file
diff --git a/.claude/skills/webperf-core-web-vitals/scripts/LCP.js b/.claude/skills/webperf-core-web-vitals/scripts/LCP.js
index 118440f..f7a6125 100644
--- a/.claude/skills/webperf-core-web-vitals/scripts/LCP.js
+++ b/.claude/skills/webperf-core-web-vitals/scripts/LCP.js
@@ -1,2 +1,2 @@
-// snippets/CoreWebVitals/LCP.js | sha256:5982d7648dc0db34 | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/CoreWebVitals/LCP.js
-(()=>{const e=e=>e<=2500?"good":e<=4e3?"needs-improvement":"poor",t=()=>{const e=performance.getEntriesByType("navigation")[0];return e?.activationStart||0};new PerformanceObserver(a=>{const s=a.getEntries(),o=s[s.length-1];if(!o)return;const r=t(),n=Math.max(0,o.startTime-r),i=(e(n),o.element);if(i){let e=i.tagName.toLowerCase();if(i.id)e=`#${i.id}`;else if(i.className&&"string"==typeof i.className){const t=i.className.trim().split(/\s+/).slice(0,2).join(".");t&&(e=`${i.tagName.toLowerCase()}.${t}`)}i.tagName.toLowerCase(),i.style.outline="3px dashed lime",i.style.outlineOffset="2px"}}).observe({type:"largest-contentful-paint",buffered:!0});const a=performance.getEntriesByType("largest-contentful-paint").at(-1);if(!a)return{script:"LCP",status:"error",error:"No LCP entries yet"};const s=t(),o=Math.round(Math.max(0,a.startTime-s)),r=(e(o),a.element);let n=null,i=null;if(r){if(n=r.tagName.toLowerCase(),r.id)n=`#${r.id}`;else if(r.className&&"string"==typeof r.className){const e=r.className.trim().split(/\s+/).slice(0,2).join(".");e&&(n=`${r.tagName.toLowerCase()}.${e}`)}const e=r.tagName.toLowerCase();i="img"===e?"Image":"video"===e?"Video poster":r.style?.backgroundImage?"Background image":"h1"===e||"p"===e?"Text block":e}})();
\ No newline at end of file
+// snippets/CoreWebVitals/LCP.js | sha256:7951027f8b24a15a | https://github.com/nucliweb/webperf-snippets/blob/main/snippets/CoreWebVitals/LCP.js
+(()=>{const e=e=>e<=2500?"good":e<=4e3?"needs-improvement":"poor",t=()=>{const e=performance.getEntriesByType("navigation")[0];return e?.activationStart||0};new PerformanceObserver(a=>{const o=a.getEntries(),s=o[o.length-1];if(!s)return;const n=t(),r=Math.max(0,s.startTime-n),i=(e(r),s.element);if(i){let e=i.tagName.toLowerCase();if(i.id)e=`#${i.id}`;else if(i.className&&"string"==typeof i.className){const t=i.className.trim().split(/\s+/).slice(0,2).join(".");t&&(e=`${i.tagName.toLowerCase()}.${t}`)}const t=i.tagName.toLowerCase();"img"===t||"video"===t||window.getComputedStyle(i),i.style.outline="3px dashed lime",i.style.outlineOffset="2px"}}).observe({type:"largest-contentful-paint",buffered:!0});const a=performance.getEntriesByType("largest-contentful-paint").at(-1);if(!a)return{script:"LCP",status:"error",error:"No LCP entries yet"};const o=t(),s=Math.round(Math.max(0,a.startTime-o)),n=(e(s),a.element);let r=null,i=null;if(n){if(r=n.tagName.toLowerCase(),n.id)r=`#${n.id}`;else if(n.className&&"string"==typeof n.className){const e=n.className.trim().split(/\s+/).slice(0,2).join(".");e&&(r=`${n.tagName.toLowerCase()}.${e}`)}const e=n.tagName.toLowerCase();i="img"===e?"Image":"video"===e?"Video poster":"none"!==window.getComputedStyle(n).backgroundImage?"Background image":"h1"===e||"p"===e?"Text block":e}})();
\ No newline at end of file
diff --git a/.claude/skills/webperf-interaction/SKILL.md b/.claude/skills/webperf-interaction/SKILL.md
index a9074cf..cc0a7dd 100644
--- a/.claude/skills/webperf-interaction/SKILL.md
+++ b/.claude/skills/webperf-interaction/SKILL.md
@@ -26,7 +26,6 @@ JavaScript snippets for measuring web performance in Chrome DevTools. Execute wi
- `scripts/LongTask.js` — Long Tasks
- `scripts/Scroll-Performance.js` — Scroll Performance Analysis
-Descriptions and thresholds: `references/snippets.md`
## Common Workflows
@@ -106,7 +105,7 @@ Use this decision tree to automatically run follow-up snippets based on results:
### After Interactions.js
-- **If any interaction > 200ms** → Run **Input-Latency-Breakdown.js** on slow interactions
+- **If any interaction > 200ms** → Run **Input-Latency-Breakdown.js** on slow interactions; also run **webperf-core-web-vitals:INP.js** for official INP measurement
- **If many interactions > 200ms** → Main thread congestion, run:
1. **Long-Animation-Frames.js** (blocking frames)
2. **LongTask.js** (long tasks)
@@ -189,36 +188,6 @@ This is a utility snippet, use results to:
- Measure style/layout/paint phases
- No automatic follow-up, use data to inform next steps
-### Cross-Skill Triggers
-
-These triggers recommend using snippets from other skills:
-
-#### From Interaction to Core Web Vitals Skill
-
-- **If INP > 200ms detected** → Use **webperf-core-web-vitals** skill:
- - INP.js (official INP measurement)
- - LCP-Sub-Parts.js (if render delay is causing INP)
-
-- **If layout shifts during interaction** → Use **webperf-core-web-vitals** skill:
- - CLS.js (measure cumulative impact)
- - LCP-Trail.js (check if shifts affect LCP candidate)
-
-#### From Interaction to Loading Skill
-
-- **If long frames caused by script execution** → Use **webperf-loading** skill:
- - JS-Execution-Time-Breakdown.js (parsing vs execution time)
- - First-And-Third-Party-Script-Info.js (identify heavy scripts)
- - Script-Loading.js (check for blocking patterns)
-
-- **If interactions slow during page load** → Use **webperf-loading** skill:
- - Event-Processing-Time.js (page load phases)
- - Find-render-blocking-resources.js (competing resources)
-
-#### From Interaction to Media Skill
-
-- **If layout shifts involve images** → Use **webperf-media** skill:
- - Image-Element-Audit.js (check for missing dimensions)
-
### Performance Budget Thresholds
Use these thresholds to automatically trigger follow-up analysis:
@@ -267,6 +236,4 @@ When multiple interaction metrics are poor:
## References
- `references/snippets.md` — Descriptions and thresholds for each script
-- `references/schema.md` — Return value schema for interpreting script output
-
-> Execute via `mcp__chrome-devtools__evaluate_script` → read with `mcp__chrome-devtools__get_console_message`.
\ No newline at end of file
+- `references/schema.md` — Return value schema for interpreting script output
\ No newline at end of file
diff --git a/.claude/skills/webperf-interaction/references/schema.md b/.claude/skills/webperf-interaction/references/schema.md
index 7328e5e..f7e8b16 100644
--- a/.claude/skills/webperf-interaction/references/schema.md
+++ b/.claude/skills/webperf-interaction/references/schema.md
@@ -186,6 +186,7 @@ Keep the existing `async () => {}` wrapper. Add a `return` statement with struct
```
#### CLS
+Returns buffered CLS immediately and keeps tracking. Always call `getCLS()` after interactions to get an updated value.
```json
{
"script": "CLS",
@@ -194,9 +195,12 @@ Keep the existing `async () => {}` wrapper. Add a `return` statement with struct
"value": 0.05,
"unit": "score",
"rating": "good",
- "thresholds": { "good": 0.1, "needsImprovement": 0.25 }
+ "thresholds": { "good": 0.1, "needsImprovement": 0.25 },
+ "message": "CLS tracking active. Call getCLS() for updated value after page interactions.",
+ "getDataFn": "getCLS"
}
```
+`getCLS()` returns the same shape with the latest accumulated value.
#### INP (tracking)
```json
@@ -224,6 +228,15 @@ Keep the existing `async () => {}` wrapper. Add a `return` statement with struct
}
}
```
+If no interactions yet, `getINP()` returns `status: "error"` with `getDataFn: "getINP"` — retry after user interaction.
+
+`getINPDetails()` returns the full sorted interaction list (array of up to 15 entries). Use when `getINP()` shows poor INP and you need to identify patterns across multiple slow interactions:
+```json
+[
+ { "formattedName": "click → button.submit", "duration": 450, "startTime": 1200,
+ "phases": { "inputDelay": 120, "processingTime": 280, "presentationDelay": 50 } }
+]
+```
#### LCP-Sub-Parts
```json
diff --git a/.claude/skills/webperf-loading/SKILL.md b/.claude/skills/webperf-loading/SKILL.md
index 3324a08..8822ef7 100644
--- a/.claude/skills/webperf-loading/SKILL.md
+++ b/.claude/skills/webperf-loading/SKILL.md
@@ -46,7 +46,6 @@ JavaScript snippets for measuring web performance in Chrome DevTools. Execute wi
- `scripts/TTFB.js` — Time To First Byte: Measure the time to first byte
- `scripts/Validate-Preload-Async-Defer-Scripts.js` — Validate Preload on Async/Defer Scripts
-Descriptions and thresholds: `references/snippets.md`
## Common Workflows
@@ -108,15 +107,6 @@ When CSS is bloated or blocking rendering:
3. **CSS-Media-Queries-Analysis.js** - Find unused responsive CSS
4. **Find-render-blocking-resources.js** - Identify blocking stylesheets
-### Image Loading Audit
-
-When images are suspected to cause loading issues:
-
-1. **Find-Above-The-Fold-Lazy-Loaded-Images.js** - Check for lazy-loading anti-patterns
-2. **Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js** - Find missed optimization opportunities
-3. **Find-Images-With-Lazy-and-Fetchpriority.js** - Detect contradictory attributes
-4. **Priority-Hints-Audit.js** - Verify LCP image has fetchpriority="high"
-
### SSR/Framework Performance
When analyzing Next.js, Nuxt, Remix, or other SSR frameworks:
@@ -224,6 +214,4 @@ Use this decision tree to automatically run follow-up snippets based on results:
## References
- `references/snippets.md` — Descriptions and thresholds for each script
-- `references/schema.md` — Return value schema for interpreting script output
-
-> Execute via `mcp__chrome-devtools__evaluate_script` → read with `mcp__chrome-devtools__get_console_message`.
\ No newline at end of file
+- `references/schema.md` — Return value schema for interpreting script output
\ No newline at end of file
diff --git a/.claude/skills/webperf-loading/references/schema.md b/.claude/skills/webperf-loading/references/schema.md
index 7328e5e..f7e8b16 100644
--- a/.claude/skills/webperf-loading/references/schema.md
+++ b/.claude/skills/webperf-loading/references/schema.md
@@ -186,6 +186,7 @@ Keep the existing `async () => {}` wrapper. Add a `return` statement with struct
```
#### CLS
+Returns buffered CLS immediately and keeps tracking. Always call `getCLS()` after interactions to get an updated value.
```json
{
"script": "CLS",
@@ -194,9 +195,12 @@ Keep the existing `async () => {}` wrapper. Add a `return` statement with struct
"value": 0.05,
"unit": "score",
"rating": "good",
- "thresholds": { "good": 0.1, "needsImprovement": 0.25 }
+ "thresholds": { "good": 0.1, "needsImprovement": 0.25 },
+ "message": "CLS tracking active. Call getCLS() for updated value after page interactions.",
+ "getDataFn": "getCLS"
}
```
+`getCLS()` returns the same shape with the latest accumulated value.
#### INP (tracking)
```json
@@ -224,6 +228,15 @@ Keep the existing `async () => {}` wrapper. Add a `return` statement with struct
}
}
```
+If no interactions yet, `getINP()` returns `status: "error"` with `getDataFn: "getINP"` — retry after user interaction.
+
+`getINPDetails()` returns the full sorted interaction list (array of up to 15 entries). Use when `getINP()` shows poor INP and you need to identify patterns across multiple slow interactions:
+```json
+[
+ { "formattedName": "click → button.submit", "duration": 450, "startTime": 1200,
+ "phases": { "inputDelay": 120, "processingTime": 280, "presentationDelay": 50 } }
+]
+```
#### LCP-Sub-Parts
```json
diff --git a/.claude/skills/webperf-media/SKILL.md b/.claude/skills/webperf-media/SKILL.md
index c284424..96e14a7 100644
--- a/.claude/skills/webperf-media/SKILL.md
+++ b/.claude/skills/webperf-media/SKILL.md
@@ -21,7 +21,6 @@ JavaScript snippets for measuring web performance in Chrome DevTools. Execute wi
- `scripts/SVG-Embedded-Bitmap-Analysis.js` — SVG Embedded Bitmap Analysis
- `scripts/Video-Element-Audit.js` — Video Element Audit
-Descriptions and thresholds: `references/snippets.md`
## Common Workflows
@@ -125,6 +124,10 @@ Use this decision tree to automatically run follow-up snippets based on results:
1. **webperf-loading:Find-Images-With-Lazy-and-Fetchpriority.js** (confirm contradiction)
2. Recommend removing one of the conflicting attributes
+- **If images competing with critical resources** → Run:
+ 1. **webperf-loading:Find-render-blocking-resources.js** (resource priority conflicts)
+ 2. **webperf-loading:TTFB-Resources.js** (identify slow image CDN)
+
- **If images missing alt text** → Accessibility issue, recommend adding descriptive alt text
### After Video-Element-Audit.js
@@ -176,44 +179,6 @@ Use this decision tree to automatically run follow-up snippets based on results:
- SVG symbols for reusable graphics
- Extracting to individual optimized images
-### Cross-Skill Triggers
-
-These triggers recommend using snippets from other skills:
-
-#### From Media to Core Web Vitals Skill
-
-- **If LCP image detected** → Use **webperf-core-web-vitals** skill:
- - LCP.js (measure LCP)
- - LCP-Sub-Parts.js (break down timing phases)
- - LCP-Image-Entropy.js (analyze image complexity)
-
-- **If video is LCP candidate** → Use **webperf-core-web-vitals** skill:
- - LCP-Video-Candidate.js (confirm and analyze)
- - LCP.js (measure impact)
-
-- **If images causing layout shifts** → Use **webperf-core-web-vitals** skill:
- - CLS.js (measure cumulative shift)
-
-#### From Media to Loading Skill
-
-- **If lazy loading issues detected** → Use **webperf-loading** skill:
- - Find-Above-The-Fold-Lazy-Loaded-Images.js (incorrectly lazy)
- - Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js (missing lazy)
- - Find-Images-With-Lazy-and-Fetchpriority.js (contradictory attributes)
-
-- **If LCP image needs priority optimization** → Use **webperf-loading** skill:
- - Priority-Hints-Audit.js (fetchpriority analysis)
- - Resource-Hints-Validation.js (preload validation)
-
-- **If images competing with critical resources** → Use **webperf-loading** skill:
- - Find-render-blocking-resources.js (resource priority conflicts)
- - TTFB-Resources.js (identify slow image CDN)
-
-#### From Media to Interaction Skill
-
-- **If images causing layout shifts during interaction** → Use **webperf-interaction** skill:
- - Layout-Shift-Loading-and-Interaction.js (shift timing analysis)
-
### Performance Budget Thresholds
Use these thresholds to trigger recommendations:
@@ -247,47 +212,7 @@ Use these thresholds to trigger recommendations:
- **Non-LCP images with fetchpriority="high"** → Remove, wasting browser hints
- **Lazy + fetchpriority="high" conflict** → Fix contradiction
-### Common Issues and Resolutions
-
-**Issue: LCP is slow and LCP element is an image**
-1. Run Image-Element-Audit.js
-2. Run webperf-core-web-vitals:LCP-Image-Entropy.js
-3. Check: format, lazy loading, fetchpriority, preload
-4. Fix in order: remove lazy, add fetchpriority="high", optimize format, add preload
-
-**Issue: CLS from images**
-1. Run Image-Element-Audit.js
-2. Check for missing width/height
-3. Add explicit dimensions or aspect-ratio CSS
-4. Verify with webperf-core-web-vitals:CLS.js
-
-**Issue: Page loads too many images**
-1. Run Image-Element-Audit.js
-2. Run webperf-loading:Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js
-3. Implement lazy loading on below-fold images
-4. Consider pagination or infinite scroll
-
-**Issue: Images are the wrong format**
-1. Run Image-Element-Audit.js
-2. Check format vs content type
-3. Recommend WebP with JPEG/PNG fallback
-4. Consider AVIF for even better compression
-
-**Issue: Video is LCP**
-1. Run Video-Element-Audit.js
-2. Run webperf-core-web-vitals:LCP-Video-Candidate.js
-3. Optimize poster image or consider static image alternative
-4. Add fetchpriority="high" to poster if keeping video
-
-**Issue: SVG files are huge**
-1. Run SVG-Embedded-Bitmap-Analysis.js
-2. Extract embedded bitmaps
-3. Run SVGO on pure SVG
-4. Re-measure file sizes
-
## References
- `references/snippets.md` — Descriptions and thresholds for each script
-- `references/schema.md` — Return value schema for interpreting script output
-
-> Execute via `mcp__chrome-devtools__evaluate_script` → read with `mcp__chrome-devtools__get_console_message`.
\ No newline at end of file
+- `references/schema.md` — Return value schema for interpreting script output
\ No newline at end of file
diff --git a/.claude/skills/webperf-media/references/schema.md b/.claude/skills/webperf-media/references/schema.md
index 7328e5e..f7e8b16 100644
--- a/.claude/skills/webperf-media/references/schema.md
+++ b/.claude/skills/webperf-media/references/schema.md
@@ -186,6 +186,7 @@ Keep the existing `async () => {}` wrapper. Add a `return` statement with struct
```
#### CLS
+Returns buffered CLS immediately and keeps tracking. Always call `getCLS()` after interactions to get an updated value.
```json
{
"script": "CLS",
@@ -194,9 +195,12 @@ Keep the existing `async () => {}` wrapper. Add a `return` statement with struct
"value": 0.05,
"unit": "score",
"rating": "good",
- "thresholds": { "good": 0.1, "needsImprovement": 0.25 }
+ "thresholds": { "good": 0.1, "needsImprovement": 0.25 },
+ "message": "CLS tracking active. Call getCLS() for updated value after page interactions.",
+ "getDataFn": "getCLS"
}
```
+`getCLS()` returns the same shape with the latest accumulated value.
#### INP (tracking)
```json
@@ -224,6 +228,15 @@ Keep the existing `async () => {}` wrapper. Add a `return` statement with struct
}
}
```
+If no interactions yet, `getINP()` returns `status: "error"` with `getDataFn: "getINP"` — retry after user interaction.
+
+`getINPDetails()` returns the full sorted interaction list (array of up to 15 entries). Use when `getINP()` shows poor INP and you need to identify patterns across multiple slow interactions:
+```json
+[
+ { "formattedName": "click → button.submit", "duration": 450, "startTime": 1200,
+ "phases": { "inputDelay": 120, "processingTime": 280, "presentationDelay": 50 } }
+]
+```
#### LCP-Sub-Parts
```json
diff --git a/.claude/skills/webperf-resources/SKILL.md b/.claude/skills/webperf-resources/SKILL.md
index a85173f..98f17ca 100644
--- a/.claude/skills/webperf-resources/SKILL.md
+++ b/.claude/skills/webperf-resources/SKILL.md
@@ -19,7 +19,6 @@ JavaScript snippets for measuring web performance in Chrome DevTools. Execute wi
- `scripts/Network-Bandwidth-Connection-Quality.js` — Network Bandwidth & Connection Quality
-Descriptions and thresholds: `references/snippets.md`
## Common Workflows
@@ -74,13 +73,15 @@ Use this decision tree to automatically run follow-up snippets based on results:
1. Run **webperf-loading:Critical-CSS-Detection.js** (inline critical CSS)
2. Run **webperf-media:Image-Element-Audit.js** (implement aggressive lazy loading)
3. Run **webperf-loading:Prefetch-Resource-Validation.js** (remove prefetch to save bandwidth)
- 4. Recommend minimal resource strategy
+ 4. Run **webperf-core-web-vitals:LCP.js** (LCP is heavily impacted by slow connections)
+ 5. Recommend minimal resource strategy
- **If effectiveType is "3g"** → Moderate connection, recommend:
1. Run **webperf-loading:Find-render-blocking-resources.js** (minimize blocking)
2. Run **webperf-media:Image-Element-Audit.js** (responsive images)
3. Run **webperf-loading:Resource-Hints-Validation.js** (optimize preconnect)
- 4. Implement adaptive image quality
+ 4. Run **webperf-core-web-vitals:INP.js** (high latency can impact interaction responsiveness)
+ 5. Implement adaptive image quality
- **If effectiveType is "4g" or better** → Good connection, recommend:
1. Standard optimization practices
@@ -96,9 +97,11 @@ Use this decision tree to automatically run follow-up snippets based on results:
- **If RTT > 300ms** → High latency, recommend:
1. Run **webperf-loading:TTFB.js** (latency impacts TTFB)
- 2. Run **webperf-loading:Resource-Hints-Validation.js** (preconnect critical for high RTT)
- 3. Minimize number of origins
- 4. Use HTTP/2 or HTTP/3 for multiplexing
+ 2. Run **webperf-loading:TTFB-Sub-Parts.js** (break down latency components)
+ 3. Run **webperf-loading:Resource-Hints-Validation.js** (preconnect critical for high RTT)
+ 4. Run **webperf-loading:Service-Worker-Analysis.js** (caching is critical for high latency)
+ 5. Minimize number of origins
+ 6. Use HTTP/2 or HTTP/3 for multiplexing
- **If downlink < 1 Mbps** → Very limited bandwidth, recommend:
1. Run **webperf-media:Image-Element-Audit.js** (aggressive compression)
@@ -111,44 +114,11 @@ Use this decision tree to automatically run follow-up snippets based on results:
- Strategic prefetch
- Preloading next-page resources
-### Cross-Skill Triggers
-
-These triggers recommend using snippets from other skills:
-
-#### From Resources to Loading Skill
-
-- **If slow connection detected (2g/3g)** → Use **webperf-loading** skill:
- - TTFB.js (latency is amplified on slow connections)
- - Critical-CSS-Detection.js (reduce RTT by inlining critical CSS)
- - Find-render-blocking-resources.js (minimize blocking resources)
- - Resource-Hints-Validation.js (preconnect is critical for high RTT)
- - Prefetch-Resource-Validation.js (avoid prefetch on slow connections)
-
-- **If high RTT detected (>200ms)** → Use **webperf-loading** skill:
- - TTFB-Sub-Parts.js (break down latency components)
- - Resource-Hints-Validation.js (preconnect to reduce RTT impact)
- - Service-Worker-Analysis.js (caching is critical for high latency)
-
-#### From Resources to Media Skill
-
-- **If slow connection or save-data detected** → Use **webperf-media** skill:
- - Image-Element-Audit.js (implement responsive images, aggressive compression)
- - Video-Element-Audit.js (disable autoplay, reduce quality)
-
-#### From Resources to Core Web Vitals Skill
-
-- **If slow connection detected** → Check Core Web Vitals impact:
- - Use **webperf-core-web-vitals:LCP.js** (LCP is heavily impacted by slow connections)
- - Use **webperf-core-web-vitals:INP.js** (high latency can impact interaction responsiveness)
-
### Adaptive Loading Implementation Guide
Based on Network Information API results, implement these strategies:
-**For slow-2g / 2g (< 50 Kbps):**
-```javascript
-// Detected by effectiveType: "slow-2g" or "2g"
-Strategies:
+**For slow-2g / 2g (< 50 Kbps):** (`effectiveType: "slow-2g"` or `"2g"`)
- Serve low-res images (quality: 30-40)
- Disable autoplay videos
- Remove all prefetch hints
@@ -156,12 +126,8 @@ Strategies:
- Defer all non-critical JavaScript
- Use system fonts (no webfonts)
- Aggressive lazy loading (load on scroll + buffer)
-```
-**For 3g (50-700 Kbps):**
-```javascript
-// Detected by effectiveType: "3g"
-Strategies:
+**For 3g (50-700 Kbps):** (`effectiveType: "3g"`)
- Serve medium-res images (quality: 60-70)
- Disable autoplay videos
- Limited prefetch (critical only)
@@ -169,31 +135,21 @@ Strategies:
- Defer non-critical JavaScript
- Preload 1-2 critical fonts
- Standard lazy loading
-```
-**For 4g+ (> 700 Kbps):**
-```javascript
-// Detected by effectiveType: "4g"
-Strategies:
+**For 4g+ (> 700 Kbps):** (`effectiveType: "4g"`)
- Serve high-res images (quality: 80-90)
- Allow autoplay videos (muted)
- Strategic prefetch for navigation
-- Standard CSS loading
-- Standard JavaScript loading
+- Standard CSS loading and JavaScript loading
- Preload critical fonts
- Standard lazy loading
-```
-**For save-data mode:**
-```javascript
-// Detected by navigator.connection.saveData === true
-Strategies:
+**For save-data mode:** (`navigator.connection.saveData === true`)
- Override connection type, treat as worse than actual
- Show "Load high quality" toggle
- Disable autoplay entirely
- Minimal images, minimal quality
- No prefetch, no preload (except critical)
-```
### Performance Budget by Connection Type
@@ -217,32 +173,6 @@ Adjust performance budgets based on connection quality:
- JavaScript: < 1MB total
- Videos: < 10MB
-### Real-World Scenarios
-
-**Scenario: User on 3G reports slow page load**
-1. Run Network-Bandwidth-Connection-Quality.js → confirms 3g
-2. Run webperf-loading:TTFB.js → high TTFB due to latency
-3. Run webperf-loading:Critical-CSS-Detection.js → CSS not inlined
-4. Recommendation: Inline critical CSS, implement adaptive loading
-
-**Scenario: User with save-data enabled complains about data usage**
-1. Run Network-Bandwidth-Connection-Quality.js → saveData: true
-2. Run webperf-media:Image-Element-Audit.js → high-res images served
-3. Run webperf-media:Video-Element-Audit.js → autoplay enabled
-4. Recommendation: Respect save-data, reduce quality, disable autoplay
-
-**Scenario: International users report slow LCP**
-1. Run Network-Bandwidth-Connection-Quality.js → high RTT (300ms+)
-2. Run webperf-core-web-vitals:LCP.js → LCP 4s+
-3. Run webperf-loading:TTFB-Sub-Parts.js → DNS + connection = 500ms
-4. Recommendation: Use CDN, implement preconnect, optimize for latency
-
-**Scenario: Mobile users in rural areas**
-1. Run Network-Bandwidth-Connection-Quality.js → 2g, high RTT, low downlink
-2. Run webperf-loading:Find-render-blocking-resources.js → many blocking
-3. Run webperf-media:Image-Element-Audit.js → all images eager-loaded
-4. Recommendation: Aggressive adaptive loading, inline critical CSS, lazy load all images
-
### Network Information API Limitations
Be aware of API limitations and fallbacks:
@@ -263,27 +193,7 @@ Be aware of API limitations and fallbacks:
- Values may be rounded or capped
- Consider user privacy when making decisions
-### Testing Adaptive Loading
-
-To test adaptive loading implementations:
-
-1. Use Chrome DevTools Network Throttling
-2. Run Network-Bandwidth-Connection-Quality.js at each throttling level
-3. Verify adaptive strategies activate correctly
-4. Measure Core Web Vitals at each connection speed
-5. Adjust breakpoints and strategies based on results
-
-**Test matrix:**
-- Offline
-- slow-2g (50 Kbps, RTT 2000ms)
-- 2g (250 Kbps, RTT 300ms)
-- 3g (750 Kbps, RTT 100ms)
-- 4g (4 Mbps, RTT 20ms)
-- save-data enabled at each level
-
## References
- `references/snippets.md` — Descriptions and thresholds for each script
-- `references/schema.md` — Return value schema for interpreting script output
-
-> Execute via `mcp__chrome-devtools__evaluate_script` → read with `mcp__chrome-devtools__get_console_message`.
\ No newline at end of file
+- `references/schema.md` — Return value schema for interpreting script output
\ No newline at end of file
diff --git a/.claude/skills/webperf-resources/references/schema.md b/.claude/skills/webperf-resources/references/schema.md
index 7328e5e..f7e8b16 100644
--- a/.claude/skills/webperf-resources/references/schema.md
+++ b/.claude/skills/webperf-resources/references/schema.md
@@ -186,6 +186,7 @@ Keep the existing `async () => {}` wrapper. Add a `return` statement with struct
```
#### CLS
+Returns buffered CLS immediately and keeps tracking. Always call `getCLS()` after interactions to get an updated value.
```json
{
"script": "CLS",
@@ -194,9 +195,12 @@ Keep the existing `async () => {}` wrapper. Add a `return` statement with struct
"value": 0.05,
"unit": "score",
"rating": "good",
- "thresholds": { "good": 0.1, "needsImprovement": 0.25 }
+ "thresholds": { "good": 0.1, "needsImprovement": 0.25 },
+ "message": "CLS tracking active. Call getCLS() for updated value after page interactions.",
+ "getDataFn": "getCLS"
}
```
+`getCLS()` returns the same shape with the latest accumulated value.
#### INP (tracking)
```json
@@ -224,6 +228,15 @@ Keep the existing `async () => {}` wrapper. Add a `return` statement with struct
}
}
```
+If no interactions yet, `getINP()` returns `status: "error"` with `getDataFn: "getINP"` — retry after user interaction.
+
+`getINPDetails()` returns the full sorted interaction list (array of up to 15 entries). Use when `getINP()` shows poor INP and you need to identify patterns across multiple slow interactions:
+```json
+[
+ { "formattedName": "click → button.submit", "duration": 450, "startTime": 1200,
+ "phases": { "inputDelay": 120, "processingTime": 280, "presentationDelay": 50 } }
+]
+```
#### LCP-Sub-Parts
```json
diff --git a/.claude/skills/webperf/SKILL.md b/.claude/skills/webperf/SKILL.md
index 7dea33b..c051ef4 100644
--- a/.claude/skills/webperf/SKILL.md
+++ b/.claude/skills/webperf/SKILL.md
@@ -15,33 +15,19 @@ metadata:
A collection of 47 JavaScript snippets for measuring and debugging web performance in Chrome DevTools. Each snippet runs in the browser console and outputs structured, color-coded results.
-## Skills by Category
-
-| Skill | Snippets | Use when |
-|-------|----------|----------|
-| webperf-core-web-vitals | 7 | Intelligent Core Web Vitals analysis with automated workflows and decision trees |
-| webperf-loading | 28 | Intelligent loading performance analysis with automated workflows for TTFB investigation (DNS/connection/server breakdown), render-blocking detection, script performance deep dive (first vs third-party attribution), font optimization, and resource hints validation |
-| webperf-interaction | 8 | Intelligent interaction performance analysis with automated workflows for INP debugging, scroll jank investigation, and main thread blocking |
-| webperf-media | 3 | Intelligent media optimization with automated workflows for images, videos, and SVGs |
-| webperf-resources | 1 | Intelligent network quality analysis with adaptive loading strategies |
-
## Quick Reference
-| User says | Skill to use |
-|-----------|--------------|
-| "debug LCP", "slow LCP", "largest contentful paint" | webperf-core-web-vitals |
-| "check CLS", "layout shifts", "visual stability" | webperf-core-web-vitals |
-| "INP", "interaction latency", "responsiveness" | webperf-core-web-vitals |
-| "TTFB", "slow server", "time to first byte" | webperf-loading |
-| "FCP", "first contentful paint", "render blocking" | webperf-loading |
-| "font loading", "script loading", "resource hints", "service worker" | webperf-loading |
-| "jank", "scroll performance", "long tasks", "animation frames", "INP debug" | webperf-interaction |
-| "image audit", "lazy loading", "image optimization", "video audit" | webperf-media |
-| "network quality", "bandwidth", "connection type", "save-data" | webperf-resources |
+| Skill | Snippets | Trigger phrases |
+|-------|----------|-----------------|
+| webperf-core-web-vitals | 7 | "debug LCP", "slow LCP", "CLS", "layout shifts", "INP", "interaction latency", "responsiveness" |
+| webperf-loading | 28 | "TTFB", "slow server", "FCP", "render blocking", "font loading", "script loading", "resource hints", "service worker" |
+| webperf-interaction | 8 | "jank", "scroll performance", "long tasks", "animation frames", "INP debug" |
+| webperf-media | 3 | "image audit", "lazy loading", "image optimization", "video audit" |
+| webperf-resources | 1 | "network quality", "bandwidth", "connection type", "save-data" |
## Workflow
-1. Identify the relevant skill based on the user's question (use Quick Reference above)
+1. Identify the relevant skill based on the user's question (see Quick Reference above)
2. Load the skill's skill.md to see available snippets and thresholds
3. Execute with Chrome DevTools MCP:
- `mcp__chrome-devtools__navigate_page` → navigate to target URL
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 1aa9ba2..eae2056 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -18,7 +18,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
- node-version: '20'
+ node-version: '22'
- name: Install dependencies
run: npm ci
diff --git a/dist/webperf-core-web-vitals/CLS.js b/dist/webperf-core-web-vitals/CLS.js
new file mode 100644
index 0000000..15d7c82
--- /dev/null
+++ b/dist/webperf-core-web-vitals/CLS.js
@@ -0,0 +1,68 @@
+(() => {
+ let cls = 0;
+ const valueToRating = score => score <= 0.1 ? "good" : score <= 0.25 ? "needs-improvement" : "poor";
+ const RATING = {
+ good: {
+ icon: "🟢",
+ color: "#0CCE6A"
+ },
+ "needs-improvement": {
+ icon: "🟡",
+ color: "#FFA400"
+ },
+ poor: {
+ icon: "🔴",
+ color: "#FF4E42"
+ }
+ };
+ const logCLS = () => {
+ const rating = valueToRating(cls);
+ const {icon: icon, color: color} = RATING[rating];
+ };
+ const observer = new PerformanceObserver(list => {
+ for (const entry of list.getEntries()) if (!entry.hadRecentInput) cls += entry.value;
+ logCLS();
+ });
+ observer.observe({
+ type: "layout-shift",
+ buffered: true
+ });
+ document.addEventListener("visibilitychange", () => {
+ if (document.visibilityState === "hidden") {
+ observer.takeRecords();
+ logCLS();
+ }
+ });
+ window.getCLS = () => {
+ logCLS();
+ const rating = valueToRating(cls);
+ return {
+ script: "CLS",
+ status: "ok",
+ metric: "CLS",
+ value: Math.round(cls * 10000) / 10000,
+ unit: "score",
+ rating: rating,
+ thresholds: {
+ good: 0.1,
+ needsImprovement: 0.25
+ }
+ };
+ };
+ const clsSync = performance.getEntriesByType("layout-shift").reduce((sum, e) => !e.hadRecentInput ? sum + e.value : sum, 0);
+ const clsRating = valueToRating(clsSync);
+ return {
+ script: "CLS",
+ status: "ok",
+ metric: "CLS",
+ value: Math.round(clsSync * 10000) / 10000,
+ unit: "score",
+ rating: clsRating,
+ thresholds: {
+ good: 0.1,
+ needsImprovement: 0.25
+ },
+ message: "CLS tracking active. Call getCLS() for updated value after page interactions.",
+ getDataFn: "getCLS"
+ };
+})();
diff --git a/dist/webperf-core-web-vitals/INP.js b/dist/webperf-core-web-vitals/INP.js
new file mode 100644
index 0000000..4aa2c4a
--- /dev/null
+++ b/dist/webperf-core-web-vitals/INP.js
@@ -0,0 +1,235 @@
+(() => {
+ const interactions = [];
+ let inpValue = 0;
+ let inpEntry = null;
+ const valueToRating = ms => ms <= 200 ? "good" : ms <= 500 ? "needs-improvement" : "poor";
+ const RATING = {
+ good: {
+ icon: "🟢",
+ color: "#0CCE6A"
+ },
+ "needs-improvement": {
+ icon: "🟡",
+ color: "#FFA400"
+ },
+ poor: {
+ icon: "🔴",
+ color: "#FF4E42"
+ }
+ };
+ const calculateINP = () => {
+ if (interactions.length === 0) return {
+ value: 0,
+ entry: null
+ };
+ const sorted = [ ...interactions ].sort((a, b) => b.duration - a.duration);
+ const index = interactions.length < 50 ? 0 : Math.floor(interactions.length * 0.02);
+ return {
+ value: sorted[index].duration,
+ entry: sorted[index]
+ };
+ };
+ const getInteractionName = entry => {
+ const target = entry.target;
+ if (!target) return entry.name;
+ let selector = target.tagName.toLowerCase();
+ if (target.id) selector += `#${target.id}`; else if (target.className && typeof target.className === "string") {
+ const classes = target.className.trim().split(/\s+/).slice(0, 2).join(".");
+ if (classes) selector += `.${classes}`;
+ }
+ return `${entry.name} → ${selector}`;
+ };
+ const getPhaseBreakdown = entry => {
+ const phases = {
+ inputDelay: 0,
+ processingTime: 0,
+ presentationDelay: 0
+ };
+ if (entry.processingStart && entry.processingEnd) {
+ phases.inputDelay = entry.processingStart - entry.startTime;
+ phases.processingTime = entry.processingEnd - entry.processingStart;
+ phases.presentationDelay = entry.duration - phases.inputDelay - phases.processingTime;
+ }
+ return phases;
+ };
+ const observer = new PerformanceObserver(list => {
+ for (const entry of list.getEntries()) {
+ if (!entry.interactionId) continue;
+ const existing = interactions.find(i => i.interactionId === entry.interactionId);
+ if (!existing || entry.duration > existing.duration) {
+ if (existing) {
+ const idx = interactions.indexOf(existing);
+ interactions.splice(idx, 1);
+ }
+ interactions.push({
+ name: entry.name,
+ duration: entry.duration,
+ startTime: entry.startTime,
+ interactionId: entry.interactionId,
+ target: entry.target,
+ processingStart: entry.processingStart,
+ processingEnd: entry.processingEnd,
+ formattedName: getInteractionName(entry),
+ phases: getPhaseBreakdown(entry),
+ entry: entry
+ });
+ }
+ const result = calculateINP();
+ inpValue = result.value;
+ inpEntry = result.entry;
+ }
+ });
+ observer.observe({
+ type: "event",
+ buffered: true,
+ durationThreshold: 16
+ });
+ const logINP = () => {
+ const rating = valueToRating(inpValue);
+ const {icon: icon, color: color} = RATING[rating];
+ if (inpEntry) {
+ if (inpEntry.target) {
+ const getElementPath = el => {
+ if (!el) return "";
+ const parts = [];
+ let current = el;
+ while (current && current !== document.body && parts.length < 5) {
+ let selector = current.tagName.toLowerCase();
+ if (current.id) selector += `#${current.id}`; else if (current.className && typeof current.className === "string") {
+ const classes = current.className.trim().split(/\s+/).slice(0, 2).join(".");
+ if (classes) selector += `.${classes}`;
+ }
+ parts.unshift(selector);
+ current = current.parentElement;
+ }
+ return parts.join(" > ");
+ };
+ const path = getElementPath(inpEntry.target);
+ if (path) void 0;
+ }
+ const phases = inpEntry.phases;
+ if (phases.inputDelay > 0) {
+ const total = inpEntry.duration;
+ const barWidth = 40;
+ "▓".repeat(Math.round(phases.inputDelay / total * barWidth));
+ "█".repeat(Math.round(phases.processingTime / total * barWidth));
+ "░".repeat(Math.round(phases.presentationDelay / total * barWidth));
+ }
+ if (inpValue > 200 && phases.inputDelay > 0) {
+ if (phases.inputDelay > 100) void 0;
+ if (phases.processingTime > 200) {
+ }
+ if (phases.presentationDelay > 100) {
+ }
+ }
+ }
+ const slowInteractions = interactions.filter(i => i.duration > 200).sort((a, b) => b.duration - a.duration).slice(0, 10);
+ if (slowInteractions.length > 0) {
+ slowInteractions.slice(0, 3).forEach((interaction, idx) => {
+ if (interaction.target) void 0; else void 0;
+ });
+ }
+ const byType = {};
+ interactions.forEach(i => {
+ const type = i.name;
+ if (!byType[type]) byType[type] = {
+ count: 0,
+ totalDuration: 0,
+ maxDuration: 0
+ };
+ byType[type].count++;
+ byType[type].totalDuration += i.duration;
+ byType[type].maxDuration = Math.max(byType[type].maxDuration, i.duration);
+ });
+ if (Object.keys(byType).length > 0) {
+ }
+ if (inpValue > 200 && (!inpEntry || !inpEntry.phases || inpEntry.phases.inputDelay === 0)) {
+ }
+ };
+ window.getINP = () => {
+ const result = calculateINP();
+ inpValue = result.value;
+ inpEntry = result.entry;
+ logINP();
+ const rating = valueToRating(inpValue);
+ const details = {
+ totalInteractions: interactions.length
+ };
+ if (inpEntry) {
+ details.worstEvent = inpEntry.formattedName;
+ details.phases = {
+ inputDelay: Math.round(inpEntry.phases.inputDelay),
+ processingTime: Math.round(inpEntry.phases.processingTime),
+ presentationDelay: Math.round(inpEntry.phases.presentationDelay)
+ };
+ }
+ if (interactions.length === 0) return {
+ script: "INP",
+ status: "error",
+ error: "No interactions recorded yet. Interact with the page and call getINP() again.",
+ getDataFn: "getINP",
+ details: details
+ };
+ return {
+ script: "INP",
+ status: "ok",
+ metric: "INP",
+ value: Math.round(inpValue),
+ unit: "ms",
+ rating: rating,
+ thresholds: {
+ good: 200,
+ needsImprovement: 500
+ },
+ details: details
+ };
+ };
+ window.getINPDetails = () => {
+ if (interactions.length === 0) {
+ return [];
+ }
+ const sorted = [ ...interactions ].sort((a, b) => b.duration - a.duration);
+ const maxToShow = Math.min(sorted.length, 15);
+ sorted.slice(0, maxToShow).forEach((interaction, idx) => {
+ const phases = interaction.phases;
+ const hasPhases = phases.inputDelay > 0;
+ if (interaction.target) {
+ const getPath = el => {
+ if (!el) return "";
+ const parts = [];
+ let current = el;
+ while (current && current !== document.body && parts.length < 5) {
+ let selector = current.tagName.toLowerCase();
+ if (current.id) selector += `#${current.id}`; else if (current.className && typeof current.className === "string") {
+ const classes = current.className.trim().split(/\s+/).slice(0, 2).join(".");
+ if (classes) selector += `.${classes}`;
+ }
+ parts.unshift(selector);
+ current = current.parentElement;
+ }
+ return parts.join(" > ");
+ };
+ const path = getPath(interaction.target);
+ if (path) void 0;
+ } else void 0;
+ if (hasPhases) void 0;
+ });
+ if (sorted.length > maxToShow) void 0;
+ return sorted;
+ };
+ document.addEventListener("visibilitychange", () => {
+ if (document.visibilityState === "hidden") {
+ observer.takeRecords();
+ const result = calculateINP();
+ inpValue = result.value;
+ inpEntry = result.entry;
+ logINP();
+ }
+ });
+ return {
+ script: "INP",
+ status: "tracking",
+ message: "INP tracking active. Interact with the page then call getINP() for results.",
+ getDataFn: "getINP"
+ };
+})();
diff --git a/dist/webperf-core-web-vitals/LCP-Image-Entropy.js b/dist/webperf-core-web-vitals/LCP-Image-Entropy.js
new file mode 100644
index 0000000..d143f16
--- /dev/null
+++ b/dist/webperf-core-web-vitals/LCP-Image-Entropy.js
@@ -0,0 +1,137 @@
+(() => {
+ const formatBytes = bytes => {
+ if (!bytes) return "-";
+ const k = 1024;
+ const sizes = [ "B", "KB", "MB" ];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return (bytes / Math.pow(k, i)).toFixed(1) + " " + sizes[i];
+ };
+ const LCP_THRESHOLD = 0.05;
+ let lcpElement = null;
+ let lcpUrl = null;
+ const lcpObserver = new PerformanceObserver(list => {
+ const entries = list.getEntries();
+ const lastEntry = entries[entries.length - 1];
+ if (lastEntry) {
+ lcpElement = lastEntry.element;
+ lcpUrl = lastEntry.url;
+ }
+ });
+ lcpObserver.observe({
+ type: "largest-contentful-paint",
+ buffered: true
+ });
+ setTimeout(() => {
+ lcpObserver.disconnect();
+ const images = [ ...document.images ].filter(img => {
+ const src = img.currentSrc || img.src;
+ return src && !src.startsWith("data:image");
+ }).map(img => {
+ const src = img.currentSrc || img.src;
+ const resource = performance.getEntriesByName(src)[0];
+ const fileSize = resource?.encodedBodySize || 0;
+ const pixels = img.naturalWidth * img.naturalHeight;
+ const bpp = pixels > 0 ? fileSize * 8 / pixels : 0;
+ const isLowEntropy = bpp > 0 && bpp < LCP_THRESHOLD;
+ const isLCP = lcpElement === img || lcpUrl === src;
+ return {
+ element: img,
+ src: src,
+ shortSrc: src.split("/").pop()?.split("?")[0] || src,
+ width: img.naturalWidth,
+ height: img.naturalHeight,
+ fileSize: fileSize,
+ bpp: bpp,
+ isLowEntropy: isLowEntropy,
+ isLCP: isLCP,
+ lcpEligible: !isLowEntropy && bpp > 0
+ };
+ }).filter(img => img.bpp > 0);
+ if (images.length === 0) {
+ return;
+ }
+ const lowEntropy = images.filter(img => img.isLowEntropy);
+ images.filter(img => !img.isLowEntropy);
+ const lcpImage = images.find(img => img.isLCP);
+ if (lcpImage) {
+ lcpImage.isLowEntropy;
+ }
+ images.sort((a, b) => b.bpp - a.bpp).map(img => ({
+ Image: img.shortSrc.length > 30 ? "..." + img.shortSrc.slice(-27) : img.shortSrc,
+ Dimensions: `${img.width}×${img.height}`,
+ Size: formatBytes(img.fileSize),
+ BPP: img.bpp.toFixed(4),
+ Entropy: img.isLowEntropy ? "🔴 Low" : "🟢 Normal",
+ "LCP Eligible": img.lcpEligible ? "✅" : "❌",
+ "Is LCP": img.isLCP ? "👈" : ""
+ }));
+ if (lowEntropy.length > 0) {
+ lowEntropy.forEach(img => {
+ });
+ }
+ if (lcpImage && lcpImage.isLowEntropy) {
+ }
+ images.forEach((img, i) => {
+ img.isLowEntropy;
+ img.isLCP;
+ });
+ }, 100);
+ const lcpEntriesSync = performance.getEntriesByType("largest-contentful-paint");
+ const lcpEntrySync = lcpEntriesSync.at(-1);
+ const lcpElementSync = lcpEntrySync?.element ?? null;
+ const lcpUrlSync = lcpEntrySync?.url ?? null;
+ const imagesSync = [ ...document.images ].filter(img => {
+ const src = img.currentSrc || img.src;
+ return src && !src.startsWith("data:image");
+ }).map(img => {
+ const src = img.currentSrc || img.src;
+ const resource = performance.getEntriesByName(src)[0];
+ const fileSize = resource?.encodedBodySize || 0;
+ const pixels = img.naturalWidth * img.naturalHeight;
+ const bpp = pixels > 0 ? fileSize * 8 / pixels : 0;
+ const isLowEntropy = bpp > 0 && bpp < LCP_THRESHOLD;
+ const isLCP = lcpElementSync === img || lcpUrlSync === src;
+ return {
+ url: src.split("/").pop()?.split("?")[0] || src,
+ width: img.naturalWidth,
+ height: img.naturalHeight,
+ fileSizeBytes: fileSize,
+ bpp: Math.round(bpp * 10000) / 10000,
+ isLowEntropy: isLowEntropy,
+ lcpEligible: !isLowEntropy && bpp > 0,
+ isLCP: isLCP
+ };
+ }).filter(img => img.bpp > 0);
+ const lowEntropyCount = imagesSync.filter(img => img.isLowEntropy).length;
+ const lcpImageSync = imagesSync.find(img => img.isLCP);
+ if (lcpElementSync) {
+ lcpElementSync.style.outline = "3px dashed lime";
+ lcpElementSync.style.outlineOffset = "2px";
+ }
+ const issuesSync = [];
+ if (lowEntropyCount > 0) issuesSync.push({
+ severity: "warning",
+ message: `${lowEntropyCount} image(s) have low entropy and are LCP-ineligible in Chrome 112+`
+ });
+ if (lcpImageSync?.isLowEntropy) issuesSync.push({
+ severity: "error",
+ message: "Current LCP image has low entropy and may be skipped by Chrome"
+ });
+ return {
+ script: "LCP-Image-Entropy",
+ status: "ok",
+ count: imagesSync.length,
+ details: {
+ totalImages: imagesSync.length,
+ lowEntropyCount: lowEntropyCount,
+ lcpImageEligible: lcpImageSync ? !lcpImageSync.isLowEntropy : null,
+ lcpImage: lcpImageSync ? {
+ url: lcpImageSync.url,
+ bpp: lcpImageSync.bpp,
+ isLowEntropy: lcpImageSync.isLowEntropy
+ } : null
+ },
+ items: imagesSync,
+ issues: issuesSync
+ };
+})();
diff --git a/dist/webperf-core-web-vitals/LCP-Sub-Parts.js b/dist/webperf-core-web-vitals/LCP-Sub-Parts.js
new file mode 100644
index 0000000..38fb6b1
--- /dev/null
+++ b/dist/webperf-core-web-vitals/LCP-Sub-Parts.js
@@ -0,0 +1,226 @@
+(() => {
+ const formatMs = ms => `${Math.round(ms)}ms`;
+ const formatPercent = (value, total) => `${Math.round(value / total * 100)}%`;
+ const valueToRating = ms => ms <= 2500 ? "good" : ms <= 4000 ? "needs-improvement" : "poor";
+ const RATING = {
+ good: {
+ icon: "🟢",
+ color: "#0CCE6A"
+ },
+ "needs-improvement": {
+ icon: "🟡",
+ color: "#FFA400"
+ },
+ poor: {
+ icon: "🔴",
+ color: "#FF4E42"
+ }
+ };
+ const SUB_PARTS = [ {
+ name: "Time to First Byte",
+ key: "ttfb",
+ target: 800
+ }, {
+ name: "Resource Load Delay",
+ key: "resourceLoadDelay",
+ targetPercent: 10
+ }, {
+ name: "Resource Load Time",
+ key: "resourceLoadTime",
+ targetPercent: 40
+ }, {
+ name: "Element Render Delay",
+ key: "elementRenderDelay",
+ targetPercent: 10
+ } ];
+ const getNavigationEntry = () => {
+ const navEntry = performance.getEntriesByType("navigation")[0];
+ if (navEntry?.responseStart > 0 && navEntry.responseStart < performance.now()) return navEntry;
+ return null;
+ };
+ const observer = new PerformanceObserver(list => {
+ const lcpEntry = list.getEntries().at(-1);
+ if (!lcpEntry) return;
+ const navEntry = getNavigationEntry();
+ if (!navEntry) return;
+ const lcpResEntry = performance.getEntriesByType("resource").find(e => e.name === lcpEntry.url);
+ const activationStart = navEntry.activationStart || 0;
+ const ttfb = Math.max(0, navEntry.responseStart - activationStart);
+ const lcpRequestStart = Math.max(ttfb, lcpResEntry ? (lcpResEntry.requestStart || lcpResEntry.startTime) - activationStart : 0);
+ const lcpResponseEnd = Math.max(lcpRequestStart, lcpResEntry ? lcpResEntry.responseEnd - activationStart : 0);
+ const lcpRenderTime = Math.max(lcpResponseEnd, lcpEntry.startTime - activationStart);
+ const subPartValues = {
+ ttfb: ttfb,
+ resourceLoadDelay: lcpRequestStart - ttfb,
+ resourceLoadTime: lcpResponseEnd - lcpRequestStart,
+ elementRenderDelay: lcpRenderTime - lcpResponseEnd
+ };
+ const rating = valueToRating(lcpRenderTime);
+ const {icon: icon, color: color} = RATING[rating];
+ if (lcpEntry.element) {
+ const el = lcpEntry.element;
+ let selector = el.tagName.toLowerCase();
+ if (el.id) selector = `#${el.id}`; else if (el.className && typeof el.className === "string") {
+ const classes = el.className.trim().split(/\s+/).slice(0, 2).join(".");
+ if (classes) selector = `${el.tagName.toLowerCase()}.${classes}`;
+ }
+ if (lcpEntry.url) {
+ (() => {
+ try {
+ const u = new URL(lcpEntry.url);
+ return u.hostname !== location.hostname ? `${u.hostname}/…/${u.pathname.split("/").pop()?.split("?")[0]}` : u.pathname.split("/").pop()?.split("?")[0] || lcpEntry.url;
+ } catch {
+ return lcpEntry.url;
+ }
+ })();
+ }
+ el.style.outline = "3px dashed lime";
+ el.style.outlineOffset = "2px";
+ }
+ const phases = SUB_PARTS.map(part => ({
+ ...part,
+ value: subPartValues[part.key],
+ percent: subPartValues[part.key] / lcpRenderTime * 100
+ }));
+ const slowest = phases.reduce((a, b) => a.value > b.value ? a : b);
+ phases.map(part => {
+ const isSlowest = part.key === slowest.key;
+ const isOverTarget = part.target ? part.value > part.target : part.percent > part.targetPercent;
+ return {
+ "Sub-part": isSlowest ? `⚠️ ${part.name}` : part.name,
+ Time: formatMs(part.value),
+ "%": formatPercent(part.value, lcpRenderTime),
+ Status: isOverTarget ? "🔴 Over target" : "✅ OK"
+ };
+ });
+ const barWidth = 40;
+ const bars = phases.map(p => {
+ const width = Math.max(1, Math.round(p.value / lcpRenderTime * barWidth));
+ return {
+ key: p.key,
+ bar: width
+ };
+ });
+ "█".repeat(bars[0].bar);
+ "▓".repeat(bars[1].bar);
+ "▒".repeat(bars[2].bar);
+ "░".repeat(bars[3].bar);
+ if (slowest.key === "ttfb") {
+ } else if (slowest.key === "resourceLoadDelay") {
+ } else if (slowest.key === "resourceLoadTime") {
+ } else if (slowest.key === "elementRenderDelay") {
+ }
+ SUB_PARTS.forEach(part => performance.clearMeasures(part.name));
+ phases.forEach(part => {
+ const startTimes = {
+ ttfb: 0,
+ resourceLoadDelay: ttfb,
+ resourceLoadTime: lcpRequestStart,
+ elementRenderDelay: lcpResponseEnd
+ };
+ performance.measure(part.name, {
+ start: startTimes[part.key],
+ end: startTimes[part.key] + part.value
+ });
+ });
+ });
+ observer.observe({
+ type: "largest-contentful-paint",
+ buffered: true
+ });
+ const lcpBuffered = performance.getEntriesByType("largest-contentful-paint");
+ const lcpEntry = lcpBuffered.at(-1);
+ if (!lcpEntry) return {
+ script: "LCP-Sub-Parts",
+ status: "error",
+ error: "No LCP entries yet"
+ };
+ const navEntrySync = getNavigationEntry();
+ if (!navEntrySync) return {
+ script: "LCP-Sub-Parts",
+ status: "error",
+ error: "No navigation entry"
+ };
+ const lcpResEntrySync = performance.getEntriesByType("resource").find(e => e.name === lcpEntry.url);
+ const activationStartSync = navEntrySync.activationStart || 0;
+ const ttfbSync = Math.max(0, navEntrySync.responseStart - activationStartSync);
+ const lcpRequestStartSync = Math.max(ttfbSync, lcpResEntrySync ? (lcpResEntrySync.requestStart || lcpResEntrySync.startTime) - activationStartSync : 0);
+ const lcpResponseEndSync = Math.max(lcpRequestStartSync, lcpResEntrySync ? lcpResEntrySync.responseEnd - activationStartSync : 0);
+ const lcpRenderTimeSync = Math.max(lcpResponseEndSync, lcpEntry.startTime - activationStartSync);
+ const totalSync = Math.round(lcpRenderTimeSync);
+ const ratingSync = valueToRating(totalSync);
+ const ttfbVal = Math.round(ttfbSync);
+ const loadDelayVal = Math.round(lcpRequestStartSync - ttfbSync);
+ const loadTimeVal = Math.round(lcpResponseEndSync - lcpRequestStartSync);
+ const renderDelayVal = Math.round(lcpRenderTimeSync - lcpResponseEndSync);
+ const subPartsForRank = [ {
+ key: "ttfb",
+ value: ttfbVal
+ }, {
+ key: "resourceLoadDelay",
+ value: loadDelayVal
+ }, {
+ key: "resourceLoadTime",
+ value: loadTimeVal
+ }, {
+ key: "elementRenderDelay",
+ value: renderDelayVal
+ } ];
+ const slowestPhaseSync = subPartsForRank.reduce((a, b) => a.value > b.value ? a : b).key;
+ let lcpSelectorSync = null;
+ if (lcpEntry.element) {
+ const el = lcpEntry.element;
+ lcpSelectorSync = el.tagName.toLowerCase();
+ if (el.id) lcpSelectorSync = `#${el.id}`; else if (el.className && typeof el.className === "string") {
+ const classes = el.className.trim().split(/\s+/).slice(0, 2).join(".");
+ if (classes) lcpSelectorSync = `${el.tagName.toLowerCase()}.${classes}`;
+ }
+ }
+ const shortUrlSync = lcpEntry.url ? (() => {
+ try {
+ const u = new URL(lcpEntry.url);
+ return u.hostname !== location.hostname ? `${u.hostname}/…/${u.pathname.split("/").pop()?.split("?")[0]}` : u.pathname.split("/").pop()?.split("?")[0] || lcpEntry.url;
+ } catch {
+ return lcpEntry.url;
+ }
+ })() : null;
+ return {
+ script: "LCP-Sub-Parts",
+ status: "ok",
+ metric: "LCP",
+ value: totalSync,
+ unit: "ms",
+ rating: ratingSync,
+ thresholds: {
+ good: 2500,
+ needsImprovement: 4000
+ },
+ details: {
+ element: lcpSelectorSync,
+ url: shortUrlSync,
+ subParts: {
+ ttfb: {
+ value: ttfbVal,
+ percent: Math.round(ttfbVal / totalSync * 100),
+ overTarget: ttfbVal > 800
+ },
+ resourceLoadDelay: {
+ value: loadDelayVal,
+ percent: Math.round(loadDelayVal / totalSync * 100),
+ overTarget: loadDelayVal / totalSync * 100 > 10
+ },
+ resourceLoadTime: {
+ value: loadTimeVal,
+ percent: Math.round(loadTimeVal / totalSync * 100),
+ overTarget: loadTimeVal / totalSync * 100 > 40
+ },
+ elementRenderDelay: {
+ value: renderDelayVal,
+ percent: Math.round(renderDelayVal / totalSync * 100),
+ overTarget: renderDelayVal / totalSync * 100 > 10
+ }
+ },
+ slowestPhase: slowestPhaseSync
+ }
+ };
+})();
diff --git a/dist/webperf-core-web-vitals/LCP-Trail.js b/dist/webperf-core-web-vitals/LCP-Trail.js
new file mode 100644
index 0000000..81eb87e
--- /dev/null
+++ b/dist/webperf-core-web-vitals/LCP-Trail.js
@@ -0,0 +1,164 @@
+(() => {
+ const PALETTE = [ {
+ color: "#EF4444",
+ name: "Red"
+ }, {
+ color: "#F97316",
+ name: "Orange"
+ }, {
+ color: "#22C55E",
+ name: "Green"
+ }, {
+ color: "#3B82F6",
+ name: "Blue"
+ }, {
+ color: "#A855F7",
+ name: "Purple"
+ }, {
+ color: "#EC4899",
+ name: "Pink"
+ } ];
+ const valueToRating = ms => ms <= 2500 ? "good" : ms <= 4000 ? "needs-improvement" : "poor";
+ const RATING = {
+ good: {
+ icon: "🟢",
+ color: "#0CCE6A"
+ },
+ "needs-improvement": {
+ icon: "🟡",
+ color: "#FFA400"
+ },
+ poor: {
+ icon: "🔴",
+ color: "#FF4E42"
+ }
+ };
+ const getActivationStart = () => {
+ const navEntry = performance.getEntriesByType("navigation")[0];
+ return navEntry?.activationStart || 0;
+ };
+ const getSelector = element => {
+ if (element.id) return `#${element.id}`;
+ if (element.className && typeof element.className === "string") {
+ const classes = element.className.trim().split(/\s+/).slice(0, 2).join(".");
+ if (classes) return `${element.tagName.toLowerCase()}.${classes}`;
+ }
+ return element.tagName.toLowerCase();
+ };
+ const getElementInfo = (element, entry) => {
+ const tag = element.tagName.toLowerCase();
+ if (tag === "img") return {
+ type: "Image",
+ url: entry.url || element.src
+ };
+ if (tag === "video") return {
+ type: "Video poster",
+ url: entry.url || element.poster
+ };
+ if (window.getComputedStyle(element).backgroundImage !== "none") return {
+ type: "Background image",
+ url: entry.url
+ };
+ return {
+ type: tag === "h1" || tag === "p" ? "Text block" : tag
+ };
+ };
+ const candidates = [];
+ const logTrail = () => {
+ const current = candidates[candidates.length - 1];
+ if (!current) return;
+ const rating = valueToRating(current.time);
+ const {icon: icon, color: ratingColor} = RATING[rating];
+ const {type: type, url: url} = getElementInfo(current.element, current.entry);
+ if (url) void 0;
+ if (current.element.naturalWidth) void 0;
+ if (current.entry.size) void 0;
+ candidates.forEach(({index: index, selector: selector, color: color, name: name, time: time, element: element}) => {
+ candidates.length;
+ });
+ };
+ const observer = new PerformanceObserver(list => {
+ const activationStart = getActivationStart();
+ const seen = new Set(candidates.map(c => c.element));
+ for (const entry of list.getEntries()) {
+ const {element: element} = entry;
+ if (!element || seen.has(element)) continue;
+ const {color: color, name: name} = PALETTE[candidates.length % PALETTE.length];
+ element.style.outline = `3px dashed ${color}`;
+ element.style.outlineOffset = "2px";
+ candidates.push({
+ index: candidates.length + 1,
+ element: element,
+ selector: getSelector(element),
+ color: color,
+ name: name,
+ time: Math.max(0, entry.startTime - activationStart),
+ entry: entry
+ });
+ seen.add(element);
+ }
+ logTrail();
+ });
+ observer.observe({
+ type: "largest-contentful-paint",
+ buffered: true
+ });
+ const trailEntries = performance.getEntriesByType("largest-contentful-paint");
+ if (trailEntries.length === 0) return {
+ script: "LCP-Trail",
+ status: "error",
+ error: "No LCP entries yet"
+ };
+ const trailActivationStart = getActivationStart();
+ const seenEls = new Set;
+ const syncCandidates = [];
+ for (const entry of trailEntries) {
+ const el = entry.element;
+ if (!el || seenEls.has(el)) continue;
+ seenEls.add(el);
+ const selector = getSelector(el);
+ const time = Math.round(Math.max(0, entry.startTime - trailActivationStart));
+ const {type: type, url: url} = getElementInfo(el, entry);
+ syncCandidates.push({
+ index: syncCandidates.length + 1,
+ selector: selector,
+ time: time,
+ elementType: type,
+ ...url ? {
+ url: (() => {
+ try {
+ const u = new URL(url);
+ return u.hostname !== location.hostname ? `${u.hostname}/…/${u.pathname.split("/").pop()?.split("?")[0]}` : u.pathname.split("/").pop()?.split("?")[0] || url;
+ } catch {
+ return url;
+ }
+ })()
+ } : {}
+ });
+ }
+ if (syncCandidates.length === 0) return {
+ script: "LCP-Trail",
+ status: "error",
+ error: "No LCP elements in DOM"
+ };
+ const lastCandidate = syncCandidates.at(-1);
+ const trailValue = lastCandidate.time;
+ const trailRating = valueToRating(trailValue);
+ return {
+ script: "LCP-Trail",
+ status: "ok",
+ metric: "LCP",
+ value: trailValue,
+ unit: "ms",
+ rating: trailRating,
+ thresholds: {
+ good: 2500,
+ needsImprovement: 4000
+ },
+ details: {
+ candidateCount: syncCandidates.length,
+ finalElement: lastCandidate.selector,
+ candidates: syncCandidates
+ }
+ };
+})();
diff --git a/dist/webperf-core-web-vitals/LCP-Video-Candidate.js b/dist/webperf-core-web-vitals/LCP-Video-Candidate.js
new file mode 100644
index 0000000..84d8ea4
--- /dev/null
+++ b/dist/webperf-core-web-vitals/LCP-Video-Candidate.js
@@ -0,0 +1,145 @@
+(() => {
+ const lcpEntries = performance.getEntriesByType("largest-contentful-paint");
+ if (lcpEntries.length === 0) {
+ return {
+ script: "LCP-Video-Candidate",
+ status: "error",
+ error: "No LCP entries found"
+ };
+ }
+ const lcp = lcpEntries[lcpEntries.length - 1];
+ const element = lcp.element;
+ function valueToRating(ms) {
+ return ms <= 2500 ? "good" : ms <= 4000 ? "needs-improvement" : "poor";
+ }
+ function detectFormat(url) {
+ if (!url) return "unknown";
+ const path = url.toLowerCase().split("?")[0];
+ const ext = path.match(/\.(avif|webp|jxl|png|gif|jpg|jpeg|svg)(?:[?#]|$)/);
+ if (ext) return ext[1] === "jpeg" ? "jpg" : ext[1];
+ return "unknown";
+ }
+ function normalizeUrl(url) {
+ try {
+ return new URL(url, location.origin).href;
+ } catch {
+ return url;
+ }
+ }
+ const activationStart = (() => {
+ const nav = performance.getEntriesByType("navigation")[0];
+ return nav?.activationStart || 0;
+ })();
+ const lcpTime = Math.round(Math.max(0, lcp.startTime - activationStart));
+ if (!element || element.tagName !== "VIDEO") {
+ element && element.tagName.toLowerCase();
+ const rating = valueToRating(lcpTime);
+ if (lcp.url) void 0;
+ if (element) void 0;
+ return {
+ script: "LCP-Video-Candidate",
+ status: "ok",
+ metric: "LCP",
+ value: lcpTime,
+ unit: "ms",
+ rating: rating,
+ thresholds: {
+ good: 2500,
+ needsImprovement: 4000
+ },
+ details: {
+ isVideo: false
+ },
+ issues: []
+ };
+ }
+ const posterAttr = element.getAttribute("poster") || "";
+ const posterUrl = posterAttr ? normalizeUrl(posterAttr) : "";
+ const lcpUrl = lcp.url || "";
+ const rating = valueToRating(lcpTime);
+ const posterFormat = detectFormat(lcpUrl || posterUrl);
+ const isModernFormat = [ "avif", "webp", "jxl" ].includes(posterFormat);
+ const isCrossOrigin = lcp.renderTime === 0 && lcp.loadTime > 0;
+ const preloadLinks = Array.from(document.querySelectorAll('link[rel="preload"][as="image"]'));
+ const posterPreload = preloadLinks.find(link => {
+ const href = link.getAttribute("href");
+ if (!href) return false;
+ try {
+ return normalizeUrl(href) === posterUrl || normalizeUrl(href) === lcpUrl;
+ } catch {
+ return false;
+ }
+ }) ?? null;
+ const preload = element.getAttribute("preload");
+ const autoplay = element.hasAttribute("autoplay");
+ const muted = element.hasAttribute("muted") || element.muted;
+ const playsinline = element.hasAttribute("playsinline");
+ const issues = [];
+ if (!posterAttr) issues.push({
+ s: "error",
+ msg: "No poster attribute — the browser has no image to use as LCP candidate"
+ });
+ if (posterAttr && !posterPreload) issues.push({
+ s: "warning",
+ msg: 'No for the poster — browser discovers it late'
+ }); else if (posterPreload && posterPreload.getAttribute("fetchpriority") !== "high") issues.push({
+ s: "info",
+ msg: 'Preload found but missing fetchpriority="high" — may be deprioritised'
+ });
+ if (posterAttr && !isModernFormat && posterFormat !== "unknown") issues.push({
+ s: "info",
+ msg: `Poster uses ${posterFormat} — AVIF or WebP would reduce file size and LCP load time`
+ });
+ if (isCrossOrigin) issues.push({
+ s: "info",
+ msg: "renderTime is 0 — poster is cross-origin and the server does not send Timing-Allow-Origin"
+ });
+ if (!autoplay && preload === "none") issues.push({
+ s: "warning",
+ msg: 'preload="none" on a non-autoplay video may delay poster image loading in some browsers'
+ });
+ if (posterPreload) {
+ posterPreload.getAttribute("fetchpriority");
+ } else {
+ if (posterAttr) void 0;
+ }
+ if (issues.length > 0) {
+ issues.filter(i => i.s === "error").length;
+ issues.filter(i => i.s === "warning").length;
+ issues.filter(i => i.s === "info").length;
+ issues.forEach(issue => {
+ issue.s === "error" || issue.s;
+ });
+ } else {
+ }
+ return {
+ script: "LCP-Video-Candidate",
+ status: "ok",
+ metric: "LCP",
+ value: lcpTime,
+ unit: "ms",
+ rating: rating,
+ thresholds: {
+ good: 2500,
+ needsImprovement: 4000
+ },
+ details: {
+ isVideo: true,
+ posterUrl: lcpUrl || posterUrl || null,
+ posterFormat: posterFormat,
+ posterPreloaded: !!posterPreload,
+ fetchpriorityOnPreload: posterPreload?.getAttribute("fetchpriority") ?? null,
+ isCrossOrigin: isCrossOrigin,
+ videoAttributes: {
+ autoplay: autoplay,
+ muted: muted,
+ playsinline: playsinline,
+ preload: preload
+ }
+ },
+ issues: issues.map(i => ({
+ severity: i.s === "error" ? "error" : i.s === "warning" ? "warning" : "info",
+ message: i.msg
+ }))
+ };
+})();
diff --git a/dist/webperf-core-web-vitals/LCP.js b/dist/webperf-core-web-vitals/LCP.js
new file mode 100644
index 0000000..5816c73
--- /dev/null
+++ b/dist/webperf-core-web-vitals/LCP.js
@@ -0,0 +1,91 @@
+(() => {
+ const valueToRating = ms => ms <= 2500 ? "good" : ms <= 4000 ? "needs-improvement" : "poor";
+ const RATING = {
+ good: {
+ icon: "🟢",
+ color: "#0CCE6A"
+ },
+ "needs-improvement": {
+ icon: "🟡",
+ color: "#FFA400"
+ },
+ poor: {
+ icon: "🔴",
+ color: "#FF4E42"
+ }
+ };
+ const getActivationStart = () => {
+ const navEntry = performance.getEntriesByType("navigation")[0];
+ return navEntry?.activationStart || 0;
+ };
+ const observer = new PerformanceObserver(list => {
+ const entries = list.getEntries();
+ const lastEntry = entries[entries.length - 1];
+ if (!lastEntry) return;
+ const activationStart = getActivationStart();
+ const lcpTime = Math.max(0, lastEntry.startTime - activationStart);
+ const rating = valueToRating(lcpTime);
+ const {icon: icon, color: color} = RATING[rating];
+ const element = lastEntry.element;
+ if (element) {
+ let selector = element.tagName.toLowerCase();
+ if (element.id) selector = `#${element.id}`; else if (element.className && typeof element.className === "string") {
+ const classes = element.className.trim().split(/\s+/).slice(0, 2).join(".");
+ if (classes) selector = `${element.tagName.toLowerCase()}.${classes}`;
+ }
+ const tagName = element.tagName.toLowerCase();
+ if (tagName === "img") {
+ if (element.naturalWidth) void 0;
+ } else if (tagName === "video") {
+ } else if (window.getComputedStyle(element).backgroundImage !== "none") {
+ } else void 0;
+ if (lastEntry.size) void 0;
+ element.style.outline = "3px dashed lime";
+ element.style.outlineOffset = "2px";
+ }
+ });
+ observer.observe({
+ type: "largest-contentful-paint",
+ buffered: true
+ });
+ const lcpEntries = performance.getEntriesByType("largest-contentful-paint");
+ const lastLcpEntry = lcpEntries.at(-1);
+ if (!lastLcpEntry) return {
+ script: "LCP",
+ status: "error",
+ error: "No LCP entries yet"
+ };
+ const lcpActivationStart = getActivationStart();
+ const lcpValue = Math.round(Math.max(0, lastLcpEntry.startTime - lcpActivationStart));
+ const lcpRating = valueToRating(lcpValue);
+ const lcpEl = lastLcpEntry.element;
+ let lcpSelector = null;
+ let lcpType = null;
+ if (lcpEl) {
+ lcpSelector = lcpEl.tagName.toLowerCase();
+ if (lcpEl.id) lcpSelector = `#${lcpEl.id}`; else if (lcpEl.className && typeof lcpEl.className === "string") {
+ const classes = lcpEl.className.trim().split(/\s+/).slice(0, 2).join(".");
+ if (classes) lcpSelector = `${lcpEl.tagName.toLowerCase()}.${classes}`;
+ }
+ const tag = lcpEl.tagName.toLowerCase();
+ lcpType = tag === "img" ? "Image" : tag === "video" ? "Video poster" : window.getComputedStyle(lcpEl).backgroundImage !== "none" ? "Background image" : tag === "h1" || tag === "p" ? "Text block" : tag;
+ }
+ return {
+ script: "LCP",
+ status: "ok",
+ metric: "LCP",
+ value: lcpValue,
+ unit: "ms",
+ rating: lcpRating,
+ thresholds: {
+ good: 2500,
+ needsImprovement: 4000
+ },
+ details: {
+ element: lcpSelector,
+ elementType: lcpType,
+ url: lastLcpEntry.url || null,
+ sizePixels: lastLcpEntry.size || null
+ }
+ };
+})();
diff --git a/dist/webperf-interaction/Input-Latency-Breakdown.js b/dist/webperf-interaction/Input-Latency-Breakdown.js
new file mode 100644
index 0000000..deb017d
--- /dev/null
+++ b/dist/webperf-interaction/Input-Latency-Breakdown.js
@@ -0,0 +1,108 @@
+(() => {
+ const valueToRating = score => score <= 200 ? "good" : score <= 500 ? "needs-improvement" : "poor";
+ const RATING_COLORS = {
+ good: "#0CCE6A",
+ "needs-improvement": "#FFA400",
+ poor: "#FF4E42"
+ };
+ const RATING_ICONS = {
+ good: "🟢",
+ "needs-improvement": "🟡",
+ poor: "🔴"
+ };
+ const byEventType = {};
+ const observer = new PerformanceObserver(list => {
+ const interactions = {};
+ for (const entry of list.getEntries().filter(e => e.interactionId)) {
+ interactions[entry.interactionId] = interactions[entry.interactionId] || [];
+ interactions[entry.interactionId].push(entry);
+ }
+ for (const group of Object.values(interactions)) {
+ const entry = group.reduce((prev, curr) => prev.duration >= curr.duration ? prev : curr);
+ const eventType = entry.name;
+ const inputDelay = entry.processingStart - entry.startTime;
+ const processingTime = entry.processingEnd - entry.processingStart;
+ const presentationDelay = Math.max(4, entry.startTime + entry.duration - entry.processingEnd);
+ if (!byEventType[eventType]) byEventType[eventType] = {
+ count: 0,
+ durations: [],
+ inputDelays: [],
+ processingTimes: [],
+ presentationDelays: []
+ };
+ const bucket = byEventType[eventType];
+ bucket.count++;
+ bucket.durations.push(entry.duration);
+ bucket.inputDelays.push(inputDelay);
+ bucket.processingTimes.push(processingTime);
+ bucket.presentationDelays.push(presentationDelay);
+ }
+ });
+ observer.observe({
+ type: "event",
+ durationThreshold: 0,
+ buffered: true
+ });
+ const p75 = arr => {
+ const sorted = [ ...arr ].sort((a, b) => a - b);
+ return sorted[Math.floor(sorted.length * 0.75)] ?? sorted[sorted.length - 1];
+ };
+ window.getInputLatencyBreakdown = () => {
+ const types = Object.keys(byEventType);
+ if (types.length === 0) {
+ return;
+ }
+ for (const eventType of types.sort()) {
+ const b = byEventType[eventType];
+ const p75Total = p75(b.durations);
+ const p75InputDelay = p75(b.inputDelays);
+ const p75Processing = p75(b.processingTimes);
+ const p75Presentation = p75(b.presentationDelays);
+ const p75Sum = p75InputDelay + p75Processing + p75Presentation;
+ const phases = [ {
+ name: "Input Delay",
+ value: p75InputDelay
+ }, {
+ name: "Processing",
+ value: p75Processing
+ }, {
+ name: "Presentation",
+ value: p75Presentation
+ } ];
+ phases.reduce((a, b) => a.value > b.value ? a : b);
+ const rating = valueToRating(p75Total);
+ RATING_ICONS[rating];
+ RATING_COLORS[rating];
+ const barWidth = 36;
+ "█".repeat(Math.max(1, Math.round(p75InputDelay / p75Sum * barWidth)));
+ "▓".repeat(Math.max(1, Math.round(p75Processing / p75Sum * barWidth)));
+ "░".repeat(Math.max(1, Math.round(p75Presentation / p75Sum * barWidth)));
+ if (rating !== "good") void 0;
+ }
+ const worstType = types.reduce((a, b) => p75(byEventType[a].durations) >= p75(byEventType[b].durations) ? a : b);
+ const worstP75 = p75(byEventType[worstType].durations);
+ if (valueToRating(worstP75) !== "good") void 0;
+ const typeSummary = {};
+ for (const [type, b] of Object.entries(byEventType)) typeSummary[type] = {
+ count: b.count,
+ p75Ms: Math.round(p75(b.durations)),
+ inputDelayMs: Math.round(p75(b.inputDelays)),
+ processingMs: Math.round(p75(b.processingTimes)),
+ presentationMs: Math.round(p75(b.presentationDelays))
+ };
+ return {
+ script: "Input-Latency-Breakdown",
+ status: "ok",
+ count: types.length,
+ details: {
+ eventTypes: typeSummary
+ }
+ };
+ };
+ return {
+ script: "Input-Latency-Breakdown",
+ status: "tracking",
+ message: "Tracking input latency by event type. Interact with the page then call getInputLatencyBreakdown().",
+ getDataFn: "getInputLatencyBreakdown"
+ };
+})();
diff --git a/dist/webperf-interaction/Interactions.js b/dist/webperf-interaction/Interactions.js
new file mode 100644
index 0000000..a30bfdf
--- /dev/null
+++ b/dist/webperf-interaction/Interactions.js
@@ -0,0 +1,222 @@
+(() => {
+ const formatMs = (ms) => `${Math.round(ms)}ms`;
+
+ // INP thresholds
+ const valueToRating = (score) =>
+ score <= 200 ? "good" : score <= 500 ? "needs-improvement" : "poor";
+
+ const RATING_COLORS = {
+ good: "#0CCE6A",
+ "needs-improvement": "#FFA400",
+ poor: "#FF4E42",
+ };
+
+ const RATING_ICONS = {
+ good: "🟢",
+ "needs-improvement": "🟡",
+ poor: "🔴",
+ };
+
+ // Track all interactions for summary
+ const allInteractions = [];
+
+ const observer = new PerformanceObserver((list) => {
+ const interactions = {};
+
+ for (const entry of list
+ .getEntries()
+ .filter((entry) => entry.interactionId)) {
+ interactions[entry.interactionId] = interactions[entry.interactionId] || [];
+ interactions[entry.interactionId].push(entry);
+ }
+
+ for (const interaction of Object.values(interactions)) {
+ const entry = interaction.reduce((prev, curr) =>
+ prev.duration >= curr.duration ? prev : curr
+ );
+
+ const value = entry.duration;
+ const rating = valueToRating(value);
+ const icon = RATING_ICONS[rating];
+ const color = RATING_COLORS[rating];
+
+ // Store for summary
+ allInteractions.push({
+ duration: value,
+ rating,
+ target: entry.target,
+ type: entry.name,
+ });
+
+ // Calculate sub-parts
+ const inputDelay = entry.processingStart - entry.startTime;
+ const processingTime = entry.processingEnd - entry.processingStart;
+ const presentationDelay = Math.max(
+ 4,
+ entry.startTime + entry.duration - entry.processingEnd
+ );
+ const total = inputDelay + processingTime + presentationDelay;
+
+ // Find longest sub-part
+ const subParts = [
+ { name: "Input Delay", value: inputDelay },
+ { name: "Processing Time", value: processingTime },
+ { name: "Presentation Delay", value: presentationDelay },
+ ];
+ const longest = subParts.reduce((a, b) => (a.value > b.value ? a : b));
+
+ console.groupCollapsed(
+ `%c${icon} Interaction: ${formatMs(value)} (${rating})`,
+ `font-weight: bold; color: ${color};`
+ );
+
+ // Target info
+ console.log("%cTarget:", "font-weight: bold;", entry.target);
+ console.log(` Event type: ${entry.name}`);
+
+ // Sub-parts breakdown
+ console.log("");
+ console.log("%cSub-parts breakdown:", "font-weight: bold;");
+
+ const tableData = subParts.map((part) => {
+ const percent = ((part.value / total) * 100).toFixed(0);
+ const isLongest = part.name === longest.name;
+ return {
+ "Sub-part": isLongest ? `⚠️ ${part.name}` : part.name,
+ Duration: formatMs(part.value),
+ "%": `${percent}%`,
+ };
+ });
+
+ console.table(tableData);
+
+ // Visual bar
+ const barWidth = 40;
+ const inputBar = "█".repeat(Math.round((inputDelay / total) * barWidth));
+ const procBar = "▓".repeat(Math.round((processingTime / total) * barWidth));
+ const presBar = "░".repeat(Math.round((presentationDelay / total) * barWidth));
+ console.log(` ${inputBar}${procBar}${presBar}`);
+ console.log(" █ Input ▓ Processing ░ Presentation");
+
+ // Recommendation if slow
+ if (rating !== "good") {
+ console.log("");
+ console.log("%c💡 Optimization hint:", "font-weight: bold; color: #3b82f6;");
+ if (longest.name === "Input Delay") {
+ console.log(" Break up long tasks blocking the main thread");
+ console.log(" Use requestIdleCallback or setTimeout for non-critical work");
+ } else if (longest.name === "Processing Time") {
+ console.log(" Optimize event handlers, reduce JavaScript complexity");
+ console.log(" Consider debouncing or using web workers");
+ } else {
+ console.log(" Reduce DOM size or complexity of updates");
+ console.log(" Avoid forced synchronous layouts");
+ }
+ }
+
+ console.groupEnd();
+ }
+ });
+
+ observer.observe({
+ type: "event",
+ durationThreshold: 0,
+ buffered: true,
+ });
+
+ // Summary function
+ window.getInteractionSummary = () => {
+ if (allInteractions.length === 0) {
+ console.log("%c📊 No interactions recorded yet.", "font-weight: bold;");
+ console.log(" Interact with the page (click, type, etc.) and call this again.");
+ return { script: "Interactions", status: "error", error: "No interactions recorded yet", count: 0 };
+ }
+
+ console.group("%c📊 Interaction Summary", "font-weight: bold; font-size: 14px;");
+
+ const durations = allInteractions.map((i) => i.duration);
+ const worst = Math.max(...durations);
+ const avg = durations.reduce((a, b) => a + b, 0) / durations.length;
+ const p75 = durations.sort((a, b) => a - b)[Math.floor(durations.length * 0.75)];
+
+ const worstRating = valueToRating(worst);
+ const p75Rating = valueToRating(p75);
+
+ console.log("");
+ console.log("%cStatistics:", "font-weight: bold;");
+ console.log(` Total interactions: ${allInteractions.length}`);
+ console.log(
+ ` Worst: %c${formatMs(worst)} (${worstRating})`,
+ `color: ${RATING_COLORS[worstRating]};`
+ );
+ console.log(
+ ` P75 (INP): %c${formatMs(p75)} (${p75Rating})`,
+ `color: ${RATING_COLORS[p75Rating]};`
+ );
+ console.log(` Average: ${formatMs(avg)}`);
+
+ // Rating breakdown
+ const good = allInteractions.filter((i) => i.rating === "good").length;
+ const needsImprovement = allInteractions.filter(
+ (i) => i.rating === "needs-improvement"
+ ).length;
+ const poor = allInteractions.filter((i) => i.rating === "poor").length;
+
+ console.log("");
+ console.log("%cBy rating:", "font-weight: bold;");
+ console.log(` 🟢 Good (≤200ms): ${good}`);
+ console.log(` 🟡 Needs Improvement (≤500ms): ${needsImprovement}`);
+ console.log(` 🔴 Poor (>500ms): ${poor}`);
+
+ // Slow interactions
+ const slowInteractions = allInteractions.filter((i) => i.rating !== "good");
+ if (slowInteractions.length > 0) {
+ console.log("");
+ console.log("%c⚠️ Slow interactions:", "font-weight: bold; color: #ef4444;");
+ slowInteractions.forEach((i, idx) => {
+ const icon = RATING_ICONS[i.rating];
+ console.log(` ${idx + 1}. ${icon} ${i.type} - ${formatMs(i.duration)}`, i.target);
+ });
+ }
+
+ const durations2 = allInteractions.map((i) => i.duration);
+ const worst2 = Math.max(...durations2);
+ const avg2 = Math.round(durations2.reduce((a, b) => a + b, 0) / durations2.length);
+ const p75 = Math.round(durations2.sort((a, b) => a - b)[Math.floor(durations2.length * 0.75)]);
+ const byRating = {
+ good: allInteractions.filter((i) => i.rating === "good").length,
+ "needs-improvement": allInteractions.filter((i) => i.rating === "needs-improvement").length,
+ poor: allInteractions.filter((i) => i.rating === "poor").length,
+ };
+ console.groupEnd();
+ return {
+ script: "Interactions",
+ status: "ok",
+ count: allInteractions.length,
+ details: {
+ totalInteractions: allInteractions.length,
+ worstMs: Math.round(worst2),
+ avgMs: avg2,
+ p75Ms: p75,
+ byRating,
+ },
+ items: allInteractions.map((i) => ({
+ type: i.type,
+ durationMs: Math.round(i.duration),
+ rating: i.rating,
+ })),
+ };
+ };
+
+ console.log("%c👆 Interaction Tracking Active", "font-weight: bold; font-size: 14px;");
+ console.log(" Interact with the page to see interaction details.");
+ console.log(" Call %cgetInteractionSummary()%c for a summary.", "font-family: monospace; background: #f3f4f6; padding: 2px 4px;", "");
+
+ return {
+ script: "Interactions",
+ status: "tracking",
+ message: "Tracking interactions. Interact with the page then call getInteractionSummary() for results.",
+ getDataFn: "getInteractionSummary",
+ };
+})();
+
diff --git a/dist/webperf-interaction/Layout-Shift-Loading-and-Interaction.js b/dist/webperf-interaction/Layout-Shift-Loading-and-Interaction.js
new file mode 100644
index 0000000..9250056
--- /dev/null
+++ b/dist/webperf-interaction/Layout-Shift-Loading-and-Interaction.js
@@ -0,0 +1,163 @@
+(() => {
+ const valueToRating = score => score <= 0.1 ? "good" : score <= 0.25 ? "needs-improvement" : "poor";
+ const RATING_COLORS = {
+ good: "#0CCE6A",
+ "needs-improvement": "#FFA400",
+ poor: "#FF4E42"
+ };
+ const RATING_ICONS = {
+ good: "🟢",
+ "needs-improvement": "🟡",
+ poor: "🔴"
+ };
+ let totalCLS = 0;
+ const allShifts = [];
+ const elementShifts = new Map;
+ const getElementSelector = element => {
+ if (!element) return "(unknown)";
+ if (element.id) return `#${element.id}`;
+ if (element.className && typeof element.className === "string") {
+ const classes = element.className.trim().split(/\s+/).slice(0, 2).join(".");
+ if (classes) return `${element.tagName.toLowerCase()}.${classes}`;
+ }
+ return element.tagName?.toLowerCase() || "(unknown)";
+ };
+ const observer = new PerformanceObserver(list => {
+ for (const entry of list.getEntries()) {
+ const countedForCLS = !entry.hadRecentInput;
+ if (countedForCLS) totalCLS += entry.value;
+ const sources = entry.sources || [];
+ const elements = sources.map(source => ({
+ element: source.node,
+ selector: getElementSelector(source.node),
+ previousRect: source.previousRect,
+ currentRect: source.currentRect
+ }));
+ elements.forEach(el => {
+ if (!elementShifts.has(el.selector)) elementShifts.set(el.selector, {
+ count: 0,
+ totalShift: 0,
+ element: el.element
+ });
+ const data = elementShifts.get(el.selector);
+ data.count++;
+ data.totalShift += entry.value;
+ });
+ const shift = {
+ value: entry.value,
+ countedForCLS: countedForCLS,
+ elements: elements,
+ time: entry.startTime,
+ entry: entry
+ };
+ allShifts.push(shift);
+ if (entry.value > 0.001) {
+ const rating = valueToRating(totalCLS);
+ countedForCLS && RATING_ICONS[rating];
+ RATING_COLORS[rating];
+ if (elements.length > 0) {
+ elements.forEach((el, i) => {
+ if (el.previousRect && el.currentRect) {
+ const dx = el.currentRect.x - el.previousRect.x;
+ const dy = el.currentRect.y - el.previousRect.y;
+ const dw = el.currentRect.width - el.previousRect.width;
+ const dh = el.currentRect.height - el.previousRect.height;
+ const changes = [];
+ if (Math.abs(dx) > 0) changes.push(`x: ${dx > 0 ? "+" : ""}${Math.round(dx)}px`);
+ if (Math.abs(dy) > 0) changes.push(`y: ${dy > 0 ? "+" : ""}${Math.round(dy)}px`);
+ if (Math.abs(dw) > 0) changes.push(`width: ${dw > 0 ? "+" : ""}${Math.round(dw)}px`);
+ if (Math.abs(dh) > 0) changes.push(`height: ${dh > 0 ? "+" : ""}${Math.round(dh)}px`);
+ if (changes.length > 0) void 0;
+ }
+ });
+ } else void 0;
+ }
+ }
+ });
+ observer.observe({
+ type: "layout-shift",
+ buffered: true
+ });
+ window.getLayoutShiftSummary = () => {
+ const rating = valueToRating(totalCLS);
+ RATING_ICONS[rating];
+ RATING_COLORS[rating];
+ const countedShifts = allShifts.filter(s => s.countedForCLS);
+ const excludedShifts = allShifts.filter(s => !s.countedForCLS);
+ if (countedShifts.length > 0) {
+ Math.max(...countedShifts.map(s => s.value));
+ }
+ if (elementShifts.size > 0) {
+ const sortedElements = Array.from(elementShifts.entries()).sort((a, b) => b[1].totalShift - a[1].totalShift).slice(0, 5);
+ sortedElements.map(([selector, data]) => ({
+ Element: selector,
+ "Shift Count": data.count,
+ "Total Impact": data.totalShift.toFixed(4)
+ }));
+ sortedElements.forEach(([selector, data], i) => {
+ });
+ }
+ if (countedShifts.length > 0) {
+ const significant = countedShifts.filter(s => s.value > 0.001);
+ if (significant.length > 0) {
+ significant.map(s => ({
+ "Time (ms)": Math.round(s.time),
+ Value: s.value.toFixed(4),
+ Elements: s.elements.map(e => e.selector).join(", ") || "(unknown)"
+ }));
+ } else void 0;
+ }
+ if (rating !== "good") {
+ }
+ const topElementsData = Array.from(elementShifts.entries()).sort((a, b) => b[1].totalShift - a[1].totalShift).slice(0, 5).map(([selector, data]) => ({
+ selector: selector,
+ shiftCount: data.count,
+ totalImpact: Math.round(data.totalShift * 10000) / 10000
+ }));
+ return {
+ script: "Layout-Shift-Loading-and-Interaction",
+ status: "ok",
+ metric: "CLS",
+ value: Math.round(totalCLS * 10000) / 10000,
+ unit: "score",
+ rating: rating,
+ thresholds: {
+ good: 0.1,
+ needsImprovement: 0.25
+ },
+ details: {
+ currentCLS: Math.round(totalCLS * 10000) / 10000,
+ shiftCount: allShifts.length,
+ countedShifts: countedShifts.length,
+ excludedShifts: excludedShifts.length,
+ topElements: topElementsData
+ }
+ };
+ };
+ const rating = valueToRating(totalCLS);
+ RATING_ICONS[rating];
+ const clsBufferedSync = performance.getEntriesByType("layout-shift").reduce((sum, e) => !e.hadRecentInput ? sum + e.value : sum, 0);
+ const countedSync = performance.getEntriesByType("layout-shift").filter(e => !e.hadRecentInput).length;
+ const excludedSync = performance.getEntriesByType("layout-shift").filter(e => e.hadRecentInput).length;
+ const clsRatingSync = valueToRating(clsBufferedSync);
+ return {
+ script: "Layout-Shift-Loading-and-Interaction",
+ status: "tracking",
+ metric: "CLS",
+ value: Math.round(clsBufferedSync * 10000) / 10000,
+ unit: "score",
+ rating: clsRatingSync,
+ thresholds: {
+ good: 0.1,
+ needsImprovement: 0.25
+ },
+ details: {
+ currentCLS: Math.round(clsBufferedSync * 10000) / 10000,
+ shiftCount: countedSync + excludedSync,
+ countedShifts: countedSync,
+ excludedShifts: excludedSync
+ },
+ message: "Layout shift tracking active. Call getLayoutShiftSummary() for full element attribution.",
+ getDataFn: "getLayoutShiftSummary"
+ };
+})();
diff --git a/dist/webperf-interaction/Long-Animation-Frames-Helpers.js b/dist/webperf-interaction/Long-Animation-Frames-Helpers.js
new file mode 100644
index 0000000..0c8b91a
--- /dev/null
+++ b/dist/webperf-interaction/Long-Animation-Frames-Helpers.js
@@ -0,0 +1,229 @@
+(() => {
+ "use strict";
+ if (!("PerformanceObserver" in window) || !PerformanceObserver.supportedEntryTypes.includes("long-animation-frame")) {
+ return {
+ script: "Long-Animation-Frames-Helpers",
+ status: "unsupported",
+ error: "Long Animation Frames API not supported. Requires Chrome 123+"
+ };
+ }
+ const capturedFrames = [];
+ const getSeverity = duration => {
+ if (duration > 200) return {
+ level: "critical",
+ icon: "🔴"
+ };
+ if (duration > 150) return {
+ level: "high",
+ icon: "🟠"
+ };
+ if (duration > 100) return {
+ level: "medium",
+ icon: "🟡"
+ };
+ return {
+ level: "low",
+ icon: "🟢"
+ };
+ };
+ const observer = new PerformanceObserver(list => {
+ for (const entry of list.getEntries()) {
+ const frameData = {
+ startTime: entry.startTime,
+ duration: entry.duration,
+ renderStart: entry.renderStart,
+ styleAndLayoutStart: entry.styleAndLayoutStart,
+ firstUIEventTimestamp: entry.firstUIEventTimestamp,
+ blockingDuration: entry.blockingDuration,
+ scripts: entry.scripts.map(s => ({
+ sourceURL: s.sourceURL || "",
+ sourceFunctionName: s.sourceFunctionName || "(anonymous)",
+ invoker: s.invoker || "",
+ invokerType: s.invokerType || "",
+ duration: s.duration,
+ executionStart: s.executionStart,
+ forcedStyleAndLayoutDuration: s.forcedStyleAndLayoutDuration || 0
+ }))
+ };
+ capturedFrames.push(frameData);
+ }
+ });
+ try {
+ observer.observe({
+ type: "long-animation-frame",
+ buffered: true
+ });
+ } catch (e) {
+ return {
+ script: "Long-Animation-Frames-Helpers",
+ status: "error",
+ error: e.message
+ };
+ }
+ window.loafHelpers = {
+ summary() {
+ if (capturedFrames.length === 0) {
+ return;
+ }
+ capturedFrames.reduce((sum, f) => sum + f.duration, 0);
+ capturedFrames.reduce((sum, f) => sum + f.blockingDuration, 0);
+ capturedFrames.length;
+ Math.max(...capturedFrames.map(f => f.duration));
+ capturedFrames.filter(f => f.duration > 200).length, capturedFrames.filter(f => f.duration > 150 && f.duration <= 200).length,
+ capturedFrames.filter(f => f.duration > 100 && f.duration <= 150).length, capturedFrames.filter(f => f.duration <= 100).length;
+ },
+ topScripts(n = 10) {
+ if (capturedFrames.length === 0) {
+ return;
+ }
+ const allScripts = capturedFrames.flatMap(f => f.scripts);
+ if (allScripts.length === 0) {
+ return;
+ }
+ const scriptStats = new Map;
+ allScripts.forEach(s => {
+ const key = `${s.sourceURL}|${s.sourceFunctionName}`;
+ if (!scriptStats.has(key)) scriptStats.set(key, {
+ sourceURL: s.sourceURL,
+ functionName: s.sourceFunctionName,
+ totalDuration: 0,
+ count: 0,
+ maxDuration: 0,
+ totalForcedLayout: 0
+ });
+ const stats = scriptStats.get(key);
+ stats.totalDuration += s.duration;
+ stats.count++;
+ stats.maxDuration = Math.max(stats.maxDuration, s.duration);
+ stats.totalForcedLayout += s.forcedStyleAndLayoutDuration;
+ });
+ const sorted = Array.from(scriptStats.values()).sort((a, b) => b.totalDuration - a.totalDuration).slice(0, n);
+ sorted.map(s => {
+ let path = s.sourceURL;
+ try {
+ path = new URL(s.sourceURL || location.href).pathname;
+ if (path.length > 40) path = "..." + path.slice(-37);
+ } catch {}
+ return {
+ Script: path || "(inline)",
+ Function: s.functionName.length > 25 ? s.functionName.slice(0, 22) + "..." : s.functionName,
+ Count: s.count,
+ Total: `${s.totalDuration.toFixed(0)}ms`,
+ Max: `${s.maxDuration.toFixed(0)}ms`,
+ "Forced S&L": s.totalForcedLayout > 0 ? `${s.totalForcedLayout.toFixed(0)}ms` : "-"
+ };
+ });
+ return sorted;
+ },
+ filter(options = {}) {
+ if (capturedFrames.length === 0) {
+ return [];
+ }
+ let filtered = capturedFrames;
+ if (options.minDuration) filtered = filtered.filter(f => f.duration >= options.minDuration);
+ if (options.maxDuration) filtered = filtered.filter(f => f.duration <= options.maxDuration);
+ if (filtered.length > 0) {
+ filtered.map(f => {
+ const sev = getSeverity(f.duration);
+ return {
+ "": sev.icon,
+ Start: `${f.startTime.toFixed(0)}ms`,
+ Duration: `${f.duration.toFixed(0)}ms`,
+ Blocking: `${f.blockingDuration.toFixed(0)}ms`,
+ Scripts: f.scripts.length
+ };
+ });
+ }
+ return filtered;
+ },
+ findByURL(search) {
+ if (capturedFrames.length === 0) {
+ return [];
+ }
+ const matches = capturedFrames.filter(f => f.scripts.some(s => s.sourceURL.toLowerCase().includes(search.toLowerCase())));
+ if (matches.length > 0) {
+ matches.map(f => {
+ const matchingScript = f.scripts.find(s => s.sourceURL.toLowerCase().includes(search.toLowerCase()));
+ let scriptPath = matchingScript.sourceURL;
+ try {
+ scriptPath = new URL(scriptPath).pathname;
+ if (scriptPath.length > 35) scriptPath = "..." + scriptPath.slice(-32);
+ } catch {}
+ return {
+ "Frame Start": `${f.startTime.toFixed(0)}ms`,
+ "Frame Duration": `${f.duration.toFixed(0)}ms`,
+ Script: scriptPath,
+ "Script Duration": `${matchingScript.duration.toFixed(0)}ms`
+ };
+ });
+ }
+ return matches;
+ },
+ percentiles(pcts = [ 50, 75, 95, 99 ]) {
+ if (capturedFrames.length === 0) {
+ return {};
+ }
+ const durations = capturedFrames.map(f => f.duration).sort((a, b) => a - b);
+ const result = {};
+ pcts.forEach(p => {
+ const index = Math.ceil(p / 100 * durations.length) - 1;
+ const safeIndex = Math.max(0, Math.min(index, durations.length - 1));
+ result[`p${p}`] = durations[safeIndex];
+ });
+ Object.entries(result).forEach(([key, value]) => {
+ getSeverity(value);
+ });
+ return result;
+ },
+ exportJSON() {
+ if (capturedFrames.length === 0) {
+ return;
+ }
+ const data = JSON.stringify(capturedFrames, null, 2);
+ const blob = new Blob([ data ], {
+ type: "application/json"
+ });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `loaf-data-${(new Date).toISOString().slice(0, 10)}.json`;
+ a.click();
+ URL.revokeObjectURL(url);
+ },
+ exportCSV() {
+ if (capturedFrames.length === 0) {
+ return;
+ }
+ const rows = [ [ "Frame Start", "Duration", "Blocking", "Script URL", "Function", "Script Duration", "Forced S&L" ] ];
+ capturedFrames.forEach(f => {
+ if (f.scripts.length === 0) rows.push([ f.startTime.toFixed(2), f.duration.toFixed(2), f.blockingDuration.toFixed(2), "", "", "", "" ]); else f.scripts.forEach(s => {
+ rows.push([ f.startTime.toFixed(2), f.duration.toFixed(2), f.blockingDuration.toFixed(2), s.sourceURL, s.sourceFunctionName, s.duration.toFixed(2), s.forcedStyleAndLayoutDuration.toFixed(2) ]);
+ });
+ });
+ const csv = rows.map(row => row.map(cell => `"${cell}"`).join(",")).join("\n");
+ const blob = new Blob([ csv ], {
+ type: "text/csv"
+ });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `loaf-data-${(new Date).toISOString().slice(0, 10)}.csv`;
+ a.click();
+ URL.revokeObjectURL(url);
+ },
+ getRawData() {
+ return capturedFrames;
+ },
+ clear() {
+ capturedFrames.length = 0;
+ },
+ help() {
+ }
+ };
+ return {
+ script: "Long-Animation-Frames-Helpers",
+ status: "tracking",
+ message: "LoAF Helpers loaded. Use loafHelpers.summary(), loafHelpers.topScripts(), etc. Call loafHelpers.getRawData() to get raw frame data.",
+ getDataFn: "loafHelpers.getRawData"
+ };
+})();
diff --git a/dist/webperf-interaction/Long-Animation-Frames-Script-Attribution.js b/dist/webperf-interaction/Long-Animation-Frames-Script-Attribution.js
new file mode 100644
index 0000000..bebd6b3
--- /dev/null
+++ b/dist/webperf-interaction/Long-Animation-Frames-Script-Attribution.js
@@ -0,0 +1,133 @@
+(() => {
+ if (!PerformanceObserver.supportedEntryTypes?.includes("long-animation-frame")) {
+ return {
+ script: "Long-Animation-Frames-Script-Attribution",
+ status: "unsupported",
+ error: "long-animation-frame not supported. Chrome 116+ required."
+ };
+ }
+ const frames = performance.getEntriesByType("long-animation-frame").map(entry => ({
+ duration: entry.duration,
+ blockingDuration: entry.blockingDuration || 0,
+ scripts: (entry.scripts || []).map(s => ({
+ sourceURL: s.sourceURL || "unknown",
+ sourceFunctionName: s.sourceFunctionName || "anonymous",
+ invoker: s.invoker || "unknown",
+ duration: s.duration || 0
+ }))
+ }));
+ if (frames.length === 0) {
+ return {
+ script: "Long-Animation-Frames-Script-Attribution",
+ status: "ok",
+ details: {
+ frameCount: 0,
+ totalBlockingMs: 0,
+ byCategory: {}
+ },
+ items: []
+ };
+ }
+ const allScripts = frames.flatMap(f => f.scripts);
+ const totalBlocking = frames.reduce((sum, f) => sum + f.blockingDuration, 0);
+ frames.reduce((sum, f) => sum + f.duration, 0);
+ const categorize = url => {
+ const u = url.toLowerCase();
+ const origin = location.origin.toLowerCase();
+ if (u === "" || u === "unknown") return "unknown";
+ if ([ "react", "vue", "angular", "svelte", "framework", "chunk", "webpack", "vite" ].some(fw => u.includes(fw))) return "framework";
+ if ([ "google-analytics", "gtag", "gtm", "analytics", "facebook", "twitter", "ads", "cdn", "unpkg", "jsdelivr", "segment", "amplitude" ].some(tp => u.includes(tp))) return "third-party";
+ if (u.startsWith("chrome-extension://") || u.startsWith("moz-extension://")) return "extension";
+ if (u.startsWith("http") && !u.includes(origin.replace(/^https?:\/\//, ""))) return "third-party";
+ return "first-party";
+ };
+ const byCategory = {};
+ allScripts.forEach(script => {
+ const cat = categorize(script.sourceURL);
+ if (!byCategory[cat]) byCategory[cat] = {
+ durationMs: 0,
+ count: 0
+ };
+ byCategory[cat].durationMs += script.duration;
+ byCategory[cat].count++;
+ });
+ const byFile = {};
+ allScripts.forEach(script => {
+ const file = script.sourceURL.split("/").pop() || "unknown";
+ if (!byFile[file]) byFile[file] = {
+ duration: 0,
+ count: 0,
+ category: categorize(script.sourceURL),
+ functions: []
+ };
+ byFile[file].duration += script.duration;
+ byFile[file].count++;
+ byFile[file].functions.push({
+ name: script.sourceFunctionName,
+ invoker: script.invoker,
+ duration: script.duration
+ });
+ });
+ const topFiles = Object.entries(byFile).sort((a, b) => b[1].duration - a[1].duration).slice(0, 10);
+ const totalScript = Object.values(byCategory).reduce((sum, cat) => sum + cat.durationMs, 0);
+ const categories = [ {
+ name: "Your Code",
+ key: "first-party",
+ icon: "🔵",
+ color: "#3b82f6"
+ }, {
+ name: "Framework",
+ key: "framework",
+ icon: "🟣",
+ color: "#8b5cf6"
+ }, {
+ name: "Third-Party",
+ key: "third-party",
+ icon: "🟠",
+ color: "#f59e0b"
+ }, {
+ name: "Extensions",
+ key: "extension",
+ icon: "🟤",
+ color: "#92400e"
+ }, {
+ name: "Unknown",
+ key: "unknown",
+ icon: "⚫",
+ color: "#6b7280"
+ } ];
+ categories.forEach(({name: name, key: key, icon: icon, color: color}) => {
+ const data = byCategory[key];
+ if (data?.durationMs > 0) {
+ const pct = totalScript > 0 ? data.durationMs / totalScript * 100 : 0;
+ const barLen = Math.round(pct / 2);
+ "█".repeat(barLen), "░".repeat(Math.max(0, 50 - barLen));
+ }
+ });
+ topFiles.forEach(([file, data], idx) => {
+ totalScript > 0 && data.duration;
+ categories.find(c => c.key === data.category);
+ const topFns = data.functions.sort((a, b) => b.duration - a.duration).slice(0, 3);
+ if (topFns.length > 0) {
+ topFns.forEach((fn, i) => {});
+ }
+ });
+ return {
+ script: "Long-Animation-Frames-Script-Attribution",
+ status: "ok",
+ details: {
+ frameCount: frames.length,
+ totalBlockingMs: Math.round(totalBlocking),
+ byCategory: Object.fromEntries(Object.entries(byCategory).map(([k, v]) => [ k, {
+ durationMs: Math.round(v.durationMs),
+ count: v.count
+ } ]))
+ },
+ items: topFiles.map(([file, data]) => ({
+ file: file,
+ category: data.category,
+ durationMs: Math.round(data.duration),
+ count: data.count
+ }))
+ };
+})();
diff --git a/dist/webperf-interaction/Long-Animation-Frames.js b/dist/webperf-interaction/Long-Animation-Frames.js
new file mode 100644
index 0000000..06847dc
--- /dev/null
+++ b/dist/webperf-interaction/Long-Animation-Frames.js
@@ -0,0 +1,169 @@
+(() => {
+ const formatMs = ms => `${Math.round(ms)}ms`;
+ const valueToRating = blockingDuration => blockingDuration === 0 ? "good" : blockingDuration <= 100 ? "needs-improvement" : "poor";
+ const RATING_COLORS = {
+ good: "#0CCE6A",
+ "needs-improvement": "#FFA400",
+ poor: "#FF4E42"
+ };
+ const RATING_ICONS = {
+ good: "🟢",
+ "needs-improvement": "🟡",
+ poor: "🔴"
+ };
+ const allLoAFs = [];
+ const allEvents = [];
+ const getScriptSummary = script => {
+ const invoker = script.invoker || script.name || "(anonymous)";
+ const source = script.sourceURL ? script.sourceURL.split("/").pop()?.split("?")[0] || script.sourceURL : "";
+ return {
+ invoker: invoker,
+ source: source,
+ type: script.invokerType || "unknown"
+ };
+ };
+ const processLoAF = entry => {
+ const endTime = entry.startTime + entry.duration;
+ const workDuration = entry.renderStart ? entry.renderStart - entry.startTime : entry.duration;
+ const renderDuration = entry.renderStart ? endTime - entry.renderStart : 0;
+ const styleAndLayoutDuration = entry.styleAndLayoutStart ? endTime - entry.styleAndLayoutStart : 0;
+ const totalForcedStyleAndLayout = entry.scripts.reduce((sum, script) => sum + (script.forcedStyleAndLayoutDuration || 0), 0);
+ const scripts = entry.scripts.map(script => ({
+ ...getScriptSummary(script),
+ duration: Math.round(script.duration),
+ execDuration: Math.round(script.executionStart ? script.startTime + script.duration - script.executionStart : script.duration),
+ forcedStyleAndLayout: Math.round(script.forcedStyleAndLayoutDuration || 0),
+ startTime: Math.round(script.startTime)
+ }));
+ return {
+ startTime: Math.round(entry.startTime),
+ duration: Math.round(entry.duration),
+ blockingDuration: Math.round(entry.blockingDuration),
+ workDuration: Math.round(workDuration),
+ renderDuration: Math.round(renderDuration),
+ styleAndLayoutDuration: Math.round(styleAndLayoutDuration),
+ totalForcedStyleAndLayout: Math.round(totalForcedStyleAndLayout),
+ scripts: scripts,
+ entry: entry
+ };
+ };
+ const overlap = (e1, e2) => e1.startTime < e2.startTime + e2.duration && e2.startTime < e1.startTime + e1.duration;
+ const loafObserver = new PerformanceObserver(list => {
+ for (const entry of list.getEntries()) {
+ const processed = processLoAF(entry);
+ allLoAFs.push(processed);
+ if (entry.blockingDuration > 0) {
+ const rating = valueToRating(entry.blockingDuration);
+ RATING_ICONS[rating];
+ RATING_COLORS[rating];
+ formatMs(processed.workDuration), formatMs(processed.renderDuration), formatMs(processed.styleAndLayoutDuration);
+ const total = processed.duration;
+ const barWidth = 40;
+ "█".repeat(Math.round(processed.workDuration / total * barWidth));
+ "░".repeat(Math.round(processed.renderDuration / total * barWidth));
+ if (processed.totalForcedStyleAndLayout > 0) {
+ }
+ if (processed.scripts.length > 0) {
+ processed.scripts.map(s => ({
+ Invoker: s.invoker.length > 40 ? s.invoker.slice(0, 37) + "..." : s.invoker,
+ Type: s.type,
+ Duration: formatMs(s.duration),
+ "Forced S&L": s.forcedStyleAndLayout > 0 ? formatMs(s.forcedStyleAndLayout) : "-",
+ Source: s.source.length > 25 ? "..." + s.source.slice(-22) : s.source
+ }));
+ }
+ const overlappingEvents = allEvents.filter(e => overlap(e, entry));
+ if (overlappingEvents.length > 0) {
+ overlappingEvents.forEach(e => {
+ });
+ }
+ }
+ }
+ });
+ const eventObserver = new PerformanceObserver(list => {
+ for (const entry of list.getEntries()) if (entry.interactionId) allEvents.push(entry);
+ });
+ loafObserver.observe({
+ type: "long-animation-frame",
+ buffered: true
+ });
+ eventObserver.observe({
+ type: "event",
+ buffered: true
+ });
+ window.getLoAFSummary = () => {
+ if (allLoAFs.length === 0) {
+ return;
+ }
+ const blocking = allLoAFs.filter(l => l.blockingDuration > 0);
+ const totalBlocking = blocking.reduce((sum, l) => sum + l.blockingDuration, 0);
+ const worstBlocking = Math.max(...allLoAFs.map(l => l.blockingDuration));
+ allLoAFs.reduce((sum, l) => sum + l.duration, 0), allLoAFs.length;
+ const scriptStats = new Map;
+ allLoAFs.forEach(loaf => {
+ loaf.scripts.forEach(script => {
+ const key = `${script.invoker}|${script.source}`;
+ if (!scriptStats.has(key)) scriptStats.set(key, {
+ invoker: script.invoker,
+ source: script.source,
+ count: 0,
+ totalDuration: 0,
+ totalForcedSL: 0
+ });
+ const stats = scriptStats.get(key);
+ stats.count++;
+ stats.totalDuration += script.duration;
+ stats.totalForcedSL += script.forcedStyleAndLayout;
+ });
+ });
+ if (scriptStats.size > 0) {
+ const topScripts = Array.from(scriptStats.values()).sort((a, b) => b.totalDuration - a.totalDuration).slice(0, 10);
+ topScripts.map(s => ({
+ Invoker: s.invoker.length > 35 ? s.invoker.slice(0, 32) + "..." : s.invoker,
+ Count: s.count,
+ "Total Duration": formatMs(s.totalDuration),
+ "Forced S&L": s.totalForcedSL > 0 ? formatMs(s.totalForcedSL) : "-",
+ Source: s.source.length > 20 ? "..." + s.source.slice(-17) : s.source
+ }));
+ }
+ const forcedSLTotal = allLoAFs.reduce((sum, l) => sum + l.totalForcedStyleAndLayout, 0);
+ if (forcedSLTotal > 0) {
+ }
+ if (worstBlocking > 50) {
+ }
+ return {
+ script: "Long-Animation-Frames",
+ status: "ok",
+ count: allLoAFs.length,
+ details: {
+ totalLoAFs: allLoAFs.length,
+ withBlockingTime: blocking.length,
+ totalBlockingTimeMs: Math.round(totalBlocking),
+ worstBlockingMs: Math.round(worstBlocking),
+ topScripts: Array.from(scriptStats.values()).sort((a, b) => b.totalDuration - a.totalDuration).slice(0, 5).map(s => ({
+ invoker: s.invoker,
+ source: s.source,
+ totalDurationMs: Math.round(s.totalDuration),
+ count: s.count
+ }))
+ }
+ };
+ };
+ const loafBuffered = performance.getEntriesByType("long-animation-frame");
+ const blockingLoafs = loafBuffered.filter(e => e.blockingDuration > 0);
+ const totalBlockingSync = blockingLoafs.reduce((sum, e) => sum + e.blockingDuration, 0);
+ const worstBlockingSync = loafBuffered.length > 0 ? Math.max(...loafBuffered.map(e => e.blockingDuration)) : 0;
+ return {
+ script: "Long-Animation-Frames",
+ status: "tracking",
+ count: loafBuffered.length,
+ details: {
+ totalLoAFs: loafBuffered.length,
+ withBlockingTime: blockingLoafs.length,
+ totalBlockingTimeMs: Math.round(totalBlockingSync),
+ worstBlockingMs: Math.round(worstBlockingSync)
+ },
+ message: "Tracking long animation frames. Call getLoAFSummary() for full script attribution.",
+ getDataFn: "getLoAFSummary"
+ };
+})();
diff --git a/dist/webperf-interaction/LongTask.js b/dist/webperf-interaction/LongTask.js
new file mode 100644
index 0000000..424ead2
--- /dev/null
+++ b/dist/webperf-interaction/LongTask.js
@@ -0,0 +1,125 @@
+(() => {
+ const formatMs = ms => `${Math.round(ms)}ms`;
+ const getSeverity = duration => {
+ if (duration > 250) return {
+ level: "critical",
+ icon: "🔴",
+ color: "#ef4444"
+ };
+ if (duration > 150) return {
+ level: "high",
+ icon: "🟠",
+ color: "#f97316"
+ };
+ if (duration > 100) return {
+ level: "medium",
+ icon: "🟡",
+ color: "#eab308"
+ };
+ return {
+ level: "low",
+ icon: "🟢",
+ color: "#22c55e"
+ };
+ };
+ const allTasks = [];
+ let totalBlockingTime = 0;
+ try {
+ const observer = new PerformanceObserver(list => {
+ for (const entry of list.getEntries()) {
+ const duration = entry.duration;
+ const blockingTime = Math.max(0, duration - 50);
+ totalBlockingTime += blockingTime;
+ const sev = getSeverity(duration);
+ const task = {
+ startTime: entry.startTime,
+ duration: duration,
+ blockingTime: blockingTime,
+ severity: sev.level,
+ attribution: entry.attribution?.[0]?.containerType || "unknown",
+ containerId: entry.attribution?.[0]?.containerId || "",
+ containerName: entry.attribution?.[0]?.containerName || ""
+ };
+ allTasks.push(task);
+ if (entry.attribution && entry.attribution.length > 0) {
+ entry.attribution.forEach(attr => {
+ if (attr.containerName) void 0;
+ if (attr.containerId) void 0;
+ if (attr.containerSrc) void 0;
+ });
+ }
+ }
+ });
+ observer.observe({
+ type: "longtask",
+ buffered: true
+ });
+ window.getLongTaskSummary = () => {
+ if (allTasks.length === 0) {
+ return;
+ }
+ const durations = allTasks.map(t => t.duration);
+ const worst = Math.max(...durations);
+ const avg = durations.reduce((a, b) => a + b, 0) / durations.length;
+ getSeverity(worst);
+ const bySeverity = {
+ critical: allTasks.filter(t => t.severity === "critical").length,
+ high: allTasks.filter(t => t.severity === "high").length,
+ medium: allTasks.filter(t => t.severity === "medium").length,
+ low: allTasks.filter(t => t.severity === "low").length
+ };
+ if (allTasks.length > 0) {
+ allTasks.sort((a, b) => b.duration - a.duration).slice(0, 10).map(t => {
+ const sev = getSeverity(t.duration);
+ return {
+ "": sev.icon,
+ Start: formatMs(t.startTime),
+ Duration: formatMs(t.duration),
+ Blocking: formatMs(t.blockingTime),
+ Container: t.attribution
+ };
+ });
+ }
+ if (totalBlockingTime > 300) {
+ }
+ return {
+ script: "LongTask",
+ status: "ok",
+ count: allTasks.length,
+ details: {
+ totalBlockingTimeMs: Math.round(totalBlockingTime),
+ worstTaskMs: Math.round(worst),
+ avgDurationMs: Math.round(avg),
+ bySeverity: bySeverity
+ }
+ };
+ };
+ const longtaskBuffered = performance.getEntriesByType("longtask");
+ const totalBlockingSync = longtaskBuffered.reduce((sum, t) => sum + Math.max(0, t.duration - 50), 0);
+ const worstTaskSync = longtaskBuffered.length > 0 ? Math.max(...longtaskBuffered.map(t => t.duration)) : 0;
+ const bySeveritySync = {
+ critical: longtaskBuffered.filter(t => t.duration > 250).length,
+ high: longtaskBuffered.filter(t => t.duration > 150 && t.duration <= 250).length,
+ medium: longtaskBuffered.filter(t => t.duration > 100 && t.duration <= 150).length,
+ low: longtaskBuffered.filter(t => t.duration >= 50 && t.duration <= 100).length
+ };
+ return {
+ script: "LongTask",
+ status: "tracking",
+ count: longtaskBuffered.length,
+ details: {
+ totalBlockingTimeMs: Math.round(totalBlockingSync),
+ worstTaskMs: Math.round(worstTaskSync),
+ bySeverity: bySeveritySync
+ },
+ message: "Tracking long tasks. Call getLongTaskSummary() for statistics.",
+ getDataFn: "getLongTaskSummary"
+ };
+ } catch (e) {
+ return {
+ script: "LongTask",
+ status: "unsupported",
+ error: e.message
+ };
+ }
+})();
diff --git a/dist/webperf-interaction/Scroll-Performance.js b/dist/webperf-interaction/Scroll-Performance.js
new file mode 100644
index 0000000..661517c
--- /dev/null
+++ b/dist/webperf-interaction/Scroll-Performance.js
@@ -0,0 +1,183 @@
+(() => {
+ const TARGET_FRAME_MS = 1000 / 60;
+ const DROP_THRESHOLD_MS = TARGET_FRAME_MS * 1.5;
+ const RATING = {
+ good: {
+ icon: "🟢",
+ color: "#0CCE6A",
+ label: "Good"
+ },
+ "needs-improvement": {
+ icon: "🟡",
+ color: "#FFA400",
+ label: "Needs Improvement"
+ },
+ poor: {
+ icon: "🔴",
+ color: "#FF4E42",
+ label: "Poor"
+ }
+ };
+ const fpsRating = fps => fps >= 55 ? "good" : fps >= 40 ? "needs-improvement" : "poor";
+ const nonPassiveListeners = [];
+ const SCROLL_EVENT_TYPES = new Set([ "scroll", "wheel", "touchstart", "touchmove", "touchend" ]);
+ const _origAddEventListener = EventTarget.prototype.addEventListener;
+ EventTarget.prototype.addEventListener = function(type, listener, options) {
+ if (SCROLL_EVENT_TYPES.has(type)) {
+ const isPassive = options === true ? false : typeof options === "object" && options !== null ? options.passive === true : false;
+ if (!isPassive) {
+ const entry = {
+ type: type,
+ element: this.tagName || this.constructor?.name || "unknown",
+ id: this.id || "",
+ passive: false
+ };
+ nonPassiveListeners.push(entry);
+ }
+ }
+ return _origAddEventListener.call(this, type, listener, options);
+ };
+ const sessions = [];
+ let currentSession = null;
+ let rafId = null;
+ let lastFrameTime = null;
+ let endTimer = null;
+ const trackFrame = now => {
+ if (!currentSession) return;
+ if (lastFrameTime !== null) {
+ const frameTime = now - lastFrameTime;
+ currentSession.frames.push(frameTime);
+ if (frameTime > DROP_THRESHOLD_MS) currentSession.drops++;
+ }
+ lastFrameTime = now;
+ rafId = requestAnimationFrame(trackFrame);
+ };
+ const endSession = () => {
+ if (!currentSession) return;
+ cancelAnimationFrame(rafId);
+ const {frames: frames, drops: drops} = currentSession;
+ if (frames.length < 2) {
+ currentSession = null;
+ return;
+ }
+ const avgFrameTime = frames.reduce((a, b) => a + b, 0) / frames.length;
+ const avgFps = 1000 / avgFrameTime;
+ const minFps = 1000 / Math.max(...frames);
+ const dropRate = drops / frames.length * 100;
+ const rating = fpsRating(avgFps);
+ RATING[rating];
+ const session = {
+ avgFps: avgFps,
+ minFps: minFps,
+ frames: frames.length,
+ drops: drops,
+ dropRate: dropRate,
+ rating: rating
+ };
+ sessions.push(session);
+ if (drops > 0) {
+ Math.max(...frames);
+ }
+ currentSession = null;
+ lastFrameTime = null;
+ };
+ window.addEventListener("scroll", () => {
+ if (!currentSession) {
+ currentSession = {
+ frames: [],
+ drops: 0
+ };
+ lastFrameTime = null;
+ rafId = requestAnimationFrame(trackFrame);
+ }
+ clearTimeout(endTimer);
+ endTimer = setTimeout(endSession, 200);
+ }, {
+ passive: true
+ });
+ const auditScrollCSS = () => {
+ const results = {
+ smoothScrollElements: [],
+ willChangeElements: [],
+ contentVisibilityElements: [],
+ overscrollElements: []
+ };
+ document.querySelectorAll("*").forEach(el => {
+ const cs = getComputedStyle(el);
+ const label = el.tagName.toLowerCase() + (el.id ? `#${el.id}` : el.className ? `.${String(el.className).trim().split(/\s+/)[0]}` : "");
+ if (cs.scrollBehavior === "smooth") results.smoothScrollElements.push(label);
+ if (cs.willChange && cs.willChange !== "auto") results.willChangeElements.push({
+ element: label,
+ value: cs.willChange
+ });
+ if (cs.contentVisibility && cs.contentVisibility !== "visible") results.contentVisibilityElements.push({
+ element: label,
+ value: cs.contentVisibility
+ });
+ if (cs.overscrollBehavior && cs.overscrollBehavior !== "auto") results.overscrollElements.push({
+ element: label,
+ value: cs.overscrollBehavior
+ });
+ });
+ return results;
+ };
+ window.getScrollSummary = () => {
+ if (sessions.length === 0) void 0; else {
+ const allFps = sessions.map(s => s.avgFps);
+ allFps.reduce((a, b) => a + b, 0), allFps.length;
+ Math.min(...allFps);
+ sessions.reduce((a, s) => a + s.drops, 0);
+ }
+ if (nonPassiveListeners.length === 0) void 0; else {
+ }
+ const css = auditScrollCSS();
+ if (css.contentVisibilityElements.length > 0) void 0; else void 0;
+ if (css.smoothScrollElements.length > 0) {
+ }
+ if (css.willChangeElements.length > 0) {
+ }
+ if (css.overscrollElements.length > 0) {
+ }
+ const hasJank = sessions.some(s => s.rating !== "good");
+ const hasNonPassive = nonPassiveListeners.length > 0;
+ if (hasJank || hasNonPassive) {
+ if (hasNonPassive) void 0;
+ if (hasJank) {
+ }
+ }
+ const cssAuditResult = auditScrollCSS();
+ return {
+ script: "Scroll-Performance",
+ status: "ok",
+ details: {
+ nonPassiveListeners: nonPassiveListeners.length,
+ cssAudit: {
+ smoothScrollElements: cssAuditResult.smoothScrollElements.length,
+ willChangeElements: cssAuditResult.willChangeElements.length,
+ contentVisibilityElements: cssAuditResult.contentVisibilityElements.length
+ },
+ sessionCount: sessions.length,
+ ...sessions.length > 0 ? {
+ avgFps: Math.round(sessions.reduce((a, s) => a + s.avgFps, 0) / sessions.length),
+ worstSessionFps: Math.round(Math.min(...sessions.map(s => s.avgFps))),
+ totalDrops: sessions.reduce((a, s) => a + s.drops, 0)
+ } : {}
+ }
+ };
+ };
+ const cssSnapshot = auditScrollCSS();
+ return {
+ script: "Scroll-Performance",
+ status: "tracking",
+ details: {
+ nonPassiveListeners: nonPassiveListeners.length,
+ cssAudit: {
+ smoothScrollElements: cssSnapshot.smoothScrollElements.length,
+ willChangeElements: cssSnapshot.willChangeElements.length,
+ contentVisibilityElements: cssSnapshot.contentVisibilityElements.length
+ }
+ },
+ message: "Scroll performance tracking active. Scroll the page then call getScrollSummary() for FPS data.",
+ getDataFn: "getScrollSummary"
+ };
+})();
diff --git a/dist/webperf-loading/Back-Forward-Cache.js b/dist/webperf-loading/Back-Forward-Cache.js
new file mode 100644
index 0000000..68465f7
--- /dev/null
+++ b/dist/webperf-loading/Back-Forward-Cache.js
@@ -0,0 +1,233 @@
+(() => {
+ const results = {
+ supported: "PerformanceNavigationTiming" in window,
+ wasRestored: false,
+ eligibility: null,
+ blockingReasons: [],
+ recommendations: []
+ };
+ const checkRestoration = () => {
+ window.addEventListener("pageshow", event => {
+ if (event.persisted) {
+ results.wasRestored = true;
+ }
+ });
+ if (results.supported) {
+ const navEntry = performance.getEntriesByType("navigation")[0];
+ if (navEntry && navEntry.type === "back_forward") if (navEntry.activationStart > 0) results.wasRestored = true;
+ }
+ };
+ const testEligibility = () => {
+ const issues = [];
+ const recs = [];
+ const hasUnload = window.onunload !== null || window.onbeforeunload !== null;
+ if (hasUnload) {
+ issues.push({
+ reason: "unload/beforeunload handler detected",
+ severity: "high",
+ description: "These handlers block bfcache"
+ });
+ recs.push("Remove unload/beforeunload handlers. Use pagehide or visibilitychange instead.");
+ }
+ const meta = document.querySelector('meta[http-equiv="Cache-Control"]');
+ if (meta && meta.content.includes("no-store")) {
+ issues.push({
+ reason: "Cache-Control: no-store in meta tag",
+ severity: "high",
+ description: "Prevents page from being cached"
+ });
+ recs.push("Remove Cache-Control: no-store or change to no-cache.");
+ }
+ if (window.indexedDB) {
+ const hasIndexedDB = performance.getEntriesByType("resource").some(r => r.name.includes("indexedDB"));
+ if (hasIndexedDB) {
+ issues.push({
+ reason: "IndexedDB may be in use",
+ severity: "medium",
+ description: "Open IndexedDB transactions block bfcache"
+ });
+ recs.push("Close IndexedDB connections before page hide.");
+ }
+ }
+ if (window.BroadcastChannel) issues.push({
+ reason: "BroadcastChannel API available (check if in use)",
+ severity: "low",
+ description: "Open BroadcastChannel connections may block bfcache"
+ });
+ const iframes = document.querySelectorAll("iframe");
+ if (iframes.length > 0) {
+ issues.push({
+ reason: `${iframes.length} iframe(s) detected`,
+ severity: "medium",
+ description: "Iframes with bfcache blockers will block parent page"
+ });
+ recs.push("Ensure iframes are also bfcache compatible.");
+ }
+ if ("serviceWorker" in navigator && navigator.serviceWorker.controller) issues.push({
+ reason: "Service Worker active",
+ severity: "info",
+ description: "Service Workers with fetch handlers are generally OK, but check for ongoing operations"
+ });
+ if (window.WebSocket) {
+ issues.push({
+ reason: "WebSocket API available (check if connections are open)",
+ severity: "medium",
+ description: "Open WebSocket connections block bfcache"
+ });
+ recs.push("Close WebSocket connections before page hide.");
+ }
+ const resources = performance.getEntriesByType("resource");
+ const recent = resources.filter(r => r.responseEnd === 0 || performance.now() - r.responseEnd < 100);
+ if (recent.length > 0) {
+ issues.push({
+ reason: `${recent.length} recent/ongoing network requests`,
+ severity: "low",
+ description: "Ongoing requests may prevent bfcache"
+ });
+ recs.push("Ensure requests complete or are aborted on page hide.");
+ }
+ results.blockingReasons = issues;
+ results.recommendations = recs;
+ const highSeverity = issues.filter(i => i.severity === "high").length;
+ const mediumSeverity = issues.filter(i => i.severity === "medium").length;
+ if (highSeverity > 0) results.eligibility = "blocked"; else if (mediumSeverity > 1) results.eligibility = "likely-blocked"; else if (issues.length > 0) results.eligibility = "potentially-eligible"; else results.eligibility = "likely-eligible";
+ return results.eligibility;
+ };
+ const displayResults = () => {
+ const statusIcons = {
+ "likely-eligible": "🟢",
+ "potentially-eligible": "🟡",
+ "likely-blocked": "🟠",
+ blocked: "🔴"
+ };
+ const statusColors = {
+ "likely-eligible": "#22c55e",
+ "potentially-eligible": "#f59e0b",
+ "likely-blocked": "#fb923c",
+ blocked: "#ef4444"
+ };
+ const statusText = {
+ "likely-eligible": "Likely Eligible",
+ "potentially-eligible": "Potentially Eligible",
+ "likely-blocked": "Likely Blocked",
+ blocked: "Blocked"
+ };
+ statusIcons[results.eligibility];
+ statusColors[results.eligibility];
+ statusText[results.eligibility];
+ if (results.wasRestored) {
+ } else {
+ }
+ if (results.supported) {
+ const navEntry = performance.getEntriesByType("navigation")[0];
+ if (navEntry) {
+ if (navEntry.type === "back_forward") if (navEntry.duration < 10) void 0; else void 0;
+ }
+ }
+ if (results.blockingReasons.length > 0) {
+ results.blockingReasons.map(issue => ({
+ Severity: issue.severity.toUpperCase(),
+ Issue: issue.reason,
+ Impact: issue.description
+ }));
+ } else {
+ }
+ if (results.recommendations.length > 0) {
+ results.recommendations.forEach((rec, idx) => {
+ });
+ }
+ return results;
+ };
+ const checkNotRestoredReasons = () => {
+ if (!("PerformanceNavigationTiming" in window)) return null;
+ const navEntry = performance.getEntriesByType("navigation")[0];
+ if (!navEntry || !navEntry.notRestoredReasons) return null;
+ const reasons = navEntry.notRestoredReasons;
+ if (reasons.blocked === true) void 0; else void 0;
+ if (reasons.url) void 0;
+ if (reasons.id) void 0;
+ if (reasons.name) void 0;
+ if (reasons.src) void 0;
+ if (reasons.reasons && reasons.reasons.length > 0) {
+ reasons.reasons.forEach((reasonDetail, idx) => {
+ const reasonName = reasonDetail.reason || "Unknown reason";
+ reasonDetail.source;
+ const reasonExplanations = {
+ WebSocket: "Open WebSocket connections prevent bfcache. Close them on pagehide event.",
+ "unload-listener": "unload event listeners block bfcache. Use pagehide or visibilitychange instead.",
+ "response-cache-control-no-store": "Cache-Control: no-store header prevents caching. Change to no-cache.",
+ IndexedDB: "Open IndexedDB transactions block bfcache. Close connections on pagehide.",
+ BroadcastChannel: "Open BroadcastChannel prevents bfcache. Close it on pagehide.",
+ "dedicated-worker": "Dedicated workers can block bfcache. Terminate them on pagehide."
+ };
+ if (reasonExplanations[reasonName]) void 0;
+ });
+ }
+ if (reasons.children && reasons.children.length > 0) {
+ reasons.children.forEach((child, idx) => {
+ if (child.blocked) void 0; else void 0;
+ if (child.id) void 0;
+ if (child.name) void 0;
+ if (child.reasons && child.reasons.length > 0) {
+ child.reasons.forEach(reasonDetail => {
+ if (reasonDetail.source) void 0;
+ });
+ }
+ });
+ }
+ if (reasons.reasons && reasons.reasons.length > 0) {
+ reasons.reasons.map(r => ({
+ Reason: r.reason || "Unknown",
+ Source: r.source || "N/A"
+ }));
+ }
+ return reasons;
+ };
+ const checkExecutionTiming = () => {
+ const navEntry = performance.getEntriesByType("navigation")[0];
+ if (navEntry && navEntry.type === "back_forward") {
+ }
+ };
+ checkRestoration();
+ testEligibility();
+ setTimeout(() => {
+ checkExecutionTiming();
+ displayResults();
+ const notRestoredReasons = checkNotRestoredReasons();
+ if (!notRestoredReasons) {
+ }
+ }, 100);
+ window.checkBfcache = () => {
+ testEligibility();
+ displayResults();
+ checkNotRestoredReasons();
+ return {
+ script: "Back-Forward-Cache",
+ status: "ok",
+ details: {
+ eligibility: results.eligibility,
+ wasRestored: results.wasRestored,
+ supported: results.supported
+ },
+ issues: results.blockingReasons.map(i => ({
+ severity: i.severity === "high" ? "error" : i.severity === "medium" ? "warning" : "info",
+ message: i.reason
+ }))
+ };
+ };
+ return {
+ script: "Back-Forward-Cache",
+ status: "ok",
+ details: {
+ eligibility: results.eligibility,
+ wasRestored: results.wasRestored,
+ supported: results.supported
+ },
+ issues: results.blockingReasons.map(i => ({
+ severity: i.severity === "high" ? "error" : i.severity === "medium" ? "warning" : "info",
+ message: i.reason
+ })),
+ message: "bfcache analysis running. Call checkBfcache() to re-run analysis.",
+ getDataFn: "checkBfcache"
+ };
+})();
diff --git a/dist/webperf-loading/CSS-Media-Queries-Analysis.js b/dist/webperf-loading/CSS-Media-Queries-Analysis.js
new file mode 100644
index 0000000..84051d7
--- /dev/null
+++ b/dist/webperf-loading/CSS-Media-Queries-Analysis.js
@@ -0,0 +1,249 @@
+async function analyzeCSSMediaQueries(minWidth = 768) {
+ const stylesheets = [ ...document.styleSheets ];
+ const inlineMediaQueries = [];
+ const fileMediaQueries = [];
+ let inlineTotalClasses = 0;
+ let inlineTotalProperties = 0;
+ let filesTotalClasses = 0;
+ let filesTotalProperties = 0;
+ let inlineTotalBytes = 0;
+ let filesTotalBytes = 0;
+ let corsBlockedCount = 0;
+ function isBiggerThanBreakpoint(mediaText) {
+ if (!mediaText) return false;
+ const minWidthMatch = mediaText.match(/min-width:\s*(\d+)(px|em|rem)/i);
+ if (minWidthMatch) {
+ const value = parseInt(minWidthMatch[1]);
+ const unit = minWidthMatch[2].toLowerCase();
+ if (unit === "px" && value > minWidth) return true;
+ if (unit === "em" && value > minWidth / 16) return true;
+ if (unit === "rem" && value > minWidth / 16) return true;
+ }
+ const maxWidthMatch = mediaText.match(/max-width:\s*(\d+)(px|em|rem)/i);
+ if (maxWidthMatch && !minWidthMatch) return false;
+ return false;
+ }
+ function countClassesAndProperties(cssText) {
+ const classMatches = cssText.match(/\.[a-zA-Z0-9_-]+/g) || [];
+ const propertyMatches = cssText.match(/[a-z-]+\s*:/g) || [];
+ return {
+ classes: classMatches.length,
+ properties: propertyMatches.length
+ };
+ }
+ function getByteSize(text) {
+ return new Blob([ text ]).size;
+ }
+ function formatBytes(bytes) {
+ if (bytes === 0) return "0 Bytes";
+ const k = 1024;
+ const sizes = [ "Bytes", "KB", "MB" ];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
+ }
+ function parseMediaQueriesFromCSS(cssText, source, isInline) {
+ const mediaRegex = /@media\s*([^{]+)\{((?:[^{}]|\{[^{}]*\})*)\}/g;
+ let match;
+ while ((match = mediaRegex.exec(cssText)) !== null) {
+ const mediaText = match[1].trim();
+ const mediaContent = match[2];
+ if (isBiggerThanBreakpoint(mediaText)) {
+ const counts = countClassesAndProperties(mediaContent);
+ const byteSize = getByteSize(match[0]);
+ const mediaQueryData = {
+ source: source,
+ mediaQuery: mediaText,
+ classes: counts.classes,
+ properties: counts.properties,
+ bytes: byteSize,
+ bytesFormatted: formatBytes(byteSize)
+ };
+ if (isInline) {
+ inlineMediaQueries.push(mediaQueryData);
+ inlineTotalClasses += counts.classes;
+ inlineTotalProperties += counts.properties;
+ inlineTotalBytes += byteSize;
+ } else {
+ fileMediaQueries.push(mediaQueryData);
+ filesTotalClasses += counts.classes;
+ filesTotalProperties += counts.properties;
+ filesTotalBytes += byteSize;
+ }
+ }
+ }
+ }
+ for (let sheetIndex = 0; sheetIndex < stylesheets.length; sheetIndex++) {
+ const sheet = stylesheets[sheetIndex];
+ const isInline = !sheet.href;
+ const source = sheet.href || `