Product design skills

scripts/render.mjs

A supporting file of the design-critique skill, shown as source.

#!/usr/bin/env node
/**
 * render.mjs — render a renderable design to a PNG so the critic can SEE it.
 *
 * The Design Critique skill calls this, then reads the PNG (fs_read) to run a
 * real *visual* critique. Reading source code is never a visual evaluation —
 * this is what turns "code" input into pixels.
 *
 * Usage:
 *   node render.mjs <input> [outPath] [--width=1280] [--height=900] [--full] [--wait=ms]
 *
 *   <input>   a URL (http/https) OR a local file path (.html, .htm, .svg)
 *   outPath   defaults to a temp file; the resolved path is printed on the last stdout line
 *
 * Strategy: prefer Playwright (channel: 'chrome' — uses installed Chrome, no
 * browser download; full-page + network-idle waiting). Fall back to headless
 * Chrome's built-in --screenshot when Playwright isn't installed.
 */
import { spawnSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { resolve, isAbsolute } from 'node:path'
import { pathToFileURL } from 'node:url'
import { getPlaywright } from './ensure-playwright.mjs'

function parseArgs(argv) {
  const pos = []
  const opt = { width: 1280, height: 900, full: false, wait: 0, allowBlank: false }
  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 === '--allow-blank') opt.allowBlank = true
    else if (a.startsWith('--wait=')) opt.wait = parseInt(a.slice(7), 10)
    else pos.push(a)
  }
  return { input: pos[0], out: pos[1], opt }
}

function toTarget(input) {
  if (/^https?:\/\//i.test(input)) return input
  const abs = isAbsolute(input) ? input : resolve(process.cwd(), input)
  if (!existsSync(abs)) {
    console.error(`render: input file not found: ${abs}`)
    process.exit(2)
  }
  return pathToFileURL(abs).href
}

function chromeBinary() {
  const candidates = [
    '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
    '/Applications/Chromium.app/Contents/MacOS/Chromium',
    '/usr/bin/google-chrome',
    '/usr/bin/chromium',
    '/usr/bin/chromium-browser',
  ]
  return candidates.find(existsSync) || null
}

// A screenshot of a blank page is worse than no screenshot: the critic can't tell
// and will "critique" nothing. Inspect the live DOM before trusting the PNG.
async function inspectEmptiness(page) {
  return page.evaluate(() => {
    const text = (document.body?.innerText || '').replace(/\s+/g, ' ').trim()
    let painted = 0
    for (const el of Array.from(document.body?.querySelectorAll('*') || [])) {
      const r = el.getBoundingClientRect()
      if (r.width >= 8 && r.height >= 8) painted++
      if (painted > 12) break
    }
    return { textLen: text.length, painted, sample: text.slice(0, 80) }
  }).catch(() => ({ textLen: -1, painted: -1, sample: '' }))
}

async function renderWithPlaywright(target, out, opt) {
  const pw = await getPlaywright()
  if (!pw) return { ok: false } // not available and couldn't install — signal fallback
  const chromium = pw.chromium
  const browser = await chromium.launch({ channel: 'chrome' }).catch(() => chromium.launch())
  try {
    const page = await browser.newPage({ viewport: { width: opt.width, height: opt.height } })
    // Track assets that never arrived — the usual cause of a white SPA shell.
    const deadAssets = []
    page.on('requestfailed', (r) => { if (deadAssets.length < 6) deadAssets.push(r.url()) })
    page.on('response', (r) => { if (r.status() >= 400 && deadAssets.length < 6) deadAssets.push(r.url() + ' → ' + r.status()) })
    await page.goto(target, { waitUntil: 'networkidle', timeout: 30000 }).catch(() => {})
    if (opt.wait) await page.waitForTimeout(opt.wait)
    const empty = await inspectEmptiness(page)
    await page.screenshot({ path: out, fullPage: opt.full })
    return { ok: true, empty, deadAssets }
  } finally {
    await browser.close()
  }
}

function renderWithChrome(target, out, opt) {
  const bin = chromeBinary()
  if (!bin) return false
  const args = [
    '--headless=new', '--disable-gpu', '--hide-scrollbars', '--no-sandbox',
    `--window-size=${opt.width},${opt.height}`,
    `--screenshot=${out}`,
    target,
  ]
  const r = spawnSync(bin, args, { stdio: 'ignore', timeout: 45000 })
  return r.status === 0 && existsSync(out)
}

async function main() {
  const { input, out: outArg, opt } = parseArgs(process.argv.slice(2))
  if (!input) {
    console.error('usage: node render.mjs <url|file> [outPath] [--width=] [--height=] [--full] [--wait=ms]')
    process.exit(1)
  }
  const target = toTarget(input)
  const out = outArg
    ? (isAbsolute(outArg) ? outArg : resolve(process.cwd(), outArg))
    : resolve(tmpdir(), `design-critique-${Date.now()}.png`)

  const res = await renderWithPlaywright(target, out, opt)
  let ok = res.ok
  let engine = 'playwright'
  if (!ok) { ok = renderWithChrome(target, out, opt); engine = 'chrome-headless' }

  if (!ok) {
    console.error('render: no renderer available. Install Playwright (`npm i playwright`) or Google Chrome.')
    process.exit(3)
  }

  // Refuse to hand back a blank page pretending to be a design.
  const e = res.empty
  const blank = e && e.textLen >= 0 && e.textLen < 8 && e.painted <= 1
  if (blank && !opt.allowBlank) {
    console.error('render: BLANK PAGE — nothing rendered (no text, no laid-out elements).')
    if (res.deadAssets && res.deadAssets.length) {
      console.error('render: assets that failed to load:')
      for (const u of res.deadAssets) console.error('  - ' + u)
    }
    if (/^file:\/\//.test(target)) {
      console.error('render: this looks like a built app opened from the filesystem. Absolute asset')
      console.error('render: paths (src="/assets/…") cannot resolve over file://. Serve the folder')
      console.error('render: over http instead — use capture-build.mjs, which does this for you.')
    }
    console.error('render: treat this screen as NOT SEEN. Do not critique it. (--allow-blank overrides.)')
    process.exit(5)
  }

  // Machine-readable last line: the screenshot path the critic should read.
  const note = blank ? ', blank (allowed)' : e && e.textLen > 0 ? `, ${e.textLen} chars visible` : ''
  console.error(`render: ok via ${engine} (${opt.width}x${opt.height}${opt.full ? ', full-page' : ''}${note})`)
  console.log(out)
}

main().catch((e) => { console.error('render: failed:', e?.message || e); process.exit(4) })