diff --git a/skills/generate-screenshot/LICENSE.txt b/skills/generate-screenshot/LICENSE.txt new file mode 100644 index 000000000..35e0cd374 --- /dev/null +++ b/skills/generate-screenshot/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Akaal Creatives + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/skills/generate-screenshot/SKILL.md b/skills/generate-screenshot/SKILL.md new file mode 100644 index 000000000..3eee20a9f --- /dev/null +++ b/skills/generate-screenshot/SKILL.md @@ -0,0 +1,218 @@ +--- +name: generate-screenshot +description: > + Generate screenshots of a web app using Playwright. Captures desktop, tablet, and mobile + viewports in both light and dark mode. Auto-detects running dev servers, supports multiple + routes, and saves organised screenshots. Use when the user asks to generate screenshots, + capture screenshots, or invokes /generate-screenshot. +license: Complete terms in LICENSE.txt +allowed-tools: Bash, Read, Glob, Grep, Write, Edit, AskUserQuestion +argument-hint: "[route1] [route2] [...] [--output dir] [--url http://...] [--viewports desktop,mobile,tablet] [--modes light,dark]" +--- + +# Generate Screenshot Skill + +Generate screenshots of a running web app across multiple viewports and colour modes using Playwright. + +## Workflow + +Follow these steps **in order**. Do not skip steps. + +### Step 1: Ensure Playwright is installed + +```bash +cd $SKILL_DIR && node -e "try { require('playwright'); console.log('OK'); } catch(e) { console.log('MISSING'); }" 2>/dev/null +``` + +If `MISSING`, run: + +```bash +cd $SKILL_DIR && npm install +``` + +Then ensure the Chromium browser is available: + +```bash +cd $SKILL_DIR && npx playwright install chromium 2>&1 | tail -5 +``` + +### Step 2: Detect the dev server + +Auto-detect a running dev server on common ports: + +```bash +cd $SKILL_DIR && node -e "require('./lib/helpers').detectDevServers().then(s => console.log(JSON.stringify(s, null, 2)))" +``` + +**Common ports checked:** 3000, 3001, 3002, 4200, 5000, 5173, 5174, 8000, 8080, 8888, 9000, 1234 + +- If **one server** is found, use it as the base URL. +- If **multiple servers** are found, ask the user which one to use. +- If **no server** is found, ask the user for the URL using `AskUserQuestion`. + +The user may also provide a URL explicitly via `--url`. That takes priority. + +### Step 3: Determine routes to capture + +Check if the user provided routes as arguments (e.g. `/generate-screenshot /login /dashboard /settings`). + +- If routes **were provided**, use them. +- If routes **were NOT provided**, attempt to scan the project for route definitions: + 1. Search for route files in common framework patterns: + - **Next.js (App Router):** `Glob` for `app/**/page.{tsx,jsx,ts,js}` — convert file paths to routes + - **Next.js (Pages Router):** `Glob` for `pages/**/*.{tsx,jsx,ts,js}` — convert file paths to routes + - **React Router:** `Grep` for `path=` or `.js` and execute it using the skill's runner: + +```bash +cd $SKILL_DIR && node run.js /tmp/generate-screenshot-.js +``` + +**The generated script MUST:** + +1. Launch Chromium in **headless** mode (screenshots don't need visible browser). +2. For each colour mode (`light` / `dark`): + a. Create a new browser context with `colorScheme` set accordingly. + b. For each viewport (`desktop` / `tablet` / `mobile`): + - Set the viewport size on the context. + - For each route: + - Navigate to `{baseUrl}{route}`. + - Wait for network idle (`waitUntil: 'networkidle'`). + - Wait an additional 500ms for animations to settle. + - Take a **full-page** screenshot. + - Save to the output directory with the naming convention above. +3. Close the browser. +4. Print a summary of all screenshots taken. + +**Script template reference:** + +```javascript +const { chromium } = require('playwright'); +const path = require('path'); +const fs = require('fs'); + +(async () => { + const baseUrl = '{{BASE_URL}}'; + const outputDir = '{{OUTPUT_DIR}}'; + const routes = {{ROUTES_JSON}}; + + const viewports = { + desktop: { width: 1920, height: 1080 }, + tablet: { width: 768, height: 1024 }, + mobile: { width: 375, height: 812 }, + }; + const selectedViewports = {{VIEWPORTS_JSON}}; + const modes = {{MODES_JSON}}; + + fs.mkdirSync(outputDir, { recursive: true }); + + const browser = await chromium.launch({ headless: true }); + const screenshots = []; + + for (const mode of modes) { + for (const [vpName, vpSize] of Object.entries(viewports)) { + if (!selectedViewports.includes(vpName)) continue; + + const context = await browser.newContext({ + viewport: vpSize, + colorScheme: mode, + }); + const page = await context.newPage(); + + for (const route of routes) { + const url = `${baseUrl}${route}`; + const routeName = route === '/' ? 'home' : route.replace(/^\//, '').replace(/\//g, '-'); + const fileName = `${routeName}--${vpName}--${mode}.png`; + const filePath = path.resolve(outputDir, fileName); + + try { + await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 }); + await page.waitForTimeout(500); + await page.screenshot({ path: filePath, fullPage: true }); + screenshots.push({ route, viewport: vpName, mode, file: fileName, status: 'ok' }); + console.log(` ✓ ${fileName}`); + } catch (err) { + screenshots.push({ route, viewport: vpName, mode, file: fileName, status: 'failed', error: err.message }); + console.error(` ✗ ${fileName} — ${err.message}`); + } + } + + await context.close(); + } + } + + await browser.close(); + + const ok = screenshots.filter(s => s.status === 'ok').length; + const failed = screenshots.filter(s => s.status === 'failed').length; + console.log(`\nDone: ${ok} captured, ${failed} failed, ${screenshots.length} total.`); + console.log(`Screenshots saved to: ${path.resolve(outputDir)}`); +})(); +``` + +### Step 7: Report results + +After execution, present the user with: +1. A summary table of all screenshots (route, viewport, mode, status). +2. The output directory path. +3. Any failures with error messages. + +## Important Notes + +- **Never write scripts to `$SKILL_DIR`** — always use `/tmp/`. +- **Use absolute paths** for the output directory when passing to the script. +- **Handle authentication**: If the app requires login, ask the user for credentials or a pre-auth strategy before capturing. +- **Handle cookie banners**: If a cookie/consent banner appears, attempt to dismiss it before taking screenshots. +- **Respect the user's preferences**: If they specify a subset of viewports or modes, only capture those. +- **Error resilience**: If one route fails, continue with the remaining routes. Report all failures at the end. diff --git a/skills/generate-screenshot/examples/OUTPUT.md b/skills/generate-screenshot/examples/OUTPUT.md new file mode 100644 index 000000000..929547d48 --- /dev/null +++ b/skills/generate-screenshot/examples/OUTPUT.md @@ -0,0 +1,97 @@ +# Example Output + +## Basic single page (`/`) + +``` +screenshots/ +├── home--desktop--light.png +├── home--desktop--dark.png +├── home--tablet--light.png +├── home--tablet--dark.png +├── home--mobile--light.png +└── home--mobile--dark.png +``` + +6 screenshots (1 route x 3 viewports x 2 modes) + +## Multi-route (`/`, `/login`, `/dashboard`, `/settings`) + +``` +screenshots/ +├── home--desktop--light.png +├── home--desktop--dark.png +├── home--tablet--light.png +├── home--tablet--dark.png +├── home--mobile--light.png +├── home--mobile--dark.png +├── login--desktop--light.png +├── login--desktop--dark.png +├── login--tablet--light.png +├── login--tablet--dark.png +├── login--mobile--light.png +├── login--mobile--dark.png +├── dashboard--desktop--light.png +├── dashboard--desktop--dark.png +├── dashboard--tablet--light.png +├── dashboard--tablet--dark.png +├── dashboard--mobile--light.png +├── dashboard--mobile--dark.png +├── settings--desktop--light.png +├── settings--desktop--dark.png +├── settings--tablet--light.png +├── settings--tablet--dark.png +├── settings--mobile--light.png +└── settings--mobile--dark.png +``` + +24 screenshots (4 routes x 3 viewports x 2 modes) + +## Single viewport — desktop light only + +``` +screenshots/ +├── home--desktop--light.png +├── login--desktop--light.png +├── dashboard--desktop--light.png +└── settings--desktop--light.png +``` + +4 screenshots (4 routes x 1 viewport x 1 mode) + +## Authenticated pages (`/dashboard`, `/settings`, `/profile`) + +``` +screenshots/ +├── dashboard--desktop--light.png +├── dashboard--desktop--dark.png +├── dashboard--tablet--light.png +├── dashboard--tablet--dark.png +├── dashboard--mobile--light.png +├── dashboard--mobile--dark.png +├── settings--desktop--light.png +├── settings--desktop--dark.png +├── settings--tablet--light.png +├── settings--tablet--dark.png +├── settings--mobile--light.png +├── settings--mobile--dark.png +├── profile--desktop--light.png +├── profile--desktop--dark.png +├── profile--tablet--light.png +├── profile--tablet--dark.png +├── profile--mobile--light.png +└── profile--mobile--dark.png +``` + +18 screenshots (3 routes x 3 viewports x 2 modes) + +## Naming convention + +``` +{route}--{viewport}--{mode}.png +``` + +| Segment | Values | +|---------|--------| +| route | `home` for `/`, otherwise path with `/` replaced by `-` | +| viewport | `desktop`, `tablet`, `mobile` | +| mode | `light`, `dark` | diff --git a/skills/generate-screenshot/examples/authenticated_pages.js b/skills/generate-screenshot/examples/authenticated_pages.js new file mode 100644 index 000000000..8fb26eedb --- /dev/null +++ b/skills/generate-screenshot/examples/authenticated_pages.js @@ -0,0 +1,134 @@ +/** + * Authenticated Pages Screenshot + * + * Logs into the app first, then captures protected routes using + * the authenticated session. Reuses the same browser context across + * all routes so the session persists. + * + * Usage (manual): + * cd ~/.claude/skills/generate-screenshot && \ + * USERNAME="user@example.com" PASSWORD="secret" node run.js examples/authenticated_pages.js + * + * Usage (via Claude): + * /generate-screenshot /dashboard /settings /profile + * (Claude will ask for credentials if login is required) + * + * Environment variables: + * BASE_URL - Target URL (default: http://localhost:5173) + * OUTPUT_DIR - Output directory (default: ./screenshots) + * LOGIN_URL - Login page path (default: /login) + * USERNAME - Login username/email + * PASSWORD - Login password + * ROUTES - Comma-separated protected routes (default: /dashboard,/settings,/profile) + * + * Selectors (override if your login form differs): + * SEL_USERNAME - Username input selector (default: input[name="email"]) + * SEL_PASSWORD - Password input selector (default: input[name="password"]) + * SEL_SUBMIT - Submit button selector (default: button[type="submit"]) + * + * Output: + * screenshots/ + * ├── dashboard--desktop--light.png + * ├── dashboard--desktop--dark.png + * ├── dashboard--tablet--light.png + * ├── ... + * ├── settings--desktop--light.png + * ├── ... + * └── profile--mobile--dark.png + */ + +const { chromium } = require('playwright'); +const path = require('path'); +const fs = require('fs'); + +(async () => { + const baseUrl = process.env.BASE_URL || 'http://localhost:5173'; + const outputDir = process.env.OUTPUT_DIR || path.resolve(process.cwd(), 'screenshots'); + const loginPath = process.env.LOGIN_URL || '/login'; + const username = process.env.USERNAME || ''; + const password = process.env.PASSWORD || ''; + + const selUsername = process.env.SEL_USERNAME || 'input[name="email"]'; + const selPassword = process.env.SEL_PASSWORD || 'input[name="password"]'; + const selSubmit = process.env.SEL_SUBMIT || 'button[type="submit"]'; + + const routes = process.env.ROUTES + ? process.env.ROUTES.split(',').map(r => r.trim()) + : ['/dashboard', '/settings', '/profile']; + + const viewports = { + desktop: { width: 1920, height: 1080 }, + tablet: { width: 768, height: 1024 }, + mobile: { width: 375, height: 812 }, + }; + const modes = ['light', 'dark']; + + if (!username || !password) { + console.error('Error: USERNAME and PASSWORD environment variables are required.'); + console.error('Usage: USERNAME="user@example.com" PASSWORD="secret" node run.js examples/authenticated_pages.js'); + process.exit(1); + } + + fs.mkdirSync(outputDir, { recursive: true }); + + console.log(`Base URL: ${baseUrl}`); + console.log(`Login: ${loginPath}`); + console.log(`Routes: ${routes.join(', ')}\n`); + + const browser = await chromium.launch({ headless: true }); + const screenshots = []; + + for (const mode of modes) { + for (const [vpName, vpSize] of Object.entries(viewports)) { + const context = await browser.newContext({ + viewport: vpSize, + colorScheme: mode, + }); + const page = await context.newPage(); + + // --- Login --- + try { + console.log(` Logging in (${vpName}, ${mode})...`); + await page.goto(`${baseUrl}${loginPath}`, { waitUntil: 'networkidle', timeout: 30000 }); + await page.fill(selUsername, username); + await page.fill(selPassword, password); + await page.click(selSubmit); + await page.waitForLoadState('networkidle', { timeout: 15000 }); + console.log(' Login successful.\n'); + } catch (err) { + console.error(` Login failed (${vpName}, ${mode}): ${err.message}`); + console.error(' Skipping this viewport/mode combination.\n'); + await context.close(); + continue; + } + + // --- Capture protected routes --- + for (const route of routes) { + const url = `${baseUrl}${route}`; + const routeName = route.replace(/^\//, '').replace(/\//g, '-'); + const fileName = `${routeName}--${vpName}--${mode}.png`; + const filePath = path.resolve(outputDir, fileName); + + try { + await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 }); + await page.waitForTimeout(500); + await page.screenshot({ path: filePath, fullPage: true }); + screenshots.push({ route, viewport: vpName, mode, file: fileName, status: 'ok' }); + console.log(` OK ${fileName}`); + } catch (err) { + screenshots.push({ route, viewport: vpName, mode, file: fileName, status: 'failed', error: err.message }); + console.error(` FAIL ${fileName} - ${err.message}`); + } + } + + await context.close(); + } + } + + await browser.close(); + + const ok = screenshots.filter(s => s.status === 'ok').length; + const failed = screenshots.filter(s => s.status === 'failed').length; + console.log(`\nDone: ${ok} captured, ${failed} failed, ${screenshots.length} total.`); + console.log(`Screenshots saved to: ${outputDir}`); +})(); diff --git a/skills/generate-screenshot/examples/basic_single_page.js b/skills/generate-screenshot/examples/basic_single_page.js new file mode 100644 index 000000000..a9ed95282 --- /dev/null +++ b/skills/generate-screenshot/examples/basic_single_page.js @@ -0,0 +1,75 @@ +/** + * Basic Single Page Screenshot + * + * Captures the home page across all three viewports (desktop, tablet, mobile) + * in both light and dark mode. This is the default behaviour when running + * /generate-screenshot with no arguments. + * + * Usage (manual): + * cd ~/.claude/skills/generate-screenshot && node run.js examples/basic_single_page.js + * + * Usage (via Claude): + * /generate-screenshot + * + * Output (6 screenshots): + * screenshots/ + * ├── home--desktop--light.png + * ├── home--desktop--dark.png + * ├── home--tablet--light.png + * ├── home--tablet--dark.png + * ├── home--mobile--light.png + * └── home--mobile--dark.png + */ + +const { chromium } = require('playwright'); +const path = require('path'); +const fs = require('fs'); + +(async () => { + const baseUrl = process.env.BASE_URL || 'http://localhost:5173'; + const outputDir = process.env.OUTPUT_DIR || path.resolve(process.cwd(), 'screenshots'); + + const viewports = { + desktop: { width: 1920, height: 1080 }, + tablet: { width: 768, height: 1024 }, + mobile: { width: 375, height: 812 }, + }; + const modes = ['light', 'dark']; + + fs.mkdirSync(outputDir, { recursive: true }); + + const browser = await chromium.launch({ headless: true }); + const screenshots = []; + + for (const mode of modes) { + for (const [vpName, vpSize] of Object.entries(viewports)) { + const context = await browser.newContext({ + viewport: vpSize, + colorScheme: mode, + }); + const page = await context.newPage(); + const fileName = `home--${vpName}--${mode}.png`; + const filePath = path.resolve(outputDir, fileName); + + try { + await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 30000 }); + await page.waitForTimeout(500); + await page.screenshot({ path: filePath, fullPage: true }); + screenshots.push({ file: fileName, status: 'ok' }); + console.log(` OK ${fileName}`); + } catch (err) { + screenshots.push({ file: fileName, status: 'failed', error: err.message }); + console.error(` FAIL ${fileName} - ${err.message}`); + } + + await context.close(); + } + } + + await browser.close(); + + const ok = screenshots.filter(s => s.status === 'ok').length; + const failed = screenshots.filter(s => s.status === 'failed').length; + console.log(`\nDone: ${ok} captured, ${failed} failed, ${screenshots.length} total.`); + console.log(`Screenshots saved to: ${outputDir}`); +})(); diff --git a/skills/generate-screenshot/examples/multi_route.js b/skills/generate-screenshot/examples/multi_route.js new file mode 100644 index 000000000..56e6ebff5 --- /dev/null +++ b/skills/generate-screenshot/examples/multi_route.js @@ -0,0 +1,94 @@ +/** + * Multi-Route Screenshot + * + * Captures multiple routes across all viewports and colour modes. + * Pass routes via the ROUTES environment variable (comma-separated) + * or edit the defaults below. + * + * Usage (manual): + * cd ~/.claude/skills/generate-screenshot && node run.js examples/multi_route.js + * cd ~/.claude/skills/generate-screenshot && ROUTES="/,/login,/dashboard,/settings" node run.js examples/multi_route.js + * + * Usage (via Claude): + * /generate-screenshot /login /dashboard /settings + * + * Output (24 screenshots for 4 routes x 3 viewports x 2 modes): + * screenshots/ + * ├── home--desktop--light.png + * ├── home--desktop--dark.png + * ├── home--tablet--light.png + * ├── ... + * ├── login--desktop--light.png + * ├── login--desktop--dark.png + * ├── ... + * ├── dashboard--desktop--light.png + * ├── ... + * └── settings--mobile--dark.png + */ + +const { chromium } = require('playwright'); +const path = require('path'); +const fs = require('fs'); + +(async () => { + const baseUrl = process.env.BASE_URL || 'http://localhost:5173'; + const outputDir = process.env.OUTPUT_DIR || path.resolve(process.cwd(), 'screenshots'); + + const defaultRoutes = ['/', '/login', '/dashboard', '/settings']; + const routes = process.env.ROUTES + ? process.env.ROUTES.split(',').map(r => r.trim()) + : defaultRoutes; + + const viewports = { + desktop: { width: 1920, height: 1080 }, + tablet: { width: 768, height: 1024 }, + mobile: { width: 375, height: 812 }, + }; + const modes = ['light', 'dark']; + + fs.mkdirSync(outputDir, { recursive: true }); + + console.log(`Base URL: ${baseUrl}`); + console.log(`Routes: ${routes.join(', ')}`); + console.log(`Output: ${outputDir}\n`); + + const browser = await chromium.launch({ headless: true }); + const screenshots = []; + + for (const mode of modes) { + for (const [vpName, vpSize] of Object.entries(viewports)) { + const context = await browser.newContext({ + viewport: vpSize, + colorScheme: mode, + }); + const page = await context.newPage(); + + for (const route of routes) { + const url = `${baseUrl}${route}`; + const routeName = route === '/' ? 'home' : route.replace(/^\//, '').replace(/\//g, '-'); + const fileName = `${routeName}--${vpName}--${mode}.png`; + const filePath = path.resolve(outputDir, fileName); + + try { + await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 }); + await page.waitForTimeout(500); + await page.screenshot({ path: filePath, fullPage: true }); + screenshots.push({ route, viewport: vpName, mode, file: fileName, status: 'ok' }); + console.log(` OK ${fileName}`); + } catch (err) { + screenshots.push({ route, viewport: vpName, mode, file: fileName, status: 'failed', error: err.message }); + console.error(` FAIL ${fileName} - ${err.message}`); + } + } + + await context.close(); + } + } + + await browser.close(); + + const ok = screenshots.filter(s => s.status === 'ok').length; + const failed = screenshots.filter(s => s.status === 'failed').length; + console.log(`\nDone: ${ok} captured, ${failed} failed, ${screenshots.length} total.`); + console.log(`Screenshots saved to: ${outputDir}`); +})(); diff --git a/skills/generate-screenshot/examples/single_viewport.js b/skills/generate-screenshot/examples/single_viewport.js new file mode 100644 index 000000000..97438bdae --- /dev/null +++ b/skills/generate-screenshot/examples/single_viewport.js @@ -0,0 +1,98 @@ +/** + * Single Viewport Screenshot + * + * Captures routes using only one viewport and one colour mode. + * Useful for quick captures or CI pipelines where you only need + * a specific combination. + * + * Usage (manual): + * cd ~/.claude/skills/generate-screenshot && node run.js examples/single_viewport.js + * cd ~/.claude/skills/generate-screenshot && VIEWPORT=mobile MODE=dark node run.js examples/single_viewport.js + * + * Usage (via Claude): + * /generate-screenshot / --viewports mobile --modes dark + * + * Environment variables: + * BASE_URL - Target URL (default: http://localhost:5173) + * OUTPUT_DIR - Output directory (default: ./screenshots) + * ROUTES - Comma-separated routes (default: /) + * VIEWPORT - One of: desktop, tablet, mobile (default: desktop) + * MODE - One of: light, dark (default: light) + * + * Output (1 screenshot per route): + * screenshots/ + * └── home--desktop--light.png + */ + +const { chromium } = require('playwright'); +const path = require('path'); +const fs = require('fs'); + +(async () => { + const baseUrl = process.env.BASE_URL || 'http://localhost:5173'; + const outputDir = process.env.OUTPUT_DIR || path.resolve(process.cwd(), 'screenshots'); + + const routes = process.env.ROUTES + ? process.env.ROUTES.split(',').map(r => r.trim()) + : ['/']; + + const allViewports = { + desktop: { width: 1920, height: 1080 }, + tablet: { width: 768, height: 1024 }, + mobile: { width: 375, height: 812 }, + }; + + const vpName = process.env.VIEWPORT || 'desktop'; + const mode = process.env.MODE || 'light'; + const vpSize = allViewports[vpName]; + + if (!vpSize) { + console.error(`Unknown viewport: "${vpName}". Use one of: desktop, tablet, mobile`); + process.exit(1); + } + + if (!['light', 'dark'].includes(mode)) { + console.error(`Unknown mode: "${mode}". Use one of: light, dark`); + process.exit(1); + } + + fs.mkdirSync(outputDir, { recursive: true }); + + console.log(`Viewport: ${vpName} (${vpSize.width}x${vpSize.height})`); + console.log(`Mode: ${mode}`); + console.log(`Routes: ${routes.join(', ')}\n`); + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ + viewport: vpSize, + colorScheme: mode, + }); + const page = await context.newPage(); + const screenshots = []; + + for (const route of routes) { + const url = `${baseUrl}${route}`; + const routeName = route === '/' ? 'home' : route.replace(/^\//, '').replace(/\//g, '-'); + const fileName = `${routeName}--${vpName}--${mode}.png`; + const filePath = path.resolve(outputDir, fileName); + + try { + await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 }); + await page.waitForTimeout(500); + await page.screenshot({ path: filePath, fullPage: true }); + screenshots.push({ route, file: fileName, status: 'ok' }); + console.log(` OK ${fileName}`); + } catch (err) { + screenshots.push({ route, file: fileName, status: 'failed', error: err.message }); + console.error(` FAIL ${fileName} - ${err.message}`); + } + } + + await context.close(); + await browser.close(); + + const ok = screenshots.filter(s => s.status === 'ok').length; + const failed = screenshots.filter(s => s.status === 'failed').length; + console.log(`\nDone: ${ok} captured, ${failed} failed, ${screenshots.length} total.`); + console.log(`Screenshots saved to: ${outputDir}`); +})(); diff --git a/skills/generate-screenshot/lib/helpers.js b/skills/generate-screenshot/lib/helpers.js new file mode 100644 index 000000000..c715f8cb5 --- /dev/null +++ b/skills/generate-screenshot/lib/helpers.js @@ -0,0 +1,150 @@ +/** + * Helper utilities for the generate-screenshot skill. + */ + +const http = require('http'); +const https = require('https'); + +/** + * Common development server ports to check. + */ +const DEFAULT_PORTS = [ + 3000, 3001, 3002, 4200, 5000, 5173, 5174, 8000, 8080, 8888, 9000, 1234, +]; + +/** + * Default viewport configurations. + */ +const VIEWPORTS = { + desktop: { width: 1920, height: 1080 }, + tablet: { width: 768, height: 1024 }, + mobile: { width: 375, height: 812 }, +}; + +/** + * Default colour modes. + */ +const COLOUR_MODES = ['light', 'dark']; + +/** + * Check if a server is running on a given port. + * + * @param {number} port + * @param {string} host + * @returns {Promise<{port: number, url: string, status: number} | null>} + */ +function checkPort(port, host = 'localhost') { + return new Promise((resolve) => { + const url = `http://${host}:${port}`; + const req = http.get(url, { timeout: 2000 }, (res) => { + resolve({ port, url, status: res.statusCode }); + res.resume(); + }); + req.on('error', () => resolve(null)); + req.on('timeout', () => { + req.destroy(); + resolve(null); + }); + }); +} + +/** + * Detect running dev servers on common ports. + * + * @param {number[]} [customPorts] - Additional ports to check. + * @returns {Promise>} + */ +async function detectDevServers(customPorts = []) { + const ports = [...new Set([...DEFAULT_PORTS, ...customPorts])]; + const results = await Promise.all(ports.map((p) => checkPort(p))); + return results.filter(Boolean); +} + +/** + * Convert a route path to a safe filename segment. + * + * @param {string} route - The route path (e.g. '/dashboard/settings'). + * @returns {string} Safe filename segment (e.g. 'dashboard-settings'). + */ +function routeToFilename(route) { + if (route === '/') return 'home'; + return route + .replace(/^\//, '') + .replace(/\//g, '-') + .replace(/[^a-zA-Z0-9-_]/g, '_'); +} + +/** + * Build the screenshot filename. + * + * @param {string} route + * @param {string} viewport - e.g. 'desktop', 'mobile', 'tablet' + * @param {string} mode - e.g. 'light', 'dark' + * @returns {string} + */ +function buildFilename(route, viewport, mode) { + return `${routeToFilename(route)}--${viewport}--${mode}.png`; +} + +/** + * Attempt to dismiss common cookie/consent banners. + * + * @param {import('playwright').Page} page + * @param {number} [timeout=3000] + */ +async function dismissCookieBanner(page, timeout = 3000) { + const selectors = [ + '[data-testid="cookie-accept"]', + '[data-testid="accept-cookies"]', + 'button:has-text("Accept")', + 'button:has-text("Accept all")', + 'button:has-text("Accept All")', + 'button:has-text("Got it")', + 'button:has-text("I agree")', + 'button:has-text("OK")', + '.cookie-banner button', + '#cookie-consent button', + '[class*="cookie"] button', + '[class*="consent"] button', + ]; + + for (const selector of selectors) { + try { + const el = await page.waitForSelector(selector, { timeout }); + if (el) { + await el.click(); + await page.waitForTimeout(300); + return true; + } + } catch { + // Selector not found, try next + } + } + return false; +} + +/** + * Wait for a page to be fully ready (network idle + optional delay). + * + * @param {import('playwright').Page} page + * @param {object} [options] + * @param {number} [options.settleDelay=500] - Extra ms to wait after network idle. + * @param {number} [options.timeout=30000] - Navigation timeout in ms. + */ +async function waitForPageReady(page, options = {}) { + const { settleDelay = 500, timeout = 30000 } = options; + await page.waitForLoadState('networkidle', { timeout }); + await page.waitForTimeout(settleDelay); +} + +module.exports = { + DEFAULT_PORTS, + VIEWPORTS, + COLOUR_MODES, + detectDevServers, + routeToFilename, + buildFilename, + dismissCookieBanner, + waitForPageReady, + checkPort, +}; diff --git a/skills/generate-screenshot/package.json b/skills/generate-screenshot/package.json new file mode 100644 index 000000000..92847ff62 --- /dev/null +++ b/skills/generate-screenshot/package.json @@ -0,0 +1,26 @@ +{ + "name": "generate-screenshot", + "version": "1.0.0", + "description": "Claude CLI skill to generate web app screenshots across viewports and colour modes using Playwright", + "author": "Akaal Creatives ", + "main": "run.js", + "scripts": { + "setup": "npm install && npx playwright install chromium", + "install-browsers": "npx playwright install chromium" + }, + "dependencies": { + "playwright": "^1.57.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "license": "MIT", + "keywords": [ + "claude-code", + "claude-skill", + "playwright", + "screenshot", + "testing", + "automation" + ] +} diff --git a/skills/generate-screenshot/run.js b/skills/generate-screenshot/run.js new file mode 100644 index 000000000..5b732046d --- /dev/null +++ b/skills/generate-screenshot/run.js @@ -0,0 +1,96 @@ +#!/usr/bin/env node + +/** + * Universal Playwright script runner for generate-screenshot skill. + * + * Usage: + * node run.js script.js - Execute from file + * node run.js 'inline code' - Execute inline Playwright code + * cat script.js | node run.js - Execute from stdin + */ + +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +function ensurePlaywright() { + try { + require.resolve('playwright'); + return true; + } catch { + console.log('Playwright not found. Installing...'); + execFileSync('npm', ['install'], { cwd: __dirname, stdio: 'inherit' }); + console.log('Installing Chromium browser...'); + execFileSync('npx', ['playwright', 'install', 'chromium'], { cwd: __dirname, stdio: 'inherit' }); + return true; + } +} + +function getCode() { + const arg = process.argv[2]; + + // From file + if (arg && fs.existsSync(arg)) { + return { code: fs.readFileSync(arg, 'utf-8'), source: arg }; + } + + // Inline code + if (arg) { + return { code: arg, source: 'inline' }; + } + + // From stdin + if (!process.stdin.isTTY) { + return { + code: fs.readFileSync('/dev/stdin', 'utf-8'), + source: 'stdin', + }; + } + + console.error('Usage: node run.js '); + process.exit(1); +} + +function needsWrapping(code) { + return !code.includes('require(') && !code.includes('import '); +} + +function wrapCode(code) { + const helpersPath = path.join(__dirname, 'lib', 'helpers').replace(/\\/g, '\\\\'); + return ` +const { chromium, firefox, webkit, devices } = require('playwright'); +const helpers = require('${helpersPath}'); + +(async () => { + ${code} +})().catch(err => { + console.error('Script error:', err.message); + process.exit(1); +}); +`; +} + +async function main() { + ensurePlaywright(); + + const { code, source } = getCode(); + console.log(`Running script from: ${source}`); + + const finalCode = needsWrapping(code) ? wrapCode(code) : code; + + // Write to a temporary file for execution + const tmpFile = path.join('/tmp', `generate-screenshot-exec-${Date.now()}.js`); + fs.writeFileSync(tmpFile, finalCode); + + try { + execFileSync('node', [tmpFile], { + stdio: 'inherit', + env: { ...process.env, NODE_PATH: path.join(__dirname, 'node_modules') }, + }); + } finally { + // Clean up execution file + try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } + } +} + +main();