skills/core-web-vitals/SKILL.md
Measure and assert Core Web Vitals (LCP, INP, CLS) in Playwright scripts. Use when the user wants to capture performance metrics, validate CWV thresholds, audit pages for Google ranking signals, or add CWV measurement to existing Playwright checks. Pairs with the playwright-dev skill.
npx skillsauth add stevefeldman/agents-skills core-web-vitalsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
IMPORTANT — Path Resolution:
This skill can be installed in different locations. Before executing commands, determine the skill directory based on where you loaded this SKILL.md file, and use that path. Replace $CWV_SKILL_DIR with the actual path (e.g., ~/.copilot/skills/core-web-vitals).
The playwright-dev skill is required to run scripts. Replace $PW_SKILL_DIR with its path (e.g., ~/.copilot/skills/playwright-dev).
Measure LCP, INP, and CLS — the three Core Web Vitals that Google uses as ranking signals — plus supporting metrics FCP and TTFB, directly inside Playwright scripts.
| Metric | What It Measures | Good | Needs Improvement | Poor | |--------|-----------------|------|-------------------|------| | LCP — Largest Contentful Paint | Loading performance: when the largest visible element finishes rendering | ≤ 2,500ms | ≤ 4,000ms | > 4,000ms | | INP — Interaction to Next Paint | Responsiveness: worst-case delay from any user interaction to next paint | ≤ 200ms | ≤ 500ms | > 500ms | | CLS — Cumulative Layout Shift | Visual stability: total unexpected layout shift during page lifetime | ≤ 0.1 | ≤ 0.25 | > 0.25 | | FCP — First Contentful Paint | Time until first text/image pixel is painted | ≤ 1,800ms | ≤ 3,000ms | > 3,000ms | | TTFB — Time to First Byte | Server response time | ≤ 800ms | ≤ 1,800ms | > 1,800ms |
Why it matters: All three Core Web Vitals directly affect Google Search rankings (Page Experience signal). INP replaced FID as a Core Web Vital in March 2024.
This skill uses the official Google web-vitals library (injected via CDN after page load). The library uses buffered: true for PerformanceObserver, so late injection still accurately captures all metrics.
Special note on INP: INP only fires after at least one user interaction (click, keyboard, scroll). Always simulate interactions or interact with the page before collecting INP. Without interactions, INP will not be reported.
Key insight: Metrics like LCP and CLS are only finalized when the page becomes hidden. This skill forces a visibilitychange event to flush pending metrics before reading them.
CRITICAL — Follow these steps:
/tmp — always use /tmp/cwv-check-*.jsrun.js:
cd $PW_SKILL_DIR && node run.js /tmp/cwv-check-*.js
Ensure playwright-dev is set up:
cd $PW_SKILL_DIR && npm run setup
No additional install needed — this skill loads the web-vitals library from CDN at runtime.
Fastest way to measure CWV for any URL. Uses the cwv-collector library.
// /tmp/cwv-check-snapshot.js
const { chromium } = require('playwright');
const { collectCWV, printReport } = require('$CWV_SKILL_DIR/lib/cwv-collector');
const TARGET_URL = 'https://example.com';
(async () => {
const browser = await chromium.launch({ headless: false });
const context = await browser.newContext();
const page = await context.newPage();
try {
// Navigate and wait for page to fully load
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForLoadState('networkidle').catch(() => {}); // best-effort
// Collect all CWV metrics (simulate: true handles INP interactions)
const result = await collectCWV(page, { simulate: true });
// Print formatted report
printReport(result);
} finally {
await browser.close();
}
})();
Run it:
cd $PW_SKILL_DIR && node run.js /tmp/cwv-check-snapshot.js
Use in automated tests to fail when metrics exceed thresholds.
// /tmp/cwv-check-assert.js
const { chromium } = require('playwright');
const { collectCWV, printReport, assertCWV } = require('$CWV_SKILL_DIR/lib/cwv-collector');
const TARGET_URL = 'https://example.com';
// Custom thresholds — tighten or loosen per your requirements
const THRESHOLDS = {
LCP: 2500, // ms — Google "good" threshold
INP: 200, // ms — Google "good" threshold
CLS: 0.1, // score — Google "good" threshold
};
(async () => {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
try {
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
const result = await collectCWV(page, { simulate: true });
printReport(result);
// Throws if any metric exceeds threshold — integrates with CI
assertCWV(result, THRESHOLDS);
console.log('✅ All CWV assertions passed');
} catch (err) {
console.error('❌', err.message);
process.exit(1);
} finally {
await browser.close();
}
})();
Compare CWV across multiple pages in one run (useful for PDP, PLP, homepage, etc.).
// /tmp/cwv-check-multi.js
const { chromium } = require('playwright');
const { measureMultipleUrls, printComparison } = require('$CWV_SKILL_DIR/lib/cwv-collector');
const URLS = [
'https://example.com/',
'https://example.com/products',
'https://example.com/about',
];
(async () => {
const browser = await chromium.launch({ headless: false });
try {
const results = await measureMultipleUrls(browser, URLS, { simulate: true });
printComparison(results);
} finally {
await browser.close();
}
})();
When you need a self-contained script without requiring $CWV_SKILL_DIR:
// /tmp/cwv-check-inline.js
const { chromium } = require('playwright');
const TARGET_URL = 'https://example.com';
const WEB_VITALS_CDN = 'https://unpkg.com/web-vitals@5/dist/web-vitals.attribution.iife.js';
(async () => {
const browser = await chromium.launch({ headless: false });
const page = await (await browser.newContext()).newPage();
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
// Inject web-vitals library (buffered observer — works after page load)
await page.addScriptTag({ url: WEB_VITALS_CDN });
await page.waitForFunction(() => typeof window.webVitals !== 'undefined', { timeout: 15000 });
// Set up collectors — results accumulate in window.__cwvData
await page.evaluate(() => {
window.__cwvData = {};
const report = (m) => {
window.__cwvData[m.name] = { value: m.value, rating: m.rating };
};
window.webVitals.onLCP(report, { reportAllChanges: true });
window.webVitals.onCLS(report, { reportAllChanges: true });
window.webVitals.onINP(report, { reportAllChanges: true });
window.webVitals.onFCP(report);
window.webVitals.onTTFB(report);
});
// Wait for initial paint metrics
await page.waitForTimeout(1500);
// Simulate interactions to trigger INP
const { w, h } = await page.evaluate(() => ({
w: document.documentElement.clientWidth,
h: document.documentElement.clientHeight,
}));
await page.mouse.click(Math.floor(w / 2), Math.floor(h / 2));
await page.keyboard.press('Tab');
await page.waitForTimeout(400);
// Force metrics to flush (simulate page-hide)
await page.evaluate(() => {
Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
document.dispatchEvent(new Event('visibilitychange'));
});
await page.waitForTimeout(400);
// Read and display results
const data = await page.evaluate(() => window.__cwvData);
console.log('\nCore Web Vitals Results:');
console.log(' LCP:', data.LCP ? `${Math.round(data.LCP.value)}ms [${data.LCP.rating}]` : 'not captured');
console.log(' INP:', data.INP ? `${Math.round(data.INP.value)}ms [${data.INP.rating}]` : 'not captured');
console.log(' CLS:', data.CLS ? `${data.CLS.value.toFixed(4)} [${data.CLS.rating}]` : 'not captured');
console.log(' FCP:', data.FCP ? `${Math.round(data.FCP.value)}ms [${data.FCP.rating}]` : 'not captured');
console.log(' TTFB:', data.TTFB ? `${Math.round(data.TTFB.value)}ms [${data.TTFB.rating}]` : 'not captured');
await browser.close();
})();
Augment any existing Playwright check with CWV measurement.
// /tmp/cwv-check-augmented.js
const { chromium } = require('playwright');
const helpers = require('./lib/helpers'); // playwright-dev helpers
const { collectCWV, printReport } = require('$CWV_SKILL_DIR/lib/cwv-collector');
const TARGET_URL = 'https://example.com';
(async () => {
const browser = await chromium.launch({ headless: false });
const context = await helpers.createContext(browser);
const page = await context.newPage();
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
await helpers.dismissOverlays(page);
// --- your existing checks ---
const title = await page.title();
console.log('Page title:', title);
// --- add CWV on top ---
const cwvResult = await collectCWV(page, { simulate: true });
printReport(cwvResult);
await helpers.takeScreenshot(page, 'page-with-cwv');
await browser.close();
})();
When you need INP from a specific interaction (e.g., add-to-cart click, menu open):
// /tmp/cwv-check-inp-targeted.js
const { chromium } = require('playwright');
const { collectCWV, printReport } = require('$CWV_SKILL_DIR/lib/cwv-collector');
const TARGET_URL = 'https://example.com/product/12345';
(async () => {
const browser = await chromium.launch({ headless: false });
const page = await (await browser.newContext()).newPage();
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
// Inject CWV collectors BEFORE the key interaction
// (simulate: false — we'll do the interaction manually)
const { chromium: _, ...cwvLib } = require('$CWV_SKILL_DIR/lib/cwv-collector');
const { collectCWV: collect, injectWebVitals, setupCollectors } =
require('$CWV_SKILL_DIR/lib/cwv-collector');
const { injectWebVitals: inject } = require('$CWV_SKILL_DIR/lib/cwv-collector');
// --- manually set up before the interaction ---
const { chromium: _c, ...lib } = require('playwright');
const cwv = require('$CWV_SKILL_DIR/lib/cwv-collector');
// Step 1: Inject library
await page.addScriptTag({ url: cwv.WEB_VITALS_CDN });
await page.waitForFunction(() => typeof window.webVitals !== 'undefined', { timeout: 15000 });
await page.evaluate(() => {
window.__cwvData = {};
const r = (m) => { window.__cwvData[m.name] = { value: m.value, rating: m.rating }; };
window.webVitals.onLCP(r, { reportAllChanges: true });
window.webVitals.onCLS(r, { reportAllChanges: true });
window.webVitals.onINP(r, { reportAllChanges: true });
window.webVitals.onFCP(r);
window.webVitals.onTTFB(r);
});
await page.waitForTimeout(1000);
// Step 2: Perform YOUR specific interaction
console.log('Clicking Add to Cart...');
await page.getByRole('button', { name: /add to cart/i }).click();
await page.waitForTimeout(500); // wait for next paint
// Step 3: Force flush and collect
await page.evaluate(() => {
Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
document.dispatchEvent(new Event('visibilitychange'));
});
await page.waitForTimeout(400);
const data = await page.evaluate(() => window.__cwvData);
console.log('\nINP after Add-to-Cart:', data.INP ? `${Math.round(data.INP.value)}ms [${data.INP.rating}]` : 'not captured');
console.log('LCP:', data.LCP ? `${Math.round(data.LCP.value)}ms [${data.LCP.rating}]` : 'not captured');
console.log('CLS:', data.CLS ? `${data.CLS.value.toFixed(4)} [${data.CLS.rating}]` : 'not captured');
await browser.close();
})();
CWV values can vary run-to-run (especially on localhost). Run multiple times and average:
// /tmp/cwv-check-averaged.js
const { chromium } = require('playwright');
const { collectCWV, printReport, THRESHOLDS } = require('$CWV_SKILL_DIR/lib/cwv-collector');
const TARGET_URL = 'https://example.com';
const RUNS = 3;
(async () => {
const browser = await chromium.launch({ headless: true });
const allRuns = [];
for (let i = 0; i < RUNS; i++) {
console.log(`\nRun ${i + 1}/${RUNS}...`);
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
const result = await collectCWV(page, { simulate: true });
allRuns.push(result.metrics);
await context.close();
}
// Average each metric
const METRICS = ['LCP', 'INP', 'CLS', 'FCP', 'TTFB'];
console.log('\n══════════════════════════════');
console.log(` Averaged over ${RUNS} runs`);
console.log('══════════════════════════════');
for (const name of METRICS) {
const values = allRuns.map((r) => r[name]?.value).filter((v) => v != null);
if (values.length === 0) { console.log(` ${name}: not captured`); continue; }
const avg = values.reduce((a, b) => a + b, 0) / values.length;
const t = THRESHOLDS[name];
const unit = t?.unit === 'ms' ? `${Math.round(avg)}ms` : avg.toFixed(4);
const rating = avg <= t.good ? '✅ Good' : avg <= t.poor ? '⚠️ Needs Improvement' : '🔴 Poor';
console.log(` ${rating} ${name}: ${unit} (${values.length} samples)`);
}
console.log('══════════════════════════════\n');
await browser.close();
})();
Generate a shareable file report — HTML with visual metric cards and remediation advice, or Markdown for docs/PRs.
// /tmp/cwv-check-report.js
const { chromium } = require('playwright');
const { collectCWV, printReport, saveReport } = require('$CWV_SKILL_DIR/lib/cwv-collector');
const TARGET_URL = 'https://example.com';
(async () => {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
const result = await collectCWV(page, { simulate: true });
// Print to console
printReport(result);
// Save HTML report (visual, shareable)
saveReport(result, '/tmp/cwv-report.html', { title: 'My Site — CWV Report' });
// Save Markdown report (for PRs, Confluence, docs)
saveReport(result, '/tmp/cwv-report.md', { title: 'My Site — CWV Report' });
await browser.close();
})();
// /tmp/cwv-check-report-multi.js
const { chromium } = require('playwright');
const { measureMultipleUrls, saveReport } = require('$CWV_SKILL_DIR/lib/cwv-collector');
const URLS = [
'https://example.com/',
'https://example.com/products',
'https://example.com/checkout',
];
(async () => {
const browser = await chromium.launch({ headless: true });
const results = await measureMultipleUrls(browser, URLS, { simulate: true });
// Single report covering all pages with summary table + per-page detail + recommendations
saveReport(results, '/tmp/cwv-full-report.html', { title: 'Site-wide CWV Audit' });
saveReport(results, '/tmp/cwv-full-report.md');
await browser.close();
})();
What the HTML report includes:
INP (Interaction to Next Paint) is the trickiest CWV to capture in automated tests because it requires real user interaction.
headless: false) for more realistic timingslowMo to simulate realistic typing speed// Realistic INP measurement during a user flow
const browser = await chromium.launch({ headless: false, slowMo: 100 });
const page = await (await browser.newContext()).newPage();
// Inject CWV before the flow starts
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
await page.addScriptTag({ url: 'https://unpkg.com/web-vitals@5/dist/web-vitals.attribution.iife.js' });
await page.waitForFunction(() => typeof window.webVitals !== 'undefined');
await page.evaluate(() => {
window.__cwvData = {};
window.webVitals.onINP((m) => { window.__cwvData.INP = m; }, { reportAllChanges: true });
window.webVitals.onLCP((m) => { window.__cwvData.LCP = m; }, { reportAllChanges: true });
window.webVitals.onCLS((m) => { window.__cwvData.CLS = m; }, { reportAllChanges: true });
});
// Perform real user flow (this generates genuine INP events)
await page.getByRole('searchbox').click();
await page.keyboard.type('red shoes');
await page.keyboard.press('Enter');
await page.waitForLoadState('domcontentloaded');
await page.getByRole('link', { name: /product/i }).first().click();
await page.waitForLoadState('domcontentloaded');
await page.getByRole('button', { name: /add to cart/i }).click();
await page.waitForTimeout(500);
// Flush and read
await page.evaluate(() => {
Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
document.dispatchEvent(new Event('visibilitychange'));
});
await page.waitForTimeout(400);
const metrics = await page.evaluate(() => window.__cwvData);
console.log('INP:', metrics.INP ? `${Math.round(metrics.INP.value)}ms [${metrics.INP.rating}]` : 'not captured');
| Mode | When to use |
|------|------------|
| headless: false (default) | Local debugging, realistic INP, visual verification |
| headless: true | CI/CD, batch runs, automated regression checks |
Note: Headless mode can produce artificially lower/higher LCP and INP values because Chromium skips some paint work when no display is attached. For the most accurate CWV numbers, use
headless: falseor use--disable-gpuflags.
Run headless via playwright-dev:
cd $PW_SKILL_DIR && HEADLESS=true node run.js /tmp/cwv-check-snapshot.js
const { THRESHOLDS } = require('$CWV_SKILL_DIR/lib/cwv-collector');
// THRESHOLDS object:
// {
// LCP: { good: 2500, poor: 4000, unit: 'ms', label: 'Largest Contentful Paint' },
// INP: { good: 200, poor: 500, unit: 'ms', label: 'Interaction to Next Paint' },
// CLS: { good: 0.1, poor: 0.25, unit: '', label: 'Cumulative Layout Shift' },
// FCP: { good: 1800, poor: 3000, unit: 'ms', label: 'First Contentful Paint' },
// TTFB: { good: 800, poor: 1800, unit: 'ms', label: 'Time to First Byte' },
// }
const {
collectCWV, // Main function: collect all CWV from a loaded page
printReport, // Print formatted console report
assertCWV, // Throw if metrics exceed thresholds (for test assertions)
measureMultipleUrls, // Measure CWV across multiple URLs
printComparison, // Print multi-URL comparison to console
generateMarkdownReport, // Generate Markdown report string (single or multi-URL)
generateHtmlReport, // Generate HTML report string (visual, self-contained)
saveReport, // Save .html or .md report to disk (auto-detected by extension)
THRESHOLDS, // Google threshold constants
WEB_VITALS_CDN, // CDN URL for web-vitals library
getRating, // (name, value) → 'good' | 'needs-improvement' | 'poor'
formatValue, // (name, value) → formatted string with unit
} = require('$CWV_SKILL_DIR/lib/cwv-collector');
collectCWV(page, options)const result = await collectCWV(page, {
simulate: true, // Simulate clicks/keys to trigger INP (default: true)
waitAfterLoad: 1500, // ms to wait after page load before collecting (default: 1500)
includeAttribution: true, // Include attribution debug data (default: true)
});
// Returns:
// {
// url: string,
// metrics: {
// LCP: { name, value, formatted, rating, ratingIcon, threshold, attribution },
// INP: { ... },
// CLS: { ... },
// FCP: { ... },
// TTFB: { ... },
// },
// collectedAt: ISO string,
// collectionDurationMs: number,
// }
assertCWV(result, thresholds)// Throws if any metric exceeds threshold
assertCWV(result, {
LCP: 2500, // ms
INP: 200, // ms
CLS: 0.1, // score
});
User: "Check the CWV on our homepage"
Claude: I'll navigate to your homepage and measure Core Web Vitals.
[Detects dev server at http://localhost:3001]
[Writes /tmp/cwv-check-snapshot.js]
[Runs: cd $PW_SKILL_DIR && node run.js /tmp/cwv-check-snapshot.js]
[Reports LCP: 1823ms ✅ Good, INP: 156ms ✅ Good, CLS: 0.0821 ✅ Good]
User: "Run CWV checks on staging and fail if any metric is poor"
Claude: I'll write a CWV check with assertions at Google's 'Good' thresholds.
[Writes /tmp/cwv-check-assert.js with assertCWV({ LCP: 2500, INP: 200, CLS: 0.1 })]
[Runs: cd $PW_SKILL_DIR && HEADLESS=true node run.js /tmp/cwv-check-assert.js]
[Reports or throws on failures]
User: "Compare CWV on the homepage, PDP, and search results page"
Claude: I'll run a multi-URL comparison.
[Writes /tmp/cwv-check-multi.js with all 3 URLs]
[Runs the comparison and prints a side-by-side table]
User: "What is the INP when a user clicks Add to Cart?"
Claude: I'll set up CWV collectors before the interaction and measure INP specifically
for the Add to Cart click.
[Writes /tmp/cwv-check-inp-targeted.js]
[Navigates, sets up observers, clicks the button, flushes, reports INP]
web-vitals library fails to load:
The CDN (unpkg.com) requires outbound network access from the browser. If blocked:
// Fallback: download and serve locally, or use page.addScriptTag({ path: '/local/web-vitals.iife.js' })
INP is not captured:
simulate: true or perform manual interactions before collectingLCP is 0 or missing:
networkidle or a key selector)Metrics differ between runs:
CLS is unexpectedly high:
attribution.largestShiftTarget from the report to identify the culprit elementCannot measure CWV on password-protected or cookie-gated pages: Combine with playwright-dev's auth patterns:
const context = await browser.newContext({ storageState: '/tmp/auth-state.json' });
page.goto() first, then collectCWV()domcontentloaded, not networkidle — networkidle is fragile with analytics/beaconsattribution tells you exactly which element caused itdevelopment
Use when reviewing Dependabot alerts, npm audit findings, govulncheck output, or CVE reports on a JavaScript/Node.js or Go project — especially when triaging multiple alerts across direct and transitive dependencies to assess real-world risk and produce a remediation plan.
development
Use when a code review finding needs proof — write a focused test in JavaScript or Go that either confirms the issue is real or exposes it as over-engineering hyperbole. Trigger after code-review or code-review-skill findings are presented and evidence is requested.
development
Produce data-driven software delivery estimates by analyzing historical JIRA tickets, git activity, and engineer track records, then matching the new work against the most similar past tickets. Use this skill whenever the user asks "how long will this take", wants to estimate a piece of work, scope an epic, plan a sprint, or estimate delivery for JIRA stories or a Figma design. Also use whenever the user wants developer-to-work assignment recommendations based on history, wants to optimize an estimate by adding or reallocating engineers, or asks "what's the fastest way to ship this" or "who should work on this". Especially trigger when the user provides JIRA ticket IDs, JIRA story links, or Figma designs together with any indication of a team that will execute the work.
tools
Use when auditing an existing test suite for quality and coverage gaps, evaluating Playwright migration readiness, scoring automation against a world-class e-commerce standard, or guiding the creation of new tests. Applicable to Selenium, WebdriverIO, and Playwright suites.