From 097ec5c9a23e9567d7392eaf85ec3970c3cd3477 Mon Sep 17 00:00:00 2001 From: Joan Leon Date: Tue, 17 Mar 2026 12:50:07 +0100 Subject: [PATCH 01/13] feat: add webperf-core-web-vitals SKILL.md with workflows and decision tree --- skills/webperf-core-web-vitals/SKILL.md | 213 ++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 skills/webperf-core-web-vitals/SKILL.md diff --git a/skills/webperf-core-web-vitals/SKILL.md b/skills/webperf-core-web-vitals/SKILL.md new file mode 100644 index 000000000..ca7761be1 --- /dev/null +++ b/skills/webperf-core-web-vitals/SKILL.md @@ -0,0 +1,213 @@ +--- +name: webperf-core-web-vitals +description: Intelligent Core Web Vitals analysis with automated workflows and decision trees. Measures LCP, CLS, INP with guided debugging that automatically determines follow-up analysis based on results. Includes workflows for LCP deep dive (5 phases), CLS investigation (loading vs interaction), INP debugging (latency breakdown + attribution), and cross-skill integration with loading, interaction, and media skills. Use when the user asks about Core Web Vitals, LCP optimization, layout shifts, or interaction responsiveness. +--- + +# WebPerf: Core Web Vitals + +JavaScript snippets for measuring web performance in Chrome DevTools. Execute with `evaluate_script`, capture output with `get_console_message`. + +## Scripts + +- `scripts/CLS.js` — Cumulative Layout Shift (CLS) +- `scripts/INP.js` — Interaction to Next Paint (INP) +- `scripts/LCP-Image-Entropy.js` — LCP Image Entropy +- `scripts/LCP-Sub-Parts.js` — LCP Sub-Parts +- `scripts/LCP-Trail.js` — LCP Trail +- `scripts/LCP-Video-Candidate.js` — LCP Video Candidate +- `scripts/LCP.js` — Largest Contentful Paint (LCP) + +Descriptions, thresholds, and return schemas: `references/snippets.md`, `references/schema.md` + +## Script Execution Patterns + +Scripts fall into two execution patterns: + +### Synchronous (LCP, CLS, LCP-Sub-Parts, LCP-Trail, LCP-Image-Entropy, LCP-Video-Candidate) + +Run via `evaluate_script` and return structured JSON immediately from buffered performance data. The page must have already loaded. + +### Tracking (INP) + +INP requires real user interactions to measure. The workflow is: + +1. Run `INP.js` via `evaluate_script` → returns `{ status: "tracking", getDataFn: "getINP" }` +2. **Tell the user:** "INP tracking is now active. Please interact with the page — click buttons, open menus, fill form fields — then let me know when you're done." +3. Wait for the user to confirm they've interacted. +4. Call `evaluate_script("getINP()")` to collect results. + +> The agent cannot interact with the page on behalf of the user for INP measurement. Real user interactions are required. + +## Common Workflows + +### Complete Core Web Vitals Audit + +When the user asks for a comprehensive Core Web Vitals analysis or "audit CWV": + +1. **LCP.js** - Measure Largest Contentful Paint +2. **CLS.js** - Measure Cumulative Layout Shift +3. **INP.js** - Measure Interaction to Next Paint +4. **LCP-Sub-Parts.js** - Break down LCP timing phases +5. **LCP-Trail.js** - Track LCP candidate evolution + +### LCP Deep Dive + +When LCP is slow or the user asks "debug LCP" or "why is LCP slow": + +1. **LCP.js** - Establish baseline LCP value +2. **LCP-Sub-Parts.js** - Break down into TTFB, resource load, render delay +3. **LCP-Trail.js** - Identify all LCP candidates and changes +4. **LCP-Image-Entropy.js** - Check if LCP image has visual complexity issues +5. **LCP-Video-Candidate.js** - Detect if LCP is a video (poster or video element) + +### CLS Investigation + +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 +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) + +### INP Debugging + +When interactions feel slow or the user asks "debug INP" or "slow interactions": + +1. **INP.js** - Start tracking. Tell the user to interact with the page and confirm when done. +2. Call `getINP()` to collect results once the user confirms. +3. **Interactions.js** (from Interaction skill) - List all interactions with timing +4. **Input-Latency-Breakdown.js** (from Interaction skill) - Break down input delay, processing, presentation +5. **Long-Animation-Frames.js** (from Interaction skill) - Identify blocking animation frames +6. **Long-Animation-Frames-Script-Attribution.js** (from Interaction skill) - Find scripts causing delays + +### Video as LCP Investigation + +When LCP is a video element (detected by LCP-Video-Candidate.js): + +1. **LCP-Video-Candidate.js** - Identify video as LCP candidate +2. **Video-Element-Audit.js** (from Media skill) - Audit video loading strategy +3. **LCP-Sub-Parts.js** - Analyze video loading phases +4. Cross-reference with **webperf-loading** skill: + - Resource-Hints-Validation.js (check for video preload) + - Priority-Hints-Audit.js (check for fetchpriority on video) + +### Image as LCP Investigation + +When LCP is an image (most common case): + +1. **LCP.js** - Measure LCP timing +2. **LCP-Sub-Parts.js** - Break down timing phases +3. **LCP-Image-Entropy.js** - Analyze image complexity +4. Cross-reference with **webperf-media** skill: + - Image-Element-Audit.js (check format, dimensions, lazy loading) +5. Cross-reference with **webperf-loading** skill: + - Find-Above-The-Fold-Lazy-Loaded-Images.js (check if incorrectly lazy) + - Priority-Hints-Audit.js (check for fetchpriority="high") + - Resource-Hints-Validation.js (check for preload) + +## Decision Tree + +Use this decision tree to automatically run follow-up snippets based on results: + +### After LCP.js + +- **If LCP > 2.5s** → Run **LCP-Sub-Parts.js** to diagnose which phase is slow +- **If LCP > 4.0s (poor)** → Run full LCP deep dive workflow (5 snippets) +- **If LCP candidate is an image** → Run **LCP-Image-Entropy.js** and **webperf-media:Image-Element-Audit.js** +- **If LCP candidate is a video** → Run **LCP-Video-Candidate.js** and **webperf-media:Video-Element-Audit.js** +- **Always run** → **LCP-Trail.js** to understand candidate evolution + +### After LCP-Sub-Parts.js + +- **If TTFB phase > 600ms** → Switch to **webperf-loading** skill and run TTFB-Sub-Parts.js +- **If Resource Load Time > 1500ms** → Run: + 1. **webperf-loading:Resource-Hints-Validation.js** (check for preload/preconnect) + 2. **webperf-loading:Priority-Hints-Audit.js** (check fetchpriority) + 3. **webperf-loading:Find-render-blocking-resources.js** (competing resources) +- **If Render Delay > 200ms** → Run: + 1. **webperf-loading:Find-render-blocking-resources.js** (blocking CSS/JS) + 2. **webperf-loading:Script-Loading.js** (parser-blocking scripts) + 3. **webperf-interaction:Long-Animation-Frames.js** (main thread blocking) + +### After LCP-Trail.js + +- **If many LCP candidate changes (>3)** → This causes visual instability, investigate: + 1. **webperf-loading:Find-Above-The-Fold-Lazy-Loaded-Images.js** (late-loading images) + 2. **webperf-loading:Fonts-Preloaded-Loaded-and-used-above-the-fold.js** (font swaps) + 3. **CLS.js** (layout shifts contributing to LCP changes) +- **If final LCP candidate appears late** → Run **webperf-loading:Resource-Hints-Validation.js** +- **If early candidate was replaced** → Understand why initial content was pushed down (likely CLS issue) + +### After LCP-Image-Entropy.js + +- **If entropy is very high** → Image is visually complex, recommend: + - Modern formats (WebP, AVIF) + - Appropriate compression + - Potentially a placeholder strategy +- **If entropy is low** → Image may be over-optimized or placeholder-like +- **If large file size detected** → Run **webperf-media:Image-Element-Audit.js** for format/sizing analysis + +### After LCP-Video-Candidate.js + +- **If video is LCP** → Run: + 1. **webperf-media:Video-Element-Audit.js** (check poster, preload, formats) + 2. **webperf-loading:Priority-Hints-Audit.js** (check fetchpriority on poster) + 3. **LCP-Sub-Parts.js** (analyze video loading phases) +- **If poster image is LCP** → Treat as image LCP (run image workflows) + +### After CLS.js + +- **If CLS > 0.1** → Run **webperf-interaction:Layout-Shift-Loading-and-Interaction.js** to separate causes +- **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) + 3. **webperf-loading:Critical-CSS-Detection.js** (late-loading styles) + 4. **webperf-media:Image-Element-Audit.js** (missing width/height) +- **If CLS = 0** → Confirm with multiple page loads (might be timing-dependent) + +### After INP.js + +- **If INP > 200ms** → Run **webperf-interaction:Interactions.js** to identify slow interactions +- **If INP > 500ms (poor)** → Run full INP debugging workflow: + 1. **webperf-interaction:Interactions.js** (list all interactions) + 2. **webperf-interaction:Input-Latency-Breakdown.js** (phase breakdown) + 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 + +### Cross-Skill Triggers + +These triggers recommend using snippets from other skills: + +#### From LCP to Loading Skill + +- **If LCP > 2.5s and TTFB phase is dominant** → Use **webperf-loading** skill: + - TTFB.js, TTFB-Sub-Parts.js, Service-Worker-Analysis.js + +- **If LCP image is lazy-loaded** → Use **webperf-loading** skill: + - Find-Above-The-Fold-Lazy-Loaded-Images.js + +- **If LCP has no fetchpriority** → Use **webperf-loading** skill: + - Priority-Hints-Audit.js + +#### From CLS to Loading Skill + +- **If CLS caused by fonts** → Use **webperf-loading** skill: + - Fonts-Preloaded-Loaded-and-used-above-the-fold.js + - Resource-Hints-Validation.js (for font preload) + +- **If CLS caused by images** → Use **webperf-media** skill: + - Image-Element-Audit.js (check for width/height attributes) + +#### From INP to Interaction Skill + +- **If INP > 200ms** → Use **webperf-interaction** skill for full debugging: + - Interactions.js, Input-Latency-Breakdown.js + - Long-Animation-Frames.js, Long-Animation-Frames-Script-Attribution.js + - LongTask.js (if pre-interaction blocking suspected) + +#### From LCP/INP to Interaction Skill + +- **If render delay or interaction delay is high** → Use **webperf-interaction** skill: + - Long-Animation-Frames.js (main thread blocking) From d2d56606d7b2de57921292b16d7251c98d031c7a Mon Sep 17 00:00:00 2001 From: Joan Leon Date: Tue, 17 Mar 2026 12:50:12 +0100 Subject: [PATCH 02/13] feat: add core CWV measurement scripts (LCP, CLS, INP) --- skills/webperf-core-web-vitals/scripts/CLS.js | 79 ++++ skills/webperf-core-web-vitals/scripts/INP.js | 428 ++++++++++++++++++ skills/webperf-core-web-vitals/scripts/LCP.js | 125 +++++ 3 files changed, 632 insertions(+) create mode 100644 skills/webperf-core-web-vitals/scripts/CLS.js create mode 100644 skills/webperf-core-web-vitals/scripts/INP.js create mode 100644 skills/webperf-core-web-vitals/scripts/LCP.js diff --git a/skills/webperf-core-web-vitals/scripts/CLS.js b/skills/webperf-core-web-vitals/scripts/CLS.js new file mode 100644 index 000000000..1c45ade4f --- /dev/null +++ b/skills/webperf-core-web-vitals/scripts/CLS.js @@ -0,0 +1,79 @@ +// CLS Quick Check +// https://webperf-snippets.nucliweb.net + +(() => { + 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, color } = RATING[rating]; + console.log( + `%cCLS: ${icon} ${cls.toFixed(4)} (${rating})`, + `color: ${color}; font-weight: bold; font-size: 14px;` + ); + }; + + 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 }); + + // Update on visibility change (final CLS) + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") { + observer.takeRecords(); + console.log("%c📊 Final CLS (on page hide):", "font-weight: bold;"); + logCLS(); + } + }); + + // Expose function for manual check + window.getCLS = () => { + logCLS(); + const rating = valueToRating(cls); + return { + script: "CLS", + status: "ok", + metric: "CLS", + value: Math.round(cls * 10000) / 10000, + unit: "score", + rating, + thresholds: { good: 0.1, needsImprovement: 0.25 }, + }; + }; + + console.log( + " Call %cgetCLS()%c anytime to check current value.", + "font-family: monospace; background: #f3f4f6; padding: 2px 4px;", + "" + ); + + // Synchronous return for agent (buffered entries) + 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 }, + }; +})(); diff --git a/skills/webperf-core-web-vitals/scripts/INP.js b/skills/webperf-core-web-vitals/scripts/INP.js new file mode 100644 index 000000000..9d412ee3c --- /dev/null +++ b/skills/webperf-core-web-vitals/scripts/INP.js @@ -0,0 +1,428 @@ +// INP (Interaction to Next Paint) Tracking +// https://webperf-snippets.nucliweb.net + +(() => { + 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 formatMs = (ms) => `${Math.round(ms)}ms`; + + // Calculate INP (98th percentile of all interactions) + const calculateINP = () => { + if (interactions.length === 0) return { value: 0, entry: null }; + + // Sort by duration + const sorted = [...interactions].sort((a, b) => b.duration - a.duration); + + // Get 98th percentile (or worst if < 50 interactions) + const index = interactions.length < 50 + ? 0 + : Math.floor(interactions.length * 0.02); + + return { + value: sorted[index].duration, + entry: sorted[index], + }; + }; + + // Format interaction name + 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}`; + }; + + // Get phase breakdown (requires LoAF support) + 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; + }; + + // Observer for interactions + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + // Only track interactions with interactionId (meaningful interactions) + if (!entry.interactionId) continue; + + // Avoid duplicate entries for the same interaction + const existing = interactions.find( + (i) => i.interactionId === entry.interactionId + ); + + if (!existing || entry.duration > existing.duration) { + // Remove old entry if exists + 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, + }); + } + + // Recalculate INP + const result = calculateINP(); + inpValue = result.value; + inpEntry = result.entry; + } + }); + + // Observe event timing + observer.observe({ + type: "event", + buffered: true, + durationThreshold: 16, // Only interactions > 16ms (1 frame) + }); + + // Log INP summary + const logINP = () => { + const rating = valueToRating(inpValue); + const { icon, color } = RATING[rating]; + + console.group( + `%cINP: ${icon} ${formatMs(inpValue)} (${rating})`, + `color: ${color}; font-weight: bold; font-size: 14px;` + ); + + console.log(""); + console.log(`%c📊 Analysis:`, "font-weight: bold;"); + console.log(` Total interactions tracked: ${interactions.length}`); + console.log(` INP (98th percentile): ${formatMs(inpValue)}`); + + if (inpEntry) { + console.log(""); + console.log(`%c🎯 Worst Interaction (INP):`, "font-weight: bold; color: ${color};"); + console.log(` Event: ${inpEntry.formattedName}`); + console.log(` Duration: ${formatMs(inpEntry.duration)}`); + + // Element attribution + if (inpEntry.target) { + console.log(` Target Element:`, inpEntry.target); + + // Get element path for better context + 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) { + console.log(` Element Path: ${path}`); + } + } + + // Phase breakdown + const phases = inpEntry.phases; + if (phases.inputDelay > 0) { + console.log(""); + console.log(`%c⏱️ Phase Breakdown:`, "font-weight: bold;"); + console.log(` Input Delay: ${formatMs(phases.inputDelay)}`); + console.log(` Processing Time: ${formatMs(phases.processingTime)}`); + console.log(` Presentation Delay: ${formatMs(phases.presentationDelay)}`); + + // Visual bar + const total = inpEntry.duration; + const barWidth = 40; + const inputBar = "▓".repeat(Math.round((phases.inputDelay / total) * barWidth)); + const processBar = "█".repeat(Math.round((phases.processingTime / total) * barWidth)); + const presentBar = "░".repeat(Math.round((phases.presentationDelay / total) * barWidth)); + console.log(` ${inputBar}${processBar}${presentBar}`); + console.log(" ▓ Input █ Processing ░ Presentation"); + } + + // Recommendations based on phases + if (inpValue > 200 && phases.inputDelay > 0) { + console.log(""); + console.log("%c💡 Recommendations:", "color: #3b82f6; font-weight: bold;"); + + if (phases.inputDelay > 100) { + console.log(" • High input delay - Break up long tasks before interaction"); + } + if (phases.processingTime > 200) { + console.log(" • Long processing time - Optimize event handlers"); + console.log(" • Consider debouncing, use requestIdleCallback for non-critical work"); + } + if (phases.presentationDelay > 100) { + console.log(" • High presentation delay - Reduce render complexity"); + console.log(" • Batch DOM updates, use content-visibility"); + } + } + } + + // Slow interactions breakdown + const slowInteractions = interactions + .filter((i) => i.duration > 200) + .sort((a, b) => b.duration - a.duration) + .slice(0, 10); + + if (slowInteractions.length > 0) { + console.log(""); + console.log(`%c🐌 Slow Interactions (> 200ms):`, "color: #ef4444; font-weight: bold;"); + console.table( + slowInteractions.map((i) => ({ + Event: i.formattedName, + "Duration (ms)": Math.round(i.duration), + "Start Time (ms)": Math.round(i.startTime), + })) + ); + + // Show element attribution for top 3 + console.log(""); + console.log(`%c🎯 Element Attribution (top 3):`, "font-weight: bold;"); + slowInteractions.slice(0, 3).forEach((interaction, idx) => { + console.log(` ${idx + 1}. ${interaction.formattedName} (${Math.round(interaction.duration)}ms)`); + if (interaction.target) { + console.log(` Element:`, interaction.target); + } else { + console.log(` Element: (no target available)`); + } + }); + } + + // Interaction types breakdown + 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) { + console.log(""); + console.log(`%c📋 By Interaction Type:`, "font-weight: bold;"); + console.table( + Object.entries(byType).map(([type, stats]) => ({ + Type: type, + Count: stats.count, + "Avg (ms)": Math.round(stats.totalDuration / stats.count), + "Max (ms)": Math.round(stats.maxDuration), + })) + ); + } + + // General recommendations if no phases available + if (inpValue > 200 && (!inpEntry || !inpEntry.phases || inpEntry.phases.inputDelay === 0)) { + console.log(""); + console.log("%c💡 Recommendations:", "color: #3b82f6; font-weight: bold;"); + console.log(" • Break up long tasks using scheduler.yield() or setTimeout"); + console.log(" • Optimize event handlers - reduce computation time"); + console.log(" • Consider debouncing for frequent events"); + console.log(" • Move heavy work to Web Workers"); + console.log(" • Use requestIdleCallback for non-critical work"); + + console.log(""); + console.log(" Run getINPDetails() for full interaction list"); + console.log(" Use Long Animation Frames snippet to identify blocking scripts"); + } + + console.groupEnd(); + }; + + // Expose function to check INP anytime + 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", + metric: "INP", value: 0, unit: "ms", rating: "good", + thresholds: { good: 200, needsImprovement: 500 }, details }; + } + return { + script: "INP", + status: "ok", + metric: "INP", + value: Math.round(inpValue), + unit: "ms", + rating, + thresholds: { good: 200, needsImprovement: 500 }, + details, + }; + }; + + // Expose function to get all interactions + window.getINPDetails = () => { + console.group("%c📊 All Interactions Detail", "font-weight: bold; font-size: 14px;"); + + if (interactions.length === 0) { + console.log(" No interactions recorded yet."); + console.groupEnd(); + return []; + } + + const sorted = [...interactions].sort((a, b) => b.duration - a.duration); + + console.log(""); + console.log("%cInteraction Summary:", "font-weight: bold;"); + console.table( + sorted.map((i, idx) => ({ + "#": idx + 1, + Event: i.formattedName, + "Duration (ms)": Math.round(i.duration), + "Start (ms)": Math.round(i.startTime), + "Input Delay": Math.round(i.phases.inputDelay), + Processing: Math.round(i.phases.processingTime), + Presentation: Math.round(i.phases.presentationDelay), + })) + ); + + // Show element attribution for all interactions + console.log(""); + console.log("%c🎯 Element Attribution:", "font-weight: bold;"); + + const maxToShow = Math.min(sorted.length, 15); // Show up to 15 + sorted.slice(0, maxToShow).forEach((interaction, idx) => { + const phases = interaction.phases; + const hasPhases = phases.inputDelay > 0; + + console.group( + `${idx + 1}. ${interaction.formattedName} - ${Math.round(interaction.duration)}ms` + ); + + if (interaction.target) { + console.log("Element:", interaction.target); + + // Get element path for better identification + 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) { + console.log("Path:", path); + } + } else { + console.log("Element: (no target available)"); + } + + if (hasPhases) { + console.log( + `Phases: Input ${Math.round(phases.inputDelay)}ms | ` + + `Processing ${Math.round(phases.processingTime)}ms | ` + + `Presentation ${Math.round(phases.presentationDelay)}ms` + ); + } + + console.groupEnd(); + }); + + if (sorted.length > maxToShow) { + console.log(` ... and ${sorted.length - maxToShow} more interactions`); + } + + console.groupEnd(); + return sorted; + }; + + // Log on page hide (final INP) + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") { + observer.takeRecords(); + const result = calculateINP(); + inpValue = result.value; + inpEntry = result.entry; + + console.log("%c📊 Final INP (on page hide):", "font-weight: bold;"); + logINP(); + } + }); + + console.log("%c⚡ INP Tracking Active", "font-weight: bold; font-size: 14px;"); + console.log(" Interactions with duration > 16ms will be tracked."); + console.log( + " Call %cgetINP()%c to see current INP value.", + "font-family: monospace; background: #f3f4f6; padding: 2px 4px;", + "" + ); + console.log( + " Call %cgetINPDetails()%c for full interaction list.", + "font-family: monospace; background: #f3f4f6; padding: 2px 4px;", + "" + ); + + return { + script: "INP", + status: "tracking", + message: "INP tracking active. Interact with the page then call getINP() for results.", + getDataFn: "getINP", + }; +})(); diff --git a/skills/webperf-core-web-vitals/scripts/LCP.js b/skills/webperf-core-web-vitals/scripts/LCP.js new file mode 100644 index 000000000..a37f6cce8 --- /dev/null +++ b/skills/webperf-core-web-vitals/scripts/LCP.js @@ -0,0 +1,125 @@ +// LCP Quick Check +// https://webperf-snippets.nucliweb.net + +(() => { + 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, color } = RATING[rating]; + + console.group(`%cLCP: ${icon} ${(lcpTime / 1000).toFixed(2)}s (${rating})`, `color: ${color}; font-weight: bold; font-size: 14px;`); + + // Element info + const element = lastEntry.element; + if (element) { + console.log(""); + console.log("%cLCP Element:", "font-weight: bold;"); + + // Get element identifier + 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}`; + } + + console.log(` Element: ${selector}`, element); + + // Element type and details + const tagName = element.tagName.toLowerCase(); + if (tagName === "img") { + console.log(` Type: Image`); + console.log(` URL: ${lastEntry.url || element.src}`); + if (element.naturalWidth) { + console.log(` Dimensions: ${element.naturalWidth}×${element.naturalHeight}`); + } + } else if (tagName === "video") { + console.log(` Type: Video poster`); + console.log(` URL: ${lastEntry.url || element.poster}`); + } else if (element.style?.backgroundImage) { + console.log(` Type: Background image`); + console.log(` URL: ${lastEntry.url}`); + } else { + console.log(` Type: ${tagName === "h1" || tagName === "p" ? "Text block" : tagName}`); + } + + // Size + if (lastEntry.size) { + console.log(` Size: ${lastEntry.size.toLocaleString()} px²`); + } + + // Highlight element + element.style.outline = "3px dashed lime"; + element.style.outlineOffset = "2px"; + console.log(""); + console.log("%c✓ Element highlighted with green dashed outline", "color: #22c55e;"); + } + + console.groupEnd(); + }); + + observer.observe({ type: "largest-contentful-paint", buffered: true }); + + console.log("%c⏱️ LCP Tracking Active", "font-weight: bold; font-size: 14px;"); + console.log(" LCP may update as larger elements load."); + + // Synchronous return for agent (buffered entries) + 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" : + lcpEl.style?.backgroundImage ? "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, + }, + }; +})(); From 4466cb196b996afc7b19ab4320b13844a22a12f2 Mon Sep 17 00:00:00 2001 From: Joan Leon Date: Tue, 17 Mar 2026 12:50:16 +0100 Subject: [PATCH 03/13] feat: add LCP diagnostic scripts (Sub-Parts, Trail, Image-Entropy, Video-Candidate) --- .../scripts/LCP-Image-Entropy.js | 194 +++++++++++++ .../scripts/LCP-Sub-Parts.js | 267 ++++++++++++++++++ .../scripts/LCP-Trail.js | 175 ++++++++++++ .../scripts/LCP-Video-Candidate.js | 213 ++++++++++++++ 4 files changed, 849 insertions(+) create mode 100644 skills/webperf-core-web-vitals/scripts/LCP-Image-Entropy.js create mode 100644 skills/webperf-core-web-vitals/scripts/LCP-Sub-Parts.js create mode 100644 skills/webperf-core-web-vitals/scripts/LCP-Trail.js create mode 100644 skills/webperf-core-web-vitals/scripts/LCP-Video-Candidate.js diff --git a/skills/webperf-core-web-vitals/scripts/LCP-Image-Entropy.js b/skills/webperf-core-web-vitals/scripts/LCP-Image-Entropy.js new file mode 100644 index 000000000..59c3e5998 --- /dev/null +++ b/skills/webperf-core-web-vitals/scripts/LCP-Image-Entropy.js @@ -0,0 +1,194 @@ +// LCP Image Entropy Check +// https://webperf-snippets.nucliweb.net + +(() => { + 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; // Chrome's threshold for low-entropy + + // Get current LCP element + 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 }); + + // Wait a tick to ensure LCP is captured (for human console output) + setTimeout(() => { + lcpObserver.disconnect(); + + // Get all images + 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, + shortSrc: src.split("/").pop()?.split("?")[0] || src, + width: img.naturalWidth, + height: img.naturalHeight, + fileSize, + bpp, + isLowEntropy, + isLCP, + lcpEligible: !isLowEntropy && bpp > 0, + }; + }) + .filter((img) => img.bpp > 0); // Only images with measurable BPP + + console.group("%c🖼️ Image Entropy Analysis", "font-weight: bold; font-size: 14px;"); + + if (images.length === 0) { + console.log(" No images with measurable entropy found."); + console.log(" (Data URLs and cross-origin images without CORS are excluded)"); + console.groupEnd(); + return; + } + + // Summary + const lowEntropy = images.filter((img) => img.isLowEntropy); + const normalEntropy = images.filter((img) => !img.isLowEntropy); + const lcpImage = images.find((img) => img.isLCP); + + console.log(""); + console.log("%cSummary:", "font-weight: bold;"); + console.log(` Total images analyzed: ${images.length}`); + console.log(` 🟢 Normal entropy (LCP eligible): ${normalEntropy.length}`); + console.log(` 🔴 Low entropy (LCP ineligible): ${lowEntropy.length}`); + + if (lcpImage) { + const icon = lcpImage.isLowEntropy ? "⚠️" : "✅"; + console.log(""); + console.log(`%c${icon} Current LCP image:`, "font-weight: bold;"); + console.log(` ${lcpImage.shortSrc}`); + console.log(` BPP: ${lcpImage.bpp.toFixed(4)} ${lcpImage.isLowEntropy ? "(LOW - may be skipped!)" : "(OK)"}`); + } + + // Table + console.log(""); + console.log("%cAll Images:", "font-weight: bold;"); + + const tableData = 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 ? "👈" : "", + })); + + console.table(tableData); + + // Warnings + if (lowEntropy.length > 0) { + console.log(""); + console.log("%c⚠️ Low Entropy Images:", "font-weight: bold; color: #f59e0b;"); + console.log(" These images will NOT be considered for LCP in Chrome 112+:"); + lowEntropy.forEach((img) => { + console.log(` • ${img.shortSrc} (BPP: ${img.bpp.toFixed(4)})`, img.element); + }); + } + + if (lcpImage && lcpImage.isLowEntropy) { + console.log(""); + console.log("%c🚨 Warning:", "font-weight: bold; color: #ef4444;"); + console.log(" Your LCP image has low entropy and may be skipped by Chrome!"); + console.log(" Chrome will use the next largest element instead."); + console.log(""); + console.log("%c💡 Solutions:", "font-weight: bold; color: #3b82f6;"); + console.log(" • Replace placeholder with actual content image"); + console.log(" • Use a text element as LCP instead"); + console.log(" • Ensure hero image loads with sufficient detail"); + } + + // Elements for inspection + console.log(""); + console.log("%c🔎 Inspect elements:", "font-weight: bold;"); + images.forEach((img, i) => { + const icon = img.isLowEntropy ? "🔴" : "🟢"; + const lcpMark = img.isLCP ? " 👈 LCP" : ""; + console.log(` ${i + 1}. ${icon} ${img.shortSrc}${lcpMark}`, img.element); + }); + + console.groupEnd(); + }, 100); + + // Synchronous return for agent (buffered entries + DOM) + 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, + lcpEligible: !isLowEntropy && bpp > 0, + isLCP, + }; + }) + .filter((img) => img.bpp > 0); + const lowEntropyCount = imagesSync.filter((img) => img.isLowEntropy).length; + const lcpImageSync = imagesSync.find((img) => img.isLCP); + 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, + lcpImageEligible: lcpImageSync ? !lcpImageSync.isLowEntropy : null, + lcpImage: lcpImageSync ? { url: lcpImageSync.url, bpp: lcpImageSync.bpp, isLowEntropy: lcpImageSync.isLowEntropy } : null, + }, + items: imagesSync, + issues: issuesSync, + }; +})(); diff --git a/skills/webperf-core-web-vitals/scripts/LCP-Sub-Parts.js b/skills/webperf-core-web-vitals/scripts/LCP-Sub-Parts.js new file mode 100644 index 000000000..47bab3737 --- /dev/null +++ b/skills/webperf-core-web-vitals/scripts/LCP-Sub-Parts.js @@ -0,0 +1,267 @@ +// LCP Sub-Parts Analysis +// https://webperf-snippets.nucliweb.net + +(() => { + 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: "loadDelay", targetPercent: 10 }, + { name: "Resource Load Time", key: "loadTime", targetPercent: 40 }, + { name: "Element Render Delay", key: "renderDelay", 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; + + // Calculate sub-parts + 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, + loadDelay: lcpRequestStart - ttfb, + loadTime: lcpResponseEnd - lcpRequestStart, + renderDelay: lcpRenderTime - lcpResponseEnd, + }; + + // LCP Rating + const rating = valueToRating(lcpRenderTime); + const { icon, color } = RATING[rating]; + + console.group( + `%cLCP: ${icon} ${formatMs(lcpRenderTime)} (${rating})`, + `color: ${color}; font-weight: bold; font-size: 14px;` + ); + + // Element info + 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}`; + } + + console.log(""); + console.log("%cLCP Element:", "font-weight: bold;"); + console.log(` ${selector}`, el); + if (lcpEntry.url) { + const shortUrl = lcpEntry.url.split("/").pop()?.split("?")[0] || lcpEntry.url; + console.log(` URL: ${shortUrl}`); + } + + // Highlight + el.style.outline = "3px dashed lime"; + el.style.outlineOffset = "2px"; + } + + // Sub-parts table + console.log(""); + console.log("%cSub-Parts Breakdown:", "font-weight: bold;"); + + // Find the slowest phase + 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)); + + const tableData = 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", + }; + }); + + console.table(tableData); + + // Visual bar + 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 }; + }); + + const ttfbBar = "█".repeat(bars[0].bar); + const delayBar = "▓".repeat(bars[1].bar); + const loadBar = "▒".repeat(bars[2].bar); + const renderBar = "░".repeat(bars[3].bar); + + console.log(""); + console.log(` ${ttfbBar}${delayBar}${loadBar}${renderBar}`); + console.log(" █ TTFB ▓ Load Delay ▒ Load Time ░ Render Delay"); + + // Recommendations based on slowest phase + console.log(""); + console.log("%c💡 Optimization Focus:", "font-weight: bold; color: #3b82f6;"); + console.log(` Slowest phase: ${slowest.name} (${formatPercent(slowest.value, lcpRenderTime)})`); + + if (slowest.key === "ttfb") { + console.log(" → Use a CDN to reduce latency"); + console.log(" → Enable server-side caching"); + console.log(" → Optimize server response time"); + } else if (slowest.key === "loadDelay") { + console.log(" → Preload the LCP image: "); + console.log(" → Remove render-blocking resources"); + console.log(" → Inline critical CSS"); + } else if (slowest.key === "loadTime") { + console.log(" → Compress and resize the LCP image"); + console.log(" → Use modern formats (WebP, AVIF)"); + console.log(" → Use a CDN for faster delivery"); + } else if (slowest.key === "renderDelay") { + console.log(" → Reduce render-blocking JavaScript"); + console.log(" → Avoid client-side rendering for LCP element"); + console.log(" → Use fetchpriority=\"high\" on LCP image"); + } + + // Performance entries for DevTools + SUB_PARTS.forEach((part) => performance.clearMeasures(part.name)); + + phases.forEach((part) => { + const startTimes = { + ttfb: 0, + loadDelay: ttfb, + loadTime: lcpRequestStart, + renderDelay: lcpResponseEnd, + }; + performance.measure(part.name, { + start: startTimes[part.key], + end: startTimes[part.key] + part.value, + }); + }); + + console.log(""); + console.log("%c📊 Measures added to Performance timeline", "color: #666;"); + console.log(" Open DevTools → Performance → reload to see waterfall"); + + console.groupEnd(); + }); + + observer.observe({ type: "largest-contentful-paint", buffered: true }); + + console.log("%c📊 LCP Sub-Parts Analysis Active", "font-weight: bold; font-size: 14px;"); + console.log(" Waiting for LCP..."); + + // Synchronous return for agent (buffered entries) + 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 + ? (lcpEntry.url.split("/").pop()?.split("?")[0] || 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/skills/webperf-core-web-vitals/scripts/LCP-Trail.js b/skills/webperf-core-web-vitals/scripts/LCP-Trail.js new file mode 100644 index 000000000..24f4fc8c7 --- /dev/null +++ b/skills/webperf-core-web-vitals/scripts/LCP-Trail.js @@ -0,0 +1,175 @@ +// LCP Trail +// Tracks all LCP candidate elements during page load +// https://webperf-snippets.nucliweb.net + +(() => { + 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 (element.style?.backgroundImage) 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, color: ratingColor } = RATING[rating]; + + console.group( + `%cLCP: ${icon} ${(current.time / 1000).toFixed(2)}s (${rating})`, + `color: ${ratingColor}; font-weight: bold; font-size: 14px;` + ); + + // Current LCP element attribution + console.log(""); + console.log("%cLCP Element:", "font-weight: bold;"); + console.log(` Element: ${current.selector}`, current.element); + + const { type, url } = getElementInfo(current.element, current.entry); + console.log(` Type: ${type}`); + if (url) console.log(` URL: ${url}`); + if (current.element.naturalWidth) { + console.log( + ` Dimensions: ${current.element.naturalWidth}×${current.element.naturalHeight}` + ); + } + if (current.entry.size) { + console.log(` Size: ${current.entry.size.toLocaleString()} px²`); + } + + // Trail legend + console.log(""); + console.log("%cCandidates Trail:", "font-weight: bold;"); + candidates.forEach(({ index, selector, color, name, time, element }) => { + const isCurrent = index === candidates.length; + console.log( + `%c ● ${index}. ${selector}`, + `color: ${color}; font-weight: ${isCurrent ? "bold" : "normal"};`, + `| ${(time / 1000).toFixed(2)}s — ${name}${isCurrent ? " ← LCP" : ""}`, + element + ); + }); + + console.log(""); + console.log( + "%c✓ Each candidate highlighted with a colored dashed outline", + "color: #22c55e;" + ); + console.groupEnd(); + }; + + 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 } = entry; + if (!element || seen.has(element)) continue; + + const { color, name } = PALETTE[candidates.length % PALETTE.length]; + + element.style.outline = `3px dashed ${color}`; + element.style.outlineOffset = "2px"; + + candidates.push({ + index: candidates.length + 1, + element, + selector: getSelector(element), + color, + name, + time: Math.max(0, entry.startTime - activationStart), + entry, + }); + + seen.add(element); + } + + logTrail(); + }); + + observer.observe({ type: "largest-contentful-paint", buffered: true }); + + console.log("%c⏱️ LCP Trail Active", "font-weight: bold; font-size: 14px;"); + console.log(" Highlights all LCP candidate elements with distinct colors."); + + // Synchronous return for agent (buffered entries) + 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, url } = getElementInfo(el, entry); + syncCandidates.push({ + index: syncCandidates.length + 1, + selector, + time, + elementType: type, + ...(url ? { url: url.split("/").pop()?.split("?")[0] || 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/skills/webperf-core-web-vitals/scripts/LCP-Video-Candidate.js b/skills/webperf-core-web-vitals/scripts/LCP-Video-Candidate.js new file mode 100644 index 000000000..3c4684a9b --- /dev/null +++ b/skills/webperf-core-web-vitals/scripts/LCP-Video-Candidate.js @@ -0,0 +1,213 @@ +// LCP Video Candidate +// https://webperf-snippets.nucliweb.net + +(() => { + const lcpEntries = performance.getEntriesByType("largest-contentful-paint"); + + if (lcpEntries.length === 0) { + console.warn( + "⚠️ No LCP entries found. Run this snippet before interacting with the page, or reload and run it immediately.", + ); + 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"; + } + + const RATING = { + good: { icon: "🟢", label: "Good (≤ 2.5 s)" }, + "needs-improvement": { icon: "🟡", label: "Needs Improvement (≤ 4 s)" }, + poor: { icon: "🔴", label: "Poor (> 4 s)" }, + }; + + 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; + } + } + + console.group("%c🎬 LCP Video Candidate", "font-weight: bold; font-size: 14px;"); + console.log(""); + + // --- LCP is NOT a video --- + if (!element || element.tagName !== "VIDEO") { + const tag = element ? `<${element.tagName.toLowerCase()}>` : "(element no longer in DOM)"; + const rating = valueToRating(lcp.startTime); + console.log("%cLCP element is not a