scripts/capture-build.mjs
A supporting file of the design-critique skill, shown as source.
#!/usr/bin/env node
/**
* capture-build.mjs — render a project's ALREADY-BUILT output to PNGs.
*
* Why this exists: a built SPA cannot be opened from the filesystem. Vite/CRA/Next
* emit absolute asset paths (src="/assets/index-abc.js"), which resolve to the
* filesystem root over file:// and never load — you get a white page. Serving the
* same folder over http fixes it completely.
*
* This NEVER runs a build and NEVER runs the project's dev server or npm scripts.
* It only serves files that already exist, from a loopback-bound static server it
* starts and stops itself.
*
* Usage:
* node capture-build.mjs <project-dir|build-dir> [--routes=/,/agents,/settings]
* [--out=<dir>] [--width=1280] [--height=900] [--full]
*
* Prints JSON: { buildDir, spa, served, screens:[{route,path,chars}], skipped:[…], notes:[…] }
*/
import { createServer } from 'node:http'
import { existsSync, readFileSync, statSync, mkdirSync, readdirSync } from 'node:fs'
import { join, resolve, extname, isAbsolute } from 'node:path'
import { tmpdir } from 'node:os'
import { getPlaywright } from './ensure-playwright.mjs'
// Build outputs worth trying, best first. storybook-static is the richest:
// every component in every state, already rendered.
const BUILD_DIRS = ['storybook-static', 'dist', 'build', 'out', 'public/dist', '.next/server/app']
const MIME = {
'.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript',
'.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml',
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
'.webp': 'image/webp', '.avif': 'image/avif', '.ico': 'image/x-icon',
'.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.map': 'application/json',
}
function parseArgs(argv) {
const pos = []
const opt = { width: 1280, height: 900, full: false, routes: null, out: null }
for (const a of argv) {
if (a.startsWith('--width=')) opt.width = parseInt(a.slice(8), 10)
else if (a.startsWith('--height=')) opt.height = parseInt(a.slice(9), 10)
else if (a === '--full') opt.full = true
else if (a.startsWith('--routes=')) opt.routes = a.slice(9).split(',').map(s => s.trim()).filter(Boolean)
else if (a.startsWith('--out=')) opt.out = a.slice(6)
else pos.push(a)
}
return { dir: pos[0], opt }
}
/** Find the build output. The given dir may itself already be one. */
function findBuild(root, notes) {
if (existsSync(join(root, 'index.html')) && !existsSync(join(root, 'package.json'))) {
return { dir: root, why: 'given directory is already a built site' }
}
for (const cand of BUILD_DIRS) {
const d = join(root, cand)
if (existsSync(join(d, 'index.html'))) return { dir: d, why: cand }
}
// A bare index.html next to package.json is usually a dev template (script
// pointing at /src/main.tsx), which will not render. Say so rather than trying.
if (existsSync(join(root, 'index.html'))) {
const html = readFileSync(join(root, 'index.html'), 'utf8')
if (/src=["']\/?src\//.test(html)) {
notes.push('Found index.html but it is a dev template pointing at /src — needs a build or a dev server.')
} else {
return { dir: root, why: 'index.html at project root' }
}
}
return null
}
/**
* Index every file under the build dir ONCE, as url-path -> absolute-path.
*
* The request handler then only ever *looks up* a key in this map, so no part of
* a request string is used to build a filesystem path. That removes the class of
* bug entirely rather than trying to sanitise it: there is no path arithmetic on
* user input left to get wrong, and CodeQL's "uncontrolled data in a path
* expression" no longer has a flow to report because every path handed to fs
* originates from our own readdir walk.
*/
function indexBuildFiles(rootDir) {
const root = resolve(rootDir)
const byUrl = new Map()
const walk = (dir, prefix) => {
let entries
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
if (entry.name.startsWith('.')) continue
const abs = join(dir, entry.name)
const url = prefix + '/' + entry.name
if (entry.isDirectory()) walk(abs, url)
else if (entry.isFile()) byUrl.set(url, abs)
}
}
walk(root, '')
return byUrl
}
function startServer(rootDir, isSpa) {
const byUrl = indexBuildFiles(rootDir)
const shell = byUrl.get('/index.html') || null
return new Promise((res) => {
const srv = createServer((req, response) => {
let urlPath
try {
urlPath = decodeURIComponent((req.url || '/').split('?')[0])
} catch {
response.writeHead(400).end('bad request')
return
}
// Lookups only — never a join/resolve against urlPath.
let file = byUrl.get(urlPath)
|| byUrl.get(urlPath.replace(/\/+$/, '') + '/index.html')
|| (urlPath === '/' ? shell : undefined)
// SPA client-side routes have no file on disk — fall back to the shell.
if (!file && isSpa && !extname(urlPath)) file = shell || undefined
if (!file) { response.writeHead(404).end('not found'); return }
try {
const body = readFileSync(file)
response.writeHead(200, { 'content-type': MIME[extname(file).toLowerCase()] || 'application/octet-stream' })
response.end(body)
} catch {
response.writeHead(500).end('error')
}
})
// Loopback only, ephemeral port.
srv.listen(0, '127.0.0.1', () => res({ srv, port: srv.address().port }))
})
}
/** Static HTML files under a build dir, as fallback "routes". */
function htmlFiles(dir, base = '', depth = 0, acc = []) {
if (depth > 3) return acc
for (const name of readdirSync(dir)) {
if (name.startsWith('.') || name === 'assets' || name === 'node_modules') continue
const full = join(dir, name)
if (statSync(full).isDirectory()) htmlFiles(full, base + '/' + name, depth + 1, acc)
else if (extname(name) === '.html') acc.push(base + '/' + name)
}
return acc
}
/**
* Find a modal / banner / gate covering the page. A fresh browser profile has no
* stored state, so first-run onboarding, cookie banners and login walls appear on
* EVERY route — capturing them yields N screenshots of one overlay and none of the
* actual screens.
*/
function findOverlay(page) {
return page.evaluate(() => {
const vw = innerWidth, vh = innerHeight, viewport = vw * vh
const explicit = document.querySelector('[aria-modal="true"], [role="dialog"], [role="alertdialog"], dialog[open]')
const consider = explicit ? [explicit] : Array.from(document.body?.querySelectorAll('*') || [])
let best = null
for (const el of consider) {
const cs = getComputedStyle(el)
if (!explicit && cs.position !== 'fixed' && cs.position !== 'absolute') continue
if (cs.visibility === 'hidden' || cs.display === 'none' || cs.opacity === '0') continue
const r = el.getBoundingClientRect()
const area = (r.width * r.height) / viewport
if (area < 0.05 || area > 1.6) continue
const z = parseInt(cs.zIndex || '0', 10) || 0
if (!explicit && z < 10) continue
const text = (el.innerText || '').replace(/\s+/g, ' ').trim()
if (!text) continue
const score = (explicit ? 1e6 : 0) + z * 100 + area * 10
if (!best || score > best.score) {
best = { score, z, area: +area.toFixed(3), text: text.slice(0, 140), explicit: !!explicit }
}
}
return best
}).catch(() => null)
}
const sig = (o) => (o && o.text ? o.text.slice(0, 60).toLowerCase() : '')
async function main() {
const { dir: dirArg, opt } = parseArgs(process.argv.slice(2))
if (!dirArg) {
console.error('usage: node capture-build.mjs <project-dir> [--routes=/,/a,/b] [--out=dir] [--full]')
process.exit(1)
}
const root = isAbsolute(dirArg) ? dirArg : resolve(process.cwd(), dirArg)
if (!existsSync(root)) { console.error(`capture-build: not found: ${root}`); process.exit(2) }
const notes = []
const found = findBuild(root, notes)
if (!found) {
console.log(JSON.stringify({
buildDir: null, usableForVisualCritique: false, askUserFor: 'screenshots or a Figma link',
screens: [], skipped: [], blockedBy: null, notes: notes.concat([
'No built output found. Looked for: ' + BUILD_DIRS.join(', ') + '.',
'Nothing can be rendered from source alone — ask the user to build it, or for a running URL, or for screenshots.',
]),
}, null, 2))
return
}
const outDir = opt.out ? (isAbsolute(opt.out) ? opt.out : resolve(process.cwd(), opt.out)) : tmpdir()
mkdirSync(outDir, { recursive: true })
// Routes with :params can't be rendered meaningfully — skip them, don't fake them.
const skipped = []
let routes = opt.routes
if (routes) {
routes = routes.filter(r => {
if (/[:*]/.test(r)) { skipped.push({ route: r, why: 'needs a real parameter value' }); return false }
return true
})
}
const isSpa = !!routes && routes.length > 0
if (!routes || !routes.length) {
routes = htmlFiles(found.dir)
if (!routes.length) routes = ['/']
notes.push('No --routes given; captured the HTML files present in the build.')
}
const { srv, port } = await startServer(found.dir, isSpa)
const base = `http://127.0.0.1:${port}`
const pw = await getPlaywright()
if (!pw) {
srv.close()
console.error('capture-build: Playwright unavailable — cannot render.')
process.exit(3)
}
const browser = await pw.chromium.launch({ channel: 'chrome' }).catch(() => pw.chromium.launch())
const screens = []
try {
for (const route of routes) {
const page = await browser.newPage({ viewport: { width: opt.width, height: opt.height } })
const url = base + (route.startsWith('/') ? route : '/' + route)
let chars = 0
try {
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 })
await page.waitForTimeout(350)
chars = await page.evaluate(() => (document.body?.innerText || '').replace(/\s+/g, ' ').trim().length).catch(() => 0)
const overlay = await findOverlay(page)
const safe = route.replace(/[^\w.-]+/g, '_').replace(/^_+|_+$/g, '') || 'index'
const outPath = join(outDir, `build-${safe}-${Date.now()}.png`)
await page.screenshot({ path: outPath, fullPage: opt.full })
// Same rule as render.mjs: a blank page is NOT a seen screen.
if (chars < 8) skipped.push({ route, why: 'rendered blank (' + chars + ' chars) — not counted as seen' })
else screens.push({ route, path: outPath, chars, overlay: overlay || null })
} catch (err) {
skipped.push({ route, why: (err && err.message ? err.message : String(err)).slice(0, 120) })
} finally {
await page.close()
}
}
} finally {
await browser.close()
srv.close()
}
// If the SAME overlay sits on most screens, these captures are not usable as a
// visual critique — they all show one gate. Say so and ask for another input.
let blockedBy = null
const withOverlay = screens.filter(s => s.overlay && sig(s.overlay))
if (withOverlay.length >= 2) {
const counts = new Map()
for (const s of withOverlay) {
const k = sig(s.overlay)
counts.set(k, (counts.get(k) || 0) + 1)
}
let topKey = '', topN = 0
for (const [k, n] of counts) if (n > topN) { topN = n; topKey = k }
if (screens.length && topN / screens.length >= 0.6) {
const one = withOverlay.find(s => sig(s.overlay) === topKey)
blockedBy = {
onScreens: topN,
ofScreens: screens.length,
coversViewport: one.overlay.area,
text: one.overlay.text,
likely: /cookie|consent|gdpr/i.test(one.overlay.text) ? 'cookie/consent banner'
: /sign in|log in|login|password|continue with/i.test(one.overlay.text) ? 'login wall'
: 'first-run / onboarding gate',
}
}
}
const out = {
buildDir: found.dir, spa: isSpa, served: base, screens, skipped, blockedBy,
notes: notes.concat([`Served ${found.why} over loopback http; no build or dev server was run.`]),
}
if (blockedBy) {
out.usableForVisualCritique = false
out.askUserFor = 'screenshots or a Figma link'
out.notes.push(
`BLOCKED: the same ${blockedBy.likely} covers ${blockedBy.onScreens} of ${blockedBy.ofScreens} ` +
`captured screens (~${Math.round(blockedBy.coversViewport * 100)}% of the viewport): ` +
`"${blockedBy.text.slice(0, 70)}…". A fresh browser profile has no stored state, so this gate ` +
`appears on every route.`,
'Do NOT critique these captures — every one shows the same gate, not the screen behind it. ' +
'Report the gate ONCE at most, then ask the user for screenshots of the real screens, or a Figma link.',
)
} else {
out.usableForVisualCritique = screens.length > 0
}
console.log(JSON.stringify(out, null, 2))
}
main().catch((e) => { console.error('capture-build: failed:', e?.message || e); process.exit(4) })