[TERMINAL · SKILLS]
> mounting /skills...
> indexing skill manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
Use Cases/Build Visual Regression Testing for UI Components

Build Visual Regression Testing for UI Components

Catch unintended CSS changes before they ship — screenshot every component variant, diff against baselines, and block PRs that break visual consistency across 200+ components.

Development#typescript#tsconfig#type-checking#type-safety#javascript-migration
Works with:claude-codeopenai-codexgemini-clicursor

Skills stack · 6 skills

Avg quality 98/100·All SAFE
>

typescript

v1.0.0

Sets up and repairs TypeScript projects: picks tsconfig.json settings for a Node.js service, an npm library or a bundled web app, runs type-checking in CI, and replaces `any` and unsafe casts with types the compiler can verify. Use when someone asks to "set up TypeScript", "fix this tsconfig", "fix a TypeScript error", "upgrade to TypeScript 6 or 7", "migrate JavaScript to TypeScript", "publish types for a package", "run .ts files in Node" or "make this type-safe". Covers the changed defaults in TypeScript 6.0 and 7.0, module and moduleResolution, strict mode, project references, declaration output, narrowing, satisfies and common compiler errors.

93/100 quality
7.30× impact
SAFE· stale score
View skill
>

playwright-testing

v1.1.0

Write and maintain end-to-end tests with Playwright. Use when someone asks to "add e2e tests", "test my web app", "set up Playwright", "write browser tests", "test login flow", "visual regression testing", "test across browsers", or "automate UI testing". Covers test setup, page objects, authentication, API mocking, visual comparisons, and CI integration.

100/100 quality
3.85× impact
SAFE· stale score
View skill
>

vitest

v1.1.0

Vitest is a test runner for JavaScript and TypeScript built on Vite, with a Jest-compatible API, built-in mocking, snapshots and code coverage. Use when a user asks to set up or run unit tests in a Vite or Node project, write tests with describe/it/expect, mock modules with vi.mock, add coverage thresholds, configure vitest.config.ts or test projects, migrate from Jest, or upgrade to Vitest 5.

100/100 quality
1.50× impact
SAFE· stale score
View skill
>

github-actions

v1.1.0

GitHub Actions runs CI/CD pipelines defined as YAML workflows inside a GitHub repository. Use when someone asks to "set up CI", "add a GitHub Actions workflow", "run tests on every pull request", "cache dependencies", "build a matrix", "deploy from Actions", "push a Docker image to ghcr.io" or "reuse a workflow", or needs triggers, secrets, environments, artifacts and permissions explained.

100/100 quality
2.66× impact
SAFE· stale score
View skill
>

docker-helper

v1.1.0

Build, debug, and optimize Docker configurations. Use when a user asks to create a Dockerfile, fix Docker build errors, optimize image size, write docker-compose files, debug container issues, set up multi-stage builds, or troubleshoot networking between containers. Covers Dockerfile best practices and compose orchestration.

100/100 quality
1.44× impact
SAFE· stale score
View skill
>

storybook

v1.1.0

Storybook is a workshop for building, documenting and testing UI components in isolation. Use this skill when writing component stories (CSF), setting up Storybook 10, adding interaction, accessibility or visual tests, generating autodocs, or moving off the old test runner. Trigger words: storybook, stories, csf, play function, storybook test, vitest addon, chromatic, a11y addon.

93/100 quality
2.86× impact
SAFE· stale score
View skill
$

The Problem

A design system with 200+ React components powers 4 product teams' UIs. CSS changes cause cascading visual bugs: updating a button's padding breaks card layouts, changing a color token makes text unreadable on dark backgrounds, and a z-index change hides dropdown menus. These bugs reach production 2-3 times per month because unit tests pass — the logic is correct, but the UI looks broken. Each visual bug takes 2-3 hours to find, fix, and deploy.

Step 1: Storybook Screenshot Capture

typescript
// src/visual-tests/capture.ts
import { chromium, type Browser, type Page } from 'playwright';
import { readdir } from 'fs/promises';
import { join } from 'path';

interface CaptureConfig {
  storybookUrl: string;
  outputDir: string;
  viewports: Array<{ name: string; width: number; height: number }>;
  themes: string[];
}

const defaultConfig: CaptureConfig = {
  storybookUrl: 'http://localhost:6006',
  outputDir: '.visual-tests/current',
  viewports: [
    { name: 'mobile', width: 375, height: 812 },
    { name: 'tablet', width: 768, height: 1024 },
    { name: 'desktop', width: 1440, height: 900 },
  ],
  themes: ['light', 'dark'],
};

export async function captureAllStories(config = defaultConfig): Promise<string[]> {
  const browser = await chromium.launch();
  const screenshots: string[] = [];

  try {
    // Fetch story list from Storybook API
    const page = await browser.newPage();
    await page.goto(`${config.storybookUrl}/index.json`);
    const storyIndex = await page.evaluate(() => document.body.textContent);
    const stories = JSON.parse(storyIndex!);

    for (const [id, story] of Object.entries(stories.entries as Record<string, any>)) {
      if (story.type !== 'story') continue;

      for (const viewport of config.viewports) {
        for (const theme of config.themes) {
          const filename = `${id}--${viewport.name}--${theme}.png`;
          const filepath = join(config.outputDir, filename);

          await page.setViewportSize({ width: viewport.width, height: viewport.height });

          // Navigate to story in iframe mode with theme
          const url = `${config.storybookUrl}/iframe.html?id=${id}&globals=theme:${theme}`;
          await page.goto(url, { waitUntil: 'networkidle' });

          // Wait for animations to settle
          await page.waitForTimeout(500);

          await page.screenshot({ path: filepath, fullPage: true });
          screenshots.push(filepath);
        }
      }
    }
  } finally {
    await browser.close();
  }

  return screenshots;
}

Step 2: Pixel-Level Diff Engine

typescript
// src/visual-tests/diff.ts
import { readFile, writeFile, mkdir } from 'fs/promises';
import { join, basename } from 'path';
import { PNG } from 'pngjs';
import pixelmatch from 'pixelmatch';

interface DiffResult {
  filename: string;
  status: 'match' | 'changed' | 'new' | 'removed';
  diffPercent: number;
  diffPixels: number;
  diffImagePath?: string;
}

export async function compareScreenshots(
  baselineDir: string,
  currentDir: string,
  diffDir: string,
  threshold: number = 0.1 // percent difference to flag
): Promise<DiffResult[]> {
  await mkdir(diffDir, { recursive: true });
  const results: DiffResult[] = [];

  const currentFiles = await readdir(currentDir);

  for (const filename of currentFiles) {
    if (!filename.endsWith('.png')) continue;

    const currentPath = join(currentDir, filename);
    const baselinePath = join(baselineDir, filename);

    try {
      const baselineData = await readFile(baselinePath);
      const currentData = await readFile(currentPath);

      const baseline = PNG.sync.read(baselineData);
      const current = PNG.sync.read(currentData);

      // Handle size changes
      if (baseline.width !== current.width || baseline.height !== current.height) {
        results.push({
          filename, status: 'changed', diffPercent: 100, diffPixels: -1,
          diffImagePath: currentPath,
        });
        continue;
      }

      const diff = new PNG({ width: baseline.width, height: baseline.height });
      const diffPixels = pixelmatch(
        baseline.data, current.data, diff.data,
        baseline.width, baseline.height,
        { threshold: 0.1 } // per-pixel sensitivity
      );

      const totalPixels = baseline.width * baseline.height;
      const diffPercent = (diffPixels / totalPixels) * 100;

      if (diffPercent > threshold) {
        const diffPath = join(diffDir, `diff-${filename}`);
        await writeFile(diffPath, PNG.sync.write(diff));

        results.push({
          filename, status: 'changed', diffPercent, diffPixels,
          diffImagePath: diffPath,
        });
      } else {
        results.push({ filename, status: 'match', diffPercent, diffPixels });
      }
    } catch {
      // No baseline = new component
      results.push({ filename, status: 'new', diffPercent: 100, diffPixels: -1 });
    }
  }

  return results;
}

Step 3: GitHub Actions Integration

yaml
# .github/workflows/visual-test.yml
name: Visual Regression Tests
on:
  pull_request:
    paths: ['src/components/**', 'src/styles/**', 'src/tokens/**']

jobs:
  visual-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22 }
      - run: npm ci

      - name: Build Storybook
        run: npx storybook build -o storybook-static

      - name: Start Storybook
        run: npx http-server storybook-static -p 6006 &
        
      - name: Wait for Storybook
        run: npx wait-on http://localhost:6006

      - name: Download baselines
        uses: actions/download-artifact@v4
        with:
          name: visual-baselines
          path: .visual-tests/baseline
        continue-on-error: true

      - name: Capture screenshots
        run: npx tsx src/visual-tests/capture.ts

      - name: Compare screenshots
        id: diff
        run: npx tsx src/visual-tests/diff.ts > diff-report.json

      - name: Post PR comment with diffs
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const results = JSON.parse(fs.readFileSync('diff-report.json'));
            const changed = results.filter(r => r.status === 'changed');
            
            if (changed.length === 0) {
              await github.rest.issues.createComment({
                owner: context.repo.owner, repo: context.repo.repo,
                issue_number: context.issue.number,
                body: '✅ **Visual Tests Passed** — No visual changes detected.',
              });
              return;
            }
            
            let body = `## 🖼️ Visual Changes Detected (${changed.length})\n\n`;
            for (const c of changed.slice(0, 20)) {
              body += `- **${c.filename}**: ${c.diffPercent.toFixed(2)}% changed (${c.diffPixels} pixels)\n`;
            }
            body += '\n> Review the diff images in the artifacts to approve or fix these changes.';
            
            await github.rest.issues.createComment({
              owner: context.repo.owner, repo: context.repo.repo,
              issue_number: context.issue.number, body,
            });

      - name: Fail on unexpected changes
        run: |
          CHANGED=$(cat diff-report.json | node -e "
            const d=[];process.stdin.on('data',c=>d.push(c));
            process.stdin.on('end',()=>{
              const r=JSON.parse(d.join(''));
              console.log(r.filter(x=>x.status==='changed').length)
            })")
          if [ "$CHANGED" -gt "0" ]; then
            echo "❌ ${CHANGED} visual changes need review"
            exit 1
          fi

      - name: Upload baselines
        if: github.ref == 'refs/heads/main'
        uses: actions/upload-artifact@v4
        with:
          name: visual-baselines
          path: .visual-tests/current
          retention-days: 90

Results

  • Visual bugs in production: dropped from 2-3/month to zero
  • 200+ components: every variant screenshotted across 3 viewports × 2 themes = 1,200+ screenshots
  • Unintended CSS changes: caught in PR review with pixel-diff images
  • Design token changes: immediately see impact across all components
  • Review confidence: designers approve PRs with visual proof, not guesswork
  • CI time: 4 minutes for full visual regression suite
  • Time saved: 6-9 hours/month previously spent finding and fixing visual bugs

Related use cases