Skip to content

Instantly share code, notes, and snippets.

@zeroasterisk
Created February 14, 2026 04:06
Show Gist options
  • Select an option

  • Save zeroasterisk/3a4a53e02a251cb312256c0c9a5e8c9a to your computer and use it in GitHub Desktop.

Select an option

Save zeroasterisk/3a4a53e02a251cb312256c0c9a5e8c9a to your computer and use it in GitHub Desktop.
CUJ Screenshots Skill - Capture Critical User Journey screenshots and GIFs from web apps using headless Chromium
#!/usr/bin/env python3
"""
CUJ Screenshot Capture Script
Captures Critical User Journey screenshots and generates GIFs.
Designed to run against a local dev server.
Usage:
uv run --with playwright python capture-cujs.py
Requirements:
- Python 3.10+
- uv (recommended) or pip
- Playwright with Chromium browser
- ImageMagick (for GIF creation)
- Your app running locally
"""
import asyncio
import os
import shutil
import subprocess
import sys
def check_prerequisites() -> list[str]:
"""
Verify all prerequisites are installed.
Returns list of error messages (empty if all good).
"""
errors = []
# Check Python version
if sys.version_info < (3, 10):
errors.append(
f"❌ Python 3.10+ required (you have {sys.version_info.major}.{sys.version_info.minor})\n"
f" Fix: Install Python 3.10+ from https://python.org"
)
# Check for ImageMagick (convert command)
if not shutil.which("convert"):
errors.append(
"❌ ImageMagick not found (needed for GIF creation)\n"
" Fix (macOS): brew install imagemagick\n"
" Fix (Ubuntu): sudo apt install imagemagick\n"
" Fix (Fedora): sudo dnf install ImageMagick"
)
# Check for Playwright
try:
from playwright.async_api import async_playwright
except ImportError:
errors.append(
"❌ Playwright not installed\n"
" Fix: pip install playwright\n"
" Or: uv run --with playwright python capture-cujs.py"
)
return errors # Can't check browser without playwright
# Check for Chromium browser
try:
# Playwright stores browsers in a known location
import playwright
playwright_path = os.path.dirname(playwright.__file__)
# Quick check - try to get browser path
result = subprocess.run(
[sys.executable, "-c",
"from playwright.sync_api import sync_playwright; "
"p = sync_playwright().start(); "
"b = p.chromium.executable_path; "
"print(b); p.stop()"],
capture_output=True,
text=True,
timeout=10
)
if result.returncode != 0 or not result.stdout.strip():
raise Exception("Browser not found")
except Exception:
errors.append(
"❌ Playwright Chromium browser not installed\n"
" Fix: playwright install chromium\n"
" Or: uv run --with playwright playwright install chromium"
)
return errors
def check_server(url: str, name: str) -> str | None:
"""Check if a server is responding. Returns error message or None."""
import urllib.request
import urllib.error
try:
urllib.request.urlopen(url, timeout=3)
return None
except urllib.error.URLError:
return (
f"❌ {name} not responding at {url}\n"
f" Fix: Start your {name.lower()} before running this script"
)
except Exception as e:
return f"❌ {name} check failed: {e}"
def verify_environment(frontend_url: str = "http://localhost:5173",
backend_url: str | None = "http://localhost:8000") -> bool:
"""
Verify all prerequisites and servers are ready.
Prints helpful error messages and returns False if anything is missing.
"""
print("πŸ” Checking prerequisites...\n")
# Check tools
errors = check_prerequisites()
# Check servers
frontend_error = check_server(frontend_url, "Frontend")
if frontend_error:
errors.append(frontend_error)
if backend_url:
backend_error = check_server(backend_url, "Backend")
if backend_error:
errors.append(backend_error)
# Report results
if errors:
print("=" * 60)
print("⚠️ SETUP INCOMPLETE - Please fix the following:\n")
print("=" * 60)
for error in errors:
print(f"\n{error}")
print("\n" + "=" * 60)
print("\nπŸ“š Full setup guide: See SKILL.md in this directory")
print("=" * 60 + "\n")
return False
print("βœ… All prerequisites satisfied!\n")
return True
# Import playwright after checks (so we can report missing dependency nicely)
try:
from playwright.async_api import async_playwright
except ImportError:
# Will be caught by check_prerequisites
pass
# Configuration
VIEWPORT_MOBILE = {"width": 390, "height": 844}
DEVICE_SCALE = 2
BASE_URL = "http://localhost:5173"
OUTPUT_BASE = "/tmp/cuj-screenshots"
GIF_OUTPUT = "docs/gifs"
async def capture_cuj(
name: str,
steps: list[dict],
viewport: dict = VIEWPORT_MOBILE,
scale: float = DEVICE_SCALE,
):
"""
Capture a CUJ with the given steps.
Args:
name: CUJ name (used for output folder)
steps: List of step dicts with 'action', 'screenshot', and optional params
viewport: Browser viewport dimensions
scale: Device scale factor
Returns:
Path to output directory
"""
output_dir = f"{OUTPUT_BASE}/{name}"
os.makedirs(output_dir, exist_ok=True)
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context(
viewport=viewport,
device_scale_factor=scale
)
page = await context.new_page()
for i, step in enumerate(steps):
action = step.get("action", "screenshot")
screenshot_name = step.get("screenshot", f"{i+1:02d}-step.png")
try:
if action == "goto":
url = step.get("url", BASE_URL)
await page.goto(url)
await page.wait_for_timeout(step.get("wait", 1500))
elif action == "click":
selector = step["selector"]
await page.click(selector)
await page.wait_for_timeout(step.get("wait", 1000))
elif action == "fill":
selector = step["selector"]
value = step["value"]
await page.fill(selector, value)
await page.wait_for_timeout(step.get("wait", 300))
elif action == "scroll":
x = step.get("x", 0)
y = step.get("y", 300)
await page.evaluate(f"window.scrollBy({x}, {y})")
await page.wait_for_timeout(step.get("wait", 500))
elif action == "wait":
await page.wait_for_timeout(step.get("wait", 1000))
elif action == "screenshot":
pass # Just take screenshot
# Take screenshot after action
path = f"{output_dir}/{screenshot_name}"
await page.screenshot(path=path)
print(f"βœ“ {name}: {screenshot_name}")
except Exception as e:
print(f"βœ— {name}: {screenshot_name} - Error: {e}")
# Screenshot the error state
await page.screenshot(path=f"{output_dir}/{screenshot_name.replace('.png', '-error.png')}")
await browser.close()
return output_dir
def create_gif(input_dir: str, output_path: str, delay: int = 150):
"""
Create an animated GIF from screenshots in a directory.
Args:
input_dir: Directory containing numbered PNG files
output_path: Output GIF path
delay: Frame delay in hundredths of a second (150 = 1.5s)
"""
os.makedirs(os.path.dirname(output_path), exist_ok=True)
cmd = [
"convert",
"-delay", str(delay),
"-loop", "0",
f"{input_dir}/*.png",
output_path
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
print(f"βœ“ Created GIF: {output_path}")
else:
print(f"βœ— GIF creation failed: {result.stderr}")
# ============================================================
# Define your CUJs here
# ============================================================
CUJ_APP_TOUR = {
"name": "cuj1-app-tour",
"steps": [
{"action": "goto", "url": BASE_URL, "wait": 1500, "screenshot": "01-activity-tab.png"},
{"action": "scroll", "y": 200, "wait": 500, "screenshot": "02-scroll-tasks.png"},
{"action": "click", "selector": "text=Live", "wait": 1000, "screenshot": "03-live-tab.png"},
{"action": "click", "selector": "text=Activity", "wait": 1000, "screenshot": "04-back-to-activity.png"},
]
}
CUJ_CREATE_TASK = {
"name": "cuj2-create-task",
"steps": [
{"action": "goto", "url": BASE_URL, "wait": 1500, "screenshot": "01-initial.png"},
{"action": "click", "selector": "button:has-text('Plan')", "wait": 500, "screenshot": "02-plan-form.png"},
{"action": "fill", "selector": "input", "value": "My Demo Plan", "wait": 300, "screenshot": "03-plan-filled.png"},
{"action": "click", "selector": "button:has-text('Add')", "wait": 1000, "screenshot": "04-plan-created.png"},
{"action": "click", "selector": "button:has-text('Task')", "wait": 500, "screenshot": "05-task-form.png"},
{"action": "fill", "selector": "input", "value": "My First Task", "wait": 300, "screenshot": "06-task-filled.png"},
{"action": "click", "selector": "button:has-text('Add')", "wait": 1000, "screenshot": "07-task-created.png"},
]
}
CUJ_MOVE_TASK = {
"name": "cuj3-move-task",
"steps": [
{"action": "goto", "url": BASE_URL, "wait": 1500, "screenshot": "01-task-in-backlog.png"},
{"action": "click", "selector": "button:has-text('Start')", "wait": 1000, "screenshot": "02-task-started.png"},
{"action": "scroll", "x": 200, "y": 0, "wait": 500, "screenshot": "03-in-progress.png"},
{"action": "click", "selector": "button:has-text('Done')", "wait": 1000, "screenshot": "04-task-completed.png"},
]
}
async def main():
"""Capture all CUJs and create GIFs."""
# Verify environment before starting
if not verify_environment(frontend_url=BASE_URL, backend_url="http://localhost:8000"):
sys.exit(1)
cujs = [CUJ_APP_TOUR, CUJ_CREATE_TASK, CUJ_MOVE_TASK]
for cuj in cujs:
print(f"\nπŸ“Έ Capturing {cuj['name']}...")
output_dir = await capture_cuj(cuj["name"], cuj["steps"])
gif_path = f"{GIF_OUTPUT}/{cuj['name']}.gif"
create_gif(output_dir, gif_path)
print("\nβœ… All CUJs captured!")
if __name__ == "__main__":
asyncio.run(main())
name description
cuj-screenshots
Capture Critical User Journey (CUJ) screenshots and GIFs from web apps using headless Chromium. Use when you need to create visual demos, verify UI changes, or document user flows.

CUJ Screenshots Skill

Automate visual documentation of web app user journeys using headless browser automation.

When to Use This

Always run CUJ captures after UI changes. This is part of the dev loop, not just documentation.

Triggered By:

  • Any frontend component change
  • CSS/styling updates
  • New features that affect user flow
  • Bug fixes that change visible behavior
  • Before sharing work with humans ("here's what it looks like now")

The Loop:

Make UI change β†’ Run CUJs β†’ Review screenshots β†’ 
  β”œβ”€ Looks good? β†’ Update GIFs, commit, share link
  └─ Looks wrong? β†’ Fix bug, repeat

Proactive Use:

When finishing UI work, don't just say "done" β€” capture CUJs, update the GIFs, and share:

"Updated the task card styling. Here's the new CUJ: https://github.com/user/repo/blob/main/docs/CUJs.md"

This gives your human instant visual feedback without them needing to run the app.

What It Does

  1. Launches headless Chromium via Playwright
  2. Navigates through a defined user journey
  3. Captures screenshots at each step
  4. Stitches screenshots into an animated GIF
  5. Outputs to a docs folder for commit

Requirements

  • Playwright: uv run --with playwright python script.py
  • Chromium: Auto-installed by Playwright on first run
  • ImageMagick: For GIF creation (convert command)

Install Playwright browsers (one-time):

uv run --with playwright playwright install chromium

Quick Start

1. Define Your CUJ

Create a Python script for each journey:

import asyncio
from playwright.async_api import async_playwright
import os

async def capture_my_cuj():
    output_dir = "/tmp/cuj-screenshots/my-cuj"
    os.makedirs(output_dir, exist_ok=True)
    
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        context = await browser.new_context(
            viewport={"width": 390, "height": 844},  # Mobile
            device_scale_factor=2  # Retina
        )
        page = await context.new_page()
        
        # Step 1
        await page.goto("http://localhost:5173")
        await page.wait_for_timeout(1500)
        await page.screenshot(path=f"{output_dir}/01-initial.png")
        
        # Step 2: Interact
        await page.click("button:has-text('Submit')")
        await page.wait_for_timeout(1000)
        await page.screenshot(path=f"{output_dir}/02-submitted.png")
        
        # ... more steps
        
        await browser.close()

if __name__ == "__main__":
    asyncio.run(capture_my_cuj())

2. Run Capture

uv run --with playwright python my_cuj.py

3. Create GIF

convert -delay 150 -loop 0 /tmp/cuj-screenshots/my-cuj/*.png output.gif
  • -delay 150 = 1.5 seconds per frame (hundredths of a second)
  • -loop 0 = infinite loop

4. Commit & Share

mv output.gif docs/gifs/my-cuj.gif
git add docs/gifs/my-cuj.gif docs/CUJs.md
git commit -m "docs: add my-cuj GIF"
git push

Viewport Presets

Device Width Height Scale
iPhone 12 Pro 390 844 2
iPhone SE 375 667 2
Pixel 5 393 851 2.75
Desktop 1280 720 1
Desktop HD 1920 1080 1

Common Interactions

# Click button by text
await page.click("button:has-text('Submit')")

# Fill input
await page.fill("input[name='email']", "test@example.com")

# Select dropdown
await page.select_option("select#plan", "premium")

# Wait for element
await page.wait_for_selector(".success-message")

# Scroll
await page.evaluate("window.scrollBy(0, 300)")

# Keyboard shortcut
await page.keyboard.press("Escape")

Full Workflow Example

See scripts/capture-cujs.py for a complete example that:

  • Starts backend + frontend servers
  • Captures multiple CUJs
  • Generates GIFs
  • Updates CUJs.md

Tips

  • Wait times: Use wait_for_timeout(1000) after interactions for animations
  • Selectors: Prefer text= or has-text() over fragile CSS selectors
  • Error handling: Wrap interactions in try/except, screenshot on error
  • Naming: Use numbered prefixes (01-, 02-) for sort order
  • GIF size: Keep under 500KB for GitHub README display

CUJs.md Documentation

Create a docs/CUJs.md file to document each journey with embedded GIFs:

# Critical User Journeys (CUJs)

## CUJ 1: App Tour

**Goal:** Navigate the main app layout.

**Steps:**
1. Open app β†’ Activity tab
2. Switch to Live tab
3. Return to Activity

![App Tour](./gifs/cuj1-app-tour.gif)

---

## CUJ 2: Create Task

**Goal:** Create a new plan and task.

**Steps:**
1. Click "+ Plan"
2. Enter name, submit
3. Click "+ Task"
4. Enter name, submit

![Create Task](./gifs/cuj2-create-task.gif)

This serves as both documentation and visual regression baseline.

Verifying UI Changes

After making UI changes:

  1. Run the CUJ capture scripts
  2. Review the new screenshots (you can read image files!)
  3. If they look correct, update GIFs and commit
  4. Update CUJs.md if steps changed
  5. If something's wrong, you caught a bug!

Full Workflow

# 1. Make UI changes
# 2. Start servers
# 3. Capture CUJs
uv run --with playwright python scripts/capture-cujs.py

# 4. Review screenshots (agent can view these)
# 5. Create GIFs
convert -delay 150 -loop 0 /tmp/cuj-screenshots/cuj1/*.png docs/gifs/cuj1.gif

# 6. Update CUJs.md with new steps/descriptions
# 7. Commit everything
git add docs/CUJs.md docs/gifs/
git commit -m "docs: update CUJ screenshots after UI changes"
git push
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment