diff --git a/skills/webperf-core-web-vitals/SKILL.md b/skills/webperf-core-web-vitals/SKILL.md new file mode 100644 index 000000000..2b5744aec --- /dev/null +++ b/skills/webperf-core-web-vitals/SKILL.md @@ -0,0 +1,152 @@ +--- +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. +context: fork +--- + +# 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-Subparts.js` — LCP Subparts +- `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-Subparts, LCP-Trail, LCP-Video-Candidate) + +Run via `evaluate_script` and return structured JSON immediately from buffered performance data. The page must have already loaded. + +### Measuring (INP) + +INP requires real user interactions to measure. The workflow is: + +1. Run `INP.js` via `evaluate_script` → returns `{ status: "measuring", getDataFn: "getINP" }` +2. **Tell the user:** "INP measuring 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. +5. If `getINP()` returns `status: "error"` → the user has not interacted yet. Remind them and wait. +6. For a full breakdown of all interactions, call `evaluate_script("getINPDetails()")` — returns all recorded interactions sorted by duration. + +> 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-Subparts.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-Subparts.js** - Break down into TTFB, resource load, render delay +3. **LCP-Trail.js** - Identify all LCP candidates and changes +4. **LCP-Video-Candidate.js** - Detect if LCP is a video (poster or first frame) + +### 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. Call `getCLS()` after page interactions to capture post-load shifts + +### INP Debugging + +When interactions feel slow or the user asks "debug INP" or "slow interactions": + +1. **INP.js** - Start measuring. Tell the user to interact with the page and confirm when done. +2. Call `getINP()` to collect results once the user confirms. +3. Call `getINPDetails()` to see all interactions ranked by duration. + +### 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, detect `lcpSource` (poster or first frame) +2. **LCP-Subparts.js** - Analyze video loading phases + +### Image as LCP Investigation + +When LCP is an image (most common case): + +1. **LCP.js** - Measure LCP timing +2. **LCP-Subparts.js** - Break down timing phases +3. **LCP-Trail.js** - Track all LCP candidates to confirm final element + +## Decision Tree + +Use this decision tree to automatically run follow-up snippets based on results: + +### After LCP.js + +- **If LCP > 2.5s** → Run **LCP-Subparts.js** to diagnose which phase is slow +- **If LCP > 4.0s (poor)** → Run full LCP deep dive workflow +- **If LCP candidate is a video** → Run **LCP-Video-Candidate.js** +- **Always run** → **LCP-Trail.js** to understand candidate evolution + +### After LCP-Subparts.js + +- **If TTFB phase > 600ms** → Investigate server response time and redirects +- **If Resource Load Time > 1500ms** → Check preload hints and fetch priority for the LCP resource +- **If Render Delay > 200ms** → Investigate render-blocking resources and main thread work + +### After LCP-Trail.js + +- **If many LCP candidate changes (>3)** → Visual instability; run **CLS.js** to check layout shifts +- **If final LCP candidate appears late** → Investigate resource preloading for the LCP element +- **If early candidate was replaced** → Likely a CLS issue; run **CLS.js** + +### After LCP-Video-Candidate.js + +- **If `lcpSource === "poster"`** → Check poster preload and `fetchpriority="high"`; run **LCP-Subparts.js** +- **If `lcpSource === "first-frame"`** → Ensure `autoplay` + `muted` + `playsinline` are set; adding a poster gives explicit control +- **If `lcpSource === "unknown"`** → No poster URL or video URL detectable; run **LCP-Subparts.js** for timing breakdown + +### After CLS.js + +- **If CLS > 0.1** → Check `sources` in the result for the shifting elements; inspect for missing `width`/`height` attributes, late-loading fonts, or dynamic content insertion +- **If CLS > 0.25 (poor)** → Call `getCLS()` after interactions to confirm the score accumulates over time +- **If CLS = 0** → Confirm with multiple page loads (might be timing-dependent) + +### After INP.js + +- **If INP > 200ms** → Call `getINPDetails()` to list all interactions ranked by duration and identify the slowest one +- **If INP > 500ms (poor)** → Check `phases` in the worst interaction: high `inputDelay` suggests main thread blocking; high `processingDuration` suggests heavy event handler work +- **If specific interaction type is slow (e.g., keyboard)** → Focus `getINPDetails()` on that interaction type + +## Error Recovery + +When a script returns `status: "error"`: + +- **LCP/CLS/LCP-Subparts/LCP-Trail** → The page may not have finished loading. Ask the user to wait for full load or reload, then re-run the script. +- **INP** (`getINP()` returns error) → No interactions have been recorded yet. Remind the user to interact with the page, then call `getINP()` again. +- **LCP-Video-Candidate** → No LCP entries found; see LCP error recovery above. + +## Visual Highlighting + +By default, scripts highlight the LCP element(s) with colored dashed outlines — useful when the user is watching the browser while the agent runs. To disable: + +```js +window.__cwvHighlight = false; +// then run any LCP script +``` + +Scripts that support this flag: `LCP.js`, `LCP-Subparts.js`, `LCP-Trail.js`. diff --git a/skills/webperf-core-web-vitals/references/schema.md b/skills/webperf-core-web-vitals/references/schema.md new file mode 100644 index 000000000..513cb0055 --- /dev/null +++ b/skills/webperf-core-web-vitals/references/schema.md @@ -0,0 +1,153 @@ +# Script Return Value Schema + +All scripts return a structured JSON object as the return value. This allows agents using `evaluate_script` to read structured data directly from the return value, rather than parsing human-readable console output. + +## Base Shape + +```typescript +{ + script: string; // Script name, e.g. "LCP", "CLS", "INP" + status: "ok" // Script ran, has data + | "monitoring" // Observer active, data accumulates over time + | "error" // Failed or no data available + | "unsupported"; // Browser API not supported + + // Metric scripts (LCP, CLS, INP) + metric?: string; + value?: number; // Always a number, never a formatted string + unit?: "ms" | "score" | "count" | "bytes" | "bpp" | "fps"; + rating?: "good" | "needs-improvement" | "poor"; + thresholds?: { good: number; needsImprovement: number }; + + // Audit scripts + count?: number; + items?: object[]; + details?: object; + issues?: Array<{ severity: "error" | "warning" | "info"; message: string }>; + + // Measurement scripts + message?: string; + getDataFn?: string; // window function name: evaluate_script(`${getDataFn}()`) + + // Error info + error?: string; +} +``` + +## Agent Workflow + +``` +// Synchronous scripts (LCP, CLS, LCP-Subparts, LCP-Trail, LCP-Video-Candidate) +result = evaluate_script(scriptCode) +// → { status: "ok", value: 1240, rating: "good", ... } + +// Measurement scripts (INP) +result = evaluate_script(INP_js) +// → { status: "measuring", getDataFn: "getINP" } +// (user interacts with the page) +data = evaluate_script("getINP()") +// → { status: "ok", value: 350, rating: "needs-improvement", ... } + +// CLS (hybrid: returns current value immediately, keeps measuring) +result = evaluate_script(CLS_js) +// → { status: "ok", value: 0.05, rating: "good", message: "Call getCLS() for updated value" } +// (after more page interactions) +data = evaluate_script("getCLS()") +// → { status: "ok", value: 0.08, rating: "good", ... } +``` + +## Making Decisions from Return Values + +- `rating === "good"` → metric meets recommended thresholds +- `rating === "needs-improvement"` → investigate, check `details` and `issues` +- `rating === "poor"` → high priority fix, check `issues` for specific problems +- `status === "error"` → page may not have loaded yet, or metric has no data +- `status === "measuring"` → call `evaluate_script(result.getDataFn + "()")` after interaction + +## Script-Specific Schemas + +### 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": "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 }, + "message": "CLS measurement active. Call getCLS() for updated value after page interactions." +} +``` + +### INP (initial — measuring) +``` +`getINP()` returns (after interactions): +```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 } } +} +``` +`getINP()` returns (no interactions yet): +```json +{ "script": "INP", "status": "error", "error": "No interactions recorded yet. Interact with the page and call getINP() again.", "getDataFn": "getINP" } +``` +`getINPDetails()` returns all recorded interactions sorted by duration (useful for INP deep-dive): +```json +[ + { "formattedName": "click → button.submit", "duration": 350, "startTime": 4210, "phases": { "inputDelay": 120, "processingTime": 180, "presentationDelay": 50 } }, + { "formattedName": "keydown → input#search", "duration": 180, "startTime": 8540, "phases": { "inputDelay": 20, "processingTime": 140, "presentationDelay": 20 } } +] +``` + +### LCP-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-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, "lcpSource": "poster", + "posterUrl": "https://example.com/hero.avif", "posterFormat": "avif", + "posterPreloaded": true, "fetchpriorityOnPreload": "high", "isCrossOrigin": false, + "videoAttributes": { "autoplay": true, "muted": true, "playsinline": true, "preload": "auto" } + }, + "issues": [] +} +``` diff --git a/skills/webperf-core-web-vitals/references/snippets.md b/skills/webperf-core-web-vitals/references/snippets.md new file mode 100644 index 000000000..9e94c35e8 --- /dev/null +++ b/skills/webperf-core-web-vitals/references/snippets.md @@ -0,0 +1,83 @@ +--- +## Largest Contentful Paint (LCP) + +Quick check for Largest Contentful Paint, a Core Web Vital that measures loading performance. LCP marks when the largest content element becomes visible in the viewport. + +**Script:** `scripts/LCP.js` + +**Thresholds:** + +| Rating | Time | Meaning | +|--------|------|---------| +| 🟢 Good | ≤ 2.5s | Fast, content appears quickly | +| 🟡 Needs Improvement | ≤ 4s | Moderate delay | +| 🔴 Poor | > 4s | Slow, users may abandon | +--- +## Cumulative Layout Shift (CLS) + +Quick check for Cumulative Layout Shift, a Core Web Vital that measures visual stability. CLS tracks how much the page layout shifts unexpectedly during its lifetime, providing a single score that represents the worst batch impact of all unexpected layout shifts. + +**Script:** `scripts/CLS.js` + +**Usage:** Run `CLS.js` once on page load. It returns the current score immediately and keeps tracking. Call `getCLS()` later to get an updated value after further page interactions. + +**Thresholds:** + +| Rating | Score | Meaning | +|--------|-------|---------| +| 🟢 Good | ≤ 0.1 | Stable, minimal shifting | +| 🟡 Needs Improvement | ≤ 0.25 | Noticeable shifting | +| 🔴 Poor | > 0.25 | Significant layout instability | +--- +## Interaction to Next Paint (INP) + +Tracks Interaction to Next Paint, a Core Web Vital that measures responsiveness. INP evaluates how quickly a page responds to user interactions throughout the entire page visit. + +**Script:** `scripts/INP.js` + +**Usage:** Run `INP.js` once to start measuring. It returns `status: "measuring"` immediately. After the user interacts with the page, call `getINP()` to retrieve the current INP value. + +**Thresholds:** + +| Rating | Time | Meaning | +|--------|------|---------| +| 🟢 Good | ≤ 200ms | Responsive, feels responsive | +| 🟡 Needs Improvement | ≤ 500ms | Noticeable delay | +| 🔴 Poor | > 500ms | Slow, frustrating experience | +--- +## LCP Subparts + +Breaks down Largest Contentful Paint into its four phases to identify optimization opportunities. Understanding which phase is slowest helps focus optimization efforts where they'll have the most impact. + +**Script:** `scripts/LCP-Subparts.js` + +**Subparts:** + +| Phase | Target | Description | +|-------|--------|-------------| +| Time to First Byte (TTFB) | ≤ 800ms | Navigation start → first HTML byte | +| Resource Load Delay | < 10% of LCP | TTFB → browser starts loading LCP resource | +| Resource Load Duration | ~40% of LCP | Time spent waiting for the downloading of the LCP resource | +| Element Render Delay | < 10% of LCP | Resource downloaded → LCP element rendered | +--- +## LCP Trail + +Tracks every LCP candidate element during page load and highlights each one with a distinct colored dashed outline — so you can see the full trail from first candidate to final LCP. + +**Script:** `scripts/LCP-Trail.js` + +**Returns:** Array of all LCP candidates in order, with selector, time, element type, and URL (if applicable). The last entry is the final LCP element. +--- +## LCP Video Candidate + +Detects whether the LCP element is a `