From 5c4eabc573b682868513a35525d6051b61cbfe7a Mon Sep 17 00:00:00 2001 From: Joan Leon Date: Tue, 17 Mar 2026 20:49:13 +0100 Subject: [PATCH 1/9] fix: correct bugs and inconsistencies in Core Web Vitals snippets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugs fixed: - LCP-Video-Candidate: apply activationStart correction in both non-video and video return paths (bfcache navigations were returning wrong values) - LCP + LCP-Trail: use getComputedStyle for background-image detection instead of el.style, which only reads inline styles - CLS: add message and getDataFn to synchronous return so agents know they can call getCLS() after interactions for an updated value - INP: remove misleading rating: "good" from getINP() error case when no interactions recorded; add getDataFn: "getINP" so agents can retry Inconsistencies resolved: - LCP-Sub-Parts: align internal SUB_PARTS keys with schema output keys (loadDelay → resourceLoadDelay, loadTime → resourceLoadTime, etc.) - LCP-Sub-Parts + LCP-Trail: preserve hostname in URL for cross-origin resources (cdn.example.com/…/hero.jpg instead of just hero.jpg) - LCP-Image-Entropy: add LCP element highlighting, consistent with all other LCP scripts SKILL.md / schema improvements: - WORKFLOWS: add Error Recovery section to decision tree - WORKFLOWS: mark cross-skill scripts not yet available as (pending) - WORKFLOWS: clarify cross-skill triggers require reporting to parent agent (context: fork — subagent cannot invoke other skills directly) - WORKFLOWS: document getINPDetails() usage in INP debugging workflow - SCHEMA: update CLS schema to include message and getDataFn fields - SCHEMA: document getINPDetails() return shape and when to use it --- .claude/skills/SCHEMA.md | 15 ++++++- snippets/CoreWebVitals/CLS.js | 2 + snippets/CoreWebVitals/INP.js | 10 +++-- snippets/CoreWebVitals/LCP-Image-Entropy.js | 4 ++ snippets/CoreWebVitals/LCP-Sub-Parts.js | 42 ++++++++++++------- snippets/CoreWebVitals/LCP-Trail.js | 13 +++++- snippets/CoreWebVitals/LCP-Video-Candidate.js | 18 +++++--- snippets/CoreWebVitals/LCP.js | 4 +- snippets/CoreWebVitals/WORKFLOWS.md | 25 +++++++---- 9 files changed, 98 insertions(+), 35 deletions(-) diff --git a/.claude/skills/SCHEMA.md b/.claude/skills/SCHEMA.md index 7328e5e..f7e8b16 100644 --- a/.claude/skills/SCHEMA.md +++ b/.claude/skills/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/snippets/CoreWebVitals/CLS.js b/snippets/CoreWebVitals/CLS.js index 1c45ade..5f5dac6 100644 --- a/snippets/CoreWebVitals/CLS.js +++ b/snippets/CoreWebVitals/CLS.js @@ -75,5 +75,7 @@ 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/snippets/CoreWebVitals/INP.js b/snippets/CoreWebVitals/INP.js index 9d412ee..32e899f 100644 --- a/snippets/CoreWebVitals/INP.js +++ b/snippets/CoreWebVitals/INP.js @@ -290,9 +290,13 @@ }; } 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: "error", + error: "No interactions recorded yet. Interact with the page and call getINP() again.", + getDataFn: "getINP", + details, + }; } return { script: "INP", diff --git a/snippets/CoreWebVitals/LCP-Image-Entropy.js b/snippets/CoreWebVitals/LCP-Image-Entropy.js index 59c3e59..51e54b9 100644 --- a/snippets/CoreWebVitals/LCP-Image-Entropy.js +++ b/snippets/CoreWebVitals/LCP-Image-Entropy.js @@ -171,6 +171,10 @@ .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+` }); diff --git a/snippets/CoreWebVitals/LCP-Sub-Parts.js b/snippets/CoreWebVitals/LCP-Sub-Parts.js index 47bab37..6311c44 100644 --- a/snippets/CoreWebVitals/LCP-Sub-Parts.js +++ b/snippets/CoreWebVitals/LCP-Sub-Parts.js @@ -16,9 +16,9 @@ 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 }, + { 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 = () => { @@ -64,9 +64,9 @@ const subPartValues = { ttfb: ttfb, - loadDelay: lcpRequestStart - ttfb, - loadTime: lcpResponseEnd - lcpRequestStart, - renderDelay: lcpRenderTime - lcpResponseEnd, + resourceLoadDelay: lcpRequestStart - ttfb, + resourceLoadTime: lcpResponseEnd - lcpRequestStart, + elementRenderDelay: lcpRenderTime - lcpResponseEnd, }; // LCP Rating @@ -92,7 +92,14 @@ console.log("%cLCP Element:", "font-weight: bold;"); console.log(` ${selector}`, el); if (lcpEntry.url) { - const shortUrl = lcpEntry.url.split("/").pop()?.split("?")[0] || lcpEntry.url; + const shortUrl = (() => { + 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; } + })(); console.log(` URL: ${shortUrl}`); } @@ -155,15 +162,15 @@ 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") { + } else if (slowest.key === "resourceLoadDelay") { console.log(" → Preload the LCP image: "); console.log(" → Remove render-blocking resources"); console.log(" → Inline critical CSS"); - } else if (slowest.key === "loadTime") { + } else if (slowest.key === "resourceLoadTime") { 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") { + } else if (slowest.key === "elementRenderDelay") { console.log(" → Reduce render-blocking JavaScript"); console.log(" → Avoid client-side rendering for LCP element"); console.log(" → Use fetchpriority=\"high\" on LCP image"); @@ -175,9 +182,9 @@ phases.forEach((part) => { const startTimes = { ttfb: 0, - loadDelay: ttfb, - loadTime: lcpRequestStart, - renderDelay: lcpResponseEnd, + resourceLoadDelay: ttfb, + resourceLoadTime: lcpRequestStart, + elementRenderDelay: lcpResponseEnd, }; performance.measure(part.name, { start: startTimes[part.key], @@ -242,7 +249,14 @@ } } const shortUrlSync = lcpEntry.url - ? (lcpEntry.url.split("/").pop()?.split("?")[0] || 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", diff --git a/snippets/CoreWebVitals/LCP-Trail.js b/snippets/CoreWebVitals/LCP-Trail.js index 24f4fc8..030ee64 100644 --- a/snippets/CoreWebVitals/LCP-Trail.js +++ b/snippets/CoreWebVitals/LCP-Trail.js @@ -39,7 +39,7 @@ 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 }; + if (window.getComputedStyle(element).backgroundImage !== "none") return { type: "Background image", url: entry.url }; return { type: tag === "h1" || tag === "p" ? "Text block" : tag }; }; @@ -149,7 +149,16 @@ selector, time, elementType: type, - ...(url ? { url: url.split("/").pop()?.split("?")[0] || url } : {}), + ...(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) { diff --git a/snippets/CoreWebVitals/LCP-Video-Candidate.js b/snippets/CoreWebVitals/LCP-Video-Candidate.js index 3c4684a..ac6a86c 100644 --- a/snippets/CoreWebVitals/LCP-Video-Candidate.js +++ b/snippets/CoreWebVitals/LCP-Video-Candidate.js @@ -43,13 +43,19 @@ console.group("%c🎬 LCP Video Candidate", "font-weight: bold; font-size: 14px;"); console.log(""); + const activationStart = (() => { + const nav = performance.getEntriesByType("navigation")[0]; + return nav?.activationStart || 0; + })(); + const lcpTime = Math.round(Math.max(0, lcp.startTime - activationStart)); + // --- 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); + const rating = valueToRating(lcpTime); console.log("%cLCP element is not a