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 || `