Skip to content
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
097ec5c
feat: add webperf-core-web-vitals SKILL.md with workflows and decisio…
nucliweb Mar 17, 2026
d2d5660
feat: add core CWV measurement scripts (LCP, CLS, INP)
nucliweb Mar 17, 2026
4466cb1
feat: add LCP diagnostic scripts (Sub-Parts, Trail, Image-Entropy, Vi…
nucliweb Mar 17, 2026
7773e96
feat: add webperf-core-web-vitals references (snippets and schema)
nucliweb Mar 17, 2026
893f8d6
Merge branch 'main' into feat/webperf-core-web-vitals-skill
nucliweb Mar 17, 2026
1c70868
refactor: make core CWV scripts agent-first (remove console output)
nucliweb Mar 17, 2026
e28fa02
refactor: make LCP diagnostic scripts agent-first (remove console out…
nucliweb Mar 17, 2026
49f3c7c
feat: add context fork to webperf-core-web-vitals skill
nucliweb Mar 17, 2026
a8823bc
fix: apply skill-creator review feedback to webperf-core-web-vitals
nucliweb Mar 17, 2026
a9789ec
Merge branch 'main' into feat/webperf-core-web-vitals-skill
nucliweb Mar 17, 2026
74b5155
Merge branch 'main' into feat/webperf-core-web-vitals-skill
nucliweb Apr 24, 2026
4f7c0fb
Apply suggestions from code review
nucliweb Apr 24, 2026
9a9fd2f
Rename LCP-Sub-Parts.js to LCP-SubParts.js
nucliweb Apr 24, 2026
9ab0c12
Update skills/webperf-core-web-vitals/scripts/LCP-SubParts.js
nucliweb Apr 24, 2026
860efed
Merge branch 'main' into feat/webperf-core-web-vitals-skill
nucliweb Apr 24, 2026
8d84a7d
feat(webperf-core-web-vitals): address review feedback
nucliweb Apr 28, 2026
24e4171
fix(webperf-core-web-vitals): consistent Subparts naming
nucliweb Apr 28, 2026
83046c8
Merge branch 'main' into feat/webperf-core-web-vitals-skill
nucliweb Apr 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions skills/webperf-core-web-vitals/SKILL.md
Original file line number Diff line number Diff line change
@@ -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-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-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-Sub-Parts.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-Sub-Parts/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-Sub-Parts.js`, `LCP-Trail.js`.
153 changes: 153 additions & 0 deletions skills/webperf-core-web-vitals/references/schema.md
Original file line number Diff line number Diff line change
@@ -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": []
}
```
83 changes: 83 additions & 0 deletions skills/webperf-core-web-vitals/references/snippets.md
Original file line number Diff line number Diff line change
@@ -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 `<video>` and audits the configuration. Chrome considers both the poster image and the first frame of the video as LCP candidates.

**Script:** `scripts/LCP-Video-Candidate.js`

**Checks:**
- Whether the LCP source is the poster image or the first video frame (`lcpSource`)
- Whether a `poster` attribute exists (recommended for explicit control)
- Whether the poster is preloaded with `<link rel="preload" as="image">`
- Whether `fetchpriority="high"` is set on the preload
- Whether the poster uses a modern format (AVIF, WebP)
- Whether cross-origin timing is obscured (`renderTime = 0`)
Loading