← Commit history
SKILL.md+22−8
@@ -23,8 +23,13 @@ hero-studio pose <owner>/<slug> --surface webview|pup [--title "T"] [--brief "on ```  CONCURRENT STUDIOS: when the default port is mid-gate for a DIFFERENT page,-pose automatically starts a fresh instance on a free port and says so; two AI-threads can pose in parallel without clobbering each other. Every verb takes+pose automatically starts a fresh instance on a free port and says so; a+SECOND pose of the SAME page refuses with POSE_BUSY (exit 4) naming when the+open gate was posed: wait, --steal to take it over, or --port for a parallel+instance. Re-posing the same page PRESERVES staged vitrine content (a crashed+or timed-out gate never destroys a staged full-hero import); --fresh-vitrine+starts clean. With multiple Adom Desktops on one relay, pass --target <name>+(or set ADOM_TARGET); it rides every desktop call including the pup renderer. Every verb takes `--port N` (or HERO_STUDIO_PORT) to target an instance; the tab is named "Hero Studio :PORT" for non-default instances. `--type` takes a comma list for multi-type pages (app,service renders a clustered pill group).@@ -32,11 +37,11 @@ for multi-type pages (app,service renders a clustered pill group). `--surface` is REQUIRED: check the user's stored preference first with `adom-cli hydrogen probe` (window_mode: webview | pup) and pass that. Never open both surfaces for the same content. The pup path reuses a per-instance-session (navigate, not a duplicate window). When pup fails, pose does NOT-silently fall back to a webview the user didn't ask for: it prints what every-adom-desktop step actually said (PUP_OPEN_FAILED) and exits 3; fix the cause-(desktop app down, relay, approval/caller-identity gate) or re-run with---surface webview if the human agrees.+session (navigate, not a duplicate window). When pup fails, pose prints what every+adom-desktop step actually said (PUP_OPEN_FAILED: ambiguous target, relay+down, approval/caller-identity gate...) and then FALLS BACK to a webview tab+so the gate still reaches the human, telling you to tell them the surface+changed; pass --no-fallback for the strict exit-3 refusal instead.  `pose` starts the server if needed, registers the package with the studio (no restart needed), poses it, opens the "Hero Studio" webview tab with proxy mode off (proxy mode@@ -202,12 +207,21 @@ so every command lands in front of the human as you run it:  ```bash hero-studio set theme=aurora gradAngle=220     # or: set '{"layout":"showcase"}'+hero-studio set fontSize.title=9.5             # dot-paths build nested patches+hero-studio set --list                         # the valid enum tokens (also in GET /state as `enums`) hero-studio vitrine --file shot.png [--full] [--cover] [--chrome none|fake]-hero-studio state                              # read everything, incl. slotSize+hero-studio bg --file tile.png [--cover]       # YOUR brand texture as theme 'image'; composed text + linting still apply+hero-studio state                              # read everything, incl. slotSize + enums hero-studio toast "trying the showcase layout" --kind info hero-studio reroll                             # new procedural circuitry pattern ``` +Invalid enum values are REJECTED with the valid list (400 from /state); an+ok:true means the value really landed. state.lint is computed by the OPEN+studio page, so it is empty until a browser surface has rendered the pose;+once one is open, every gate finding (layout linter, full-import caveats,+band scan) mirrors there for the driving AI to fix before review.+ The vitrine's FAKE titlebar is auto-off whenever an imported screenshot is present: a real window shot carries its own chrome, and stacking the fake bar above it reads as fake (hero-studio#11). The CSS mock keeps the fake bar.
bin/hero-studio+1−1
@@ -7,6 +7,6 @@ DIR="$(cd "$(dirname "$SELF")/.." && pwd)" case "$1" in   pose) shift; exec node "$DIR/pose.mjs" "$@" ;;   doctor) shift; exec node "$DIR/doctor.mjs" "$@" ;;-  vitrine|set|state|toast|reroll|changes) exec node "$DIR/drive.mjs" "$@" ;;+  vitrine|set|state|toast|reroll|changes|bg) exec node "$DIR/drive.mjs" "$@" ;;   *) exec node "$DIR/server.js" "$@" ;; esac
drive.mjs+32−6
@@ -49,21 +49,47 @@ try {     console.log(JSON.stringify({ ok: true, bytes: out.bytes, slot: out.slot }));     console.error('vitrine loaded' + slot + '; the open studio updates within a second');   } else if (verb === 'set') {+    // --list: print the valid enum tokens (hero-studio#24 ask 2)+    if (args.includes('--list')) {+      const st = await jf('state');+      console.log(JSON.stringify({ enums: st.enums }, null, 2));+      process.exit(0);+    }     let patch = {};     if (args.length === 1 && args[0].trim().startsWith('{')) {       patch = JSON.parse(args[0]);     } else if (args.length) {       for (const kv of args) {-        const [k, v] = kv.split('=');-        if (!k || v === undefined) die('usage: hero-studio set \'{"theme":"aurora"}\' | theme=aurora gradAngle=200');-        patch[k] = /^-?\d+$/.test(v) ? Number(v) : v === 'true' ? true : v === 'false' ? false : v;+        const eq = kv.indexOf('=');+        if (eq < 1) die('usage: hero-studio set \'{"theme":"aurora"}\' | theme=aurora fontSize.title=9 | set --list');+        const k = kv.slice(0, eq);+        const v = kv.slice(eq + 1);+        const val = /^-?\d+(\.\d+)?$/.test(v) ? Number(v) : v === 'true' ? true : v === 'false' ? false : v;+        // dot-paths build nested patches (#24 ask 1: fontSize.title=9 used to+        // land as a literal "fontSize.title" key the page ignored)+        const parts = k.split('.');+        let node = patch;+        for (let i = 0; i < parts.length - 1; i++) node = node[parts[i]] = node[parts[i]] || {};+        node[parts[parts.length - 1]] = val;       }     } else {-      die('usage: hero-studio set \'{"theme":"aurora"}\' | theme=aurora gradAngle=200');+      die('usage: hero-studio set \'{"theme":"aurora"}\' | theme=aurora fontSize.title=9 | set --list');+    }+    const r = await fetch(BASE + 'state', {+      method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch),+    });+    const out = await r.json().catch(() => ({}));+    if (!r.ok || out.ok === false) {+      die('ERROR: ' + (out.error || 'state rejected') + (out.valid ? '\n  valid: ' + out.valid.join(' | ') : ''));     }-    const out = await jf('state', patch);     console.log(JSON.stringify({ ok: true, applied: patch }));-    void out;+  } else if (verb === 'bg') {+    const fi = args.indexOf('--file');+    if (fi < 0 || !args[fi + 1]) die('usage: hero-studio bg --file <image> [--cover]   (imports background art as theme "image")');+    const out = await jf('bg-file', { path: path.resolve(args[fi + 1]), cover: args.includes('--cover') });+    if (!out.ok) die('ERROR: ' + out.error);+    console.log(JSON.stringify({ ok: true, theme: 'image', bytes: out.bytes }));+    console.error('background art imported; theme switched to image (composed text, layouts, and the linter still apply)');   } else if (verb === 'changes') {     console.log(JSON.stringify(await jf('changes'), null, 2));   } else if (verb === 'state') {
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "hero-studio",   "title": "Hero Studio",   "type": "app",-  "version": "0.14.1",+  "version": "0.15.0",   "description": "The hero-image lab with a human gate: `hero-studio pose <owner/slug>` opens the studio in front of the human, posed for their package, and blocks until they click Generate, producing the provenance-stamped 2000x1250 PNG the registry's hero gate requires. The image carries the title and artwork; the exported image also bakes the type pill (top right) and full wiki slug (bottom right); the page shows the hero bare, and only the homepage marquee overlays its own dynamic info. Adom-org pages get the house wordmark and palette automatically; every other owner brings their own accent and background colors, with placement fixed for all. The floating app window is the vitrine: it auto-loads a real screenshot from the package's wiki page (screenshots/, docs/), takes a manual import, or a full-hero import (the human-ratified re-stamp path for existing heroes). The pose gate opens in verification mode: per-item checklist (title, subtitle, vitrine, type match) and a Generate that unlocks only fully confirmed. Includes vitrine bleeds, brand tile themes, gradient angles, pixel-faithful wiki marquee and index-card previews, and a component-hero mode with a photo/raytrace/phong fallback ladder.",   "license": "MIT",   "tags": [
page.json+1−1
@@ -2,7 +2,7 @@   "slug": "hero-studio",   "title": "Hero Studio",   "type": "app",-  "version": "0.14.1",+  "version": "0.15.0",   "description": "The hero-image lab with a human gate: `hero-studio pose <owner/slug>` opens the studio in front of the human, posed for their package, and blocks until they click Generate, producing the provenance-stamped 2000x1250 PNG the registry's hero gate requires. The image carries the title and artwork; the exported image also bakes the type pill (top right) and full wiki slug (bottom right); the page shows the hero bare, and only the homepage marquee overlays its own dynamic info. Adom-org pages get the house wordmark and palette automatically; every other owner brings their own accent and background colors, with placement fixed for all. The floating app window is the vitrine: it auto-loads a real screenshot from the package's wiki page (screenshots/, docs/), takes a manual import, or a full-hero import (the human-ratified re-stamp path for existing heroes). The pose gate opens in verification mode: per-item checklist (title, subtitle, vitrine, type match) and a Generate that unlocks only fully confirmed. Includes vitrine bleeds, brand tile themes, gradient angles, pixel-faithful wiki marquee and index-card previews, and a component-hero mode with a photo/raytrace/phong fallback ladder.",   "license": "MIT",   "tags": [
pose.mjs+36−11
@@ -7,8 +7,9 @@ // this and waits; it cannot continue without a human-made image. // // Exit codes: 0 generated, 2 timeout (no human generated), 1 setup error,-// 3 requested surface unavailable (pup failed; the reason is printed, and the-// gate deliberately does NOT fall back to a webview the user didn't ask for).+// 3 pup failed with --no-fallback set (default: diagnosed webview fallback so+// the gate still reaches the human), 4 POSE_BUSY (same page mid-gate; use+// --steal / --port). import { execFile, spawn } from 'child_process'; import { fileURLToPath } from 'url'; import path from 'path';@@ -65,14 +66,18 @@ const jf = async (p, opts) => {   if (!r.ok) throw new Error(p + ' -> ' + r.status);   return r.json(); };+// Multiple Adom Desktops on one relay need addressing (#24 ask 3):+// --target <name> or ADOM_TARGET rides every adom-desktop call.+const AD_TARGET = flags.target || process.env.ADOM_TARGET || '';+const adArgs = (a) => (AD_TARGET ? [...a, '--target', AD_TARGET] : a); const sh = (cmd, a, timeout = 20000) => new Promise((res) => {-  execFile(cmd, a, { timeout }, (err, stdout) => res(err ? null : stdout));+  execFile(cmd, cmd === 'adom-desktop' ? adArgs(a) : a, { timeout }, (err, stdout) => res(err ? null : stdout)); }); // Verbose variant: keeps the output and failure mode, so a pup failure can be // DIAGNOSED instead of swallowed (hero-studio#12: PUP_UNREACHABLE fired while // the same relay demonstrably worked, and nothing said why). const shv = (cmd, a, timeout = 20000) => new Promise((res) => {-  execFile(cmd, a, { timeout }, (err, stdout, stderr) => res({+  execFile(cmd, cmd === 'adom-desktop' ? adArgs(a) : a, { timeout }, (err, stdout, stderr) => res({     ok: !err,     out: String(stdout || ''),     errOut: String(stderr || ''),@@ -90,6 +95,16 @@ const portPinned = Boolean(flags.port || process.env.HERO_STUDIO_PORT); BASE = 'http://localhost:' + port + '/'; let state = null; try { state = await jf('state'); } catch {}+if (state && state.page === ref && state.verify+    && !(state.approved && state.approved.page === ref) && !('steal' in flags)) {+  // Same page, gate already open: a second pose would silently clobber the+  // first thread's staging (#24 ask 6). Refuse and NAME the holder.+  const since = state.posedAt ? new Date(state.posedAt).toISOString() : 'unknown time';+  console.error('ERROR [POSE_BUSY]: a gate for ' + ref + ' is already open on :' + port + ' (posed at ' + since + ').');+  console.error('  Options: wait for that review; re-run with --steal to take over the open gate');+  console.error('  (same staging, fresh checklist); or --port <n> for a parallel instance.');+  process.exit(4);+} if (state && !portPinned) {   const gateBusy = state.verify && state.page && state.page !== ref     && !(state.approved && state.approved.page === state.page);@@ -130,6 +145,10 @@ await jf('api/pages', {   ).filter(([, v]) => v))), }); const poseMode = (typeList[0] === 'component') ? 'component' : 'app';+const samePageRepose = !!(state && state.page === ref && state.vitrine && state.vitrine.src);+if (samePageRepose && !('fresh-vitrine' in flags)) {+  console.error('note: re-posing ' + ref + ' with staged vitrine content preserved (' + Math.round(state.vitrine.src.length / 1024) + 'kB' + (state.vitrine.full ? ', full-hero import' : '') + '); pass --fresh-vitrine to start clean');+} await jf('state', {   method: 'POST', headers: { 'Content-Type': 'application/json' },   body: JSON.stringify({@@ -139,11 +158,14 @@ await jf('state', {     confirm: { title: false, subtitle: false, vitrine: false, type: false },     flags: { title: false, subtitle: false, vitrine: false, type: false },     lint: [],-    // clear the previous package's vitrine so a failed auto-pull can never-    // leave sibling A's screenshot in sibling B's hero. Artwork state (theme,-    // layout, angle, brand) deliberately carries across poses: that IS the-    // platform-twin flow, one pose per sibling with only vitrine + slug new.-    vitrine: { src: '', full: false },+    posedAt: Date.now(),+    // Clear the previous package's vitrine so a failed auto-pull can never+    // leave sibling A's screenshot in sibling B's hero -- but PRESERVE staged+    // work when re-posing the SAME page (#24 ask 4: a crashed or timed-out+    // gate must not destroy a staged full-hero import). --fresh-vitrine+    // forces the clear. Artwork state (theme, layout, angle, brand) always+    // carries: that IS the platform-twin flow.+    ...(samePageRepose && !('fresh-vitrine' in flags) ? {} : { vitrine: { src: '', full: false } }),   }), }); @@ -158,8 +180,11 @@ try { } catch {}  // 2c. best-effort: pre-load a real screenshot from the package's wiki page-// into the vitrine, so the mock only shows when the page has no images-try { await jf('vitrine-from-page?page=' + ref, { method: 'POST' }); } catch {}+// into the vitrine, so the mock only shows when the page has no images.+// Skipped when a same-page re-pose preserved staged content (#24 ask 4).+if (!(samePageRepose && !('fresh-vitrine' in flags))) {+  try { await jf('vitrine-from-page?page=' + ref, { method: 'POST' }); } catch {}+}  // 3. open the studio IN FRONT OF THE HUMAN on the surface they actually use. const proxyBase = (process.env.VSCODE_PROXY_URI || '').replace('{{port}}', String(port));
public/index.html+19
@@ -89,6 +89,9 @@ select { } .swatch.on { outline:2px solid var(--accent); outline-offset:1px; } .sw-midnight { background:linear-gradient(155deg,#0a0e14,#0d1117 45%,#071318); }+.sw-image { background:repeating-conic-gradient(#1c2530 0 25%, #0d1117 0 50%) 0 0/8px 8px; }+.hero[data-theme=image] .bg { background-color:#0a0e14; background-image:var(--bgimg, none); background-repeat:repeat; background-size:var(--bgtile, 22cqw); }+.hero[data-theme=image].bg-cover .bg { background-repeat:no-repeat; background-size:cover; background-position:center; } .sw-pcb { background:linear-gradient(155deg,#03211f,#04302c); } .sw-onion { background:repeating-radial-gradient(circle at 90% 10%, #101a2c 0 4px, #16223a 4px 6px); } .sw-pill { background:#0c3a3c; }@@ -678,8 +681,11 @@ body[data-render] #hero { width:1600px; max-width:none; aspect-ratio:auto; heigh       <div class="swatch sw-aurora" data-v="aurora" data-tip="Aurora: layered soft radials"></div>       <div class="swatch sw-mesh" data-v="mesh" data-tip="Mesh: conic color fields, angle follows the gradient slider"></div>       <div class="swatch sw-circuitry" data-v="circuitry" data-tip="Circuitry: procedurally generated PCB traces, gently different every roll"></div>+      <div class="swatch sw-image" data-v="image" id="sw-image" data-tip="Imported background art: your own brand texture as the theme (tile or cover). Import below; composed text, layouts, and the linter all still apply"></div>     </div>     <button class="btn" id="btn-reroll" style="display:none" data-tip="Re-roll the procedural pattern">Reroll circuitry pattern</button>+    <input type="file" id="bg-file" accept="image/*" data-tip="Import background art for the image theme">+    <label class="chk"><input type="checkbox" id="bg-cover"> Cover (scale to fill) instead of tiling</label>      <h3>Gradient angle</h3>     <input type="range" id="rng-angle" min="0" max="360" step="5" value="155" data-tip="Rotate the base gradient so each hero feels unique while staying on-brand">@@ -957,6 +963,11 @@ function render() {   hero.dataset.imgsrc = state.comp.imgSrc;   hero.dataset.mpn = state.comp.mpn;   hero.classList.toggle('guides', !!state.guides);+  // imported background art (theme 'image', hero-studio#24 ask 7)+  const bg = state.bgImage || {};+  hero.style.setProperty('--bgimg', bg.src ? 'url(' + bg.src + ')' : 'none');+  hero.classList.toggle('bg-cover', !!bg.cover);+  $('bg-cover').checked = !!bg.cover;   // ?? not ||: an angle of 0 is a real choice, not an unset value   hero.style.setProperty('--ga', (state.gradAngle ?? 155) + 'deg');   $('rng-angle').value = state.gradAngle ?? 155;@@ -1389,6 +1400,14 @@ $('verifybar').addEventListener('click', e => {     handleToast({ text: why, kind: 'warn', seq: Date.now() });   } });+$('bg-file').addEventListener('change', e => {+  const f = e.target.files && e.target.files[0];+  if (!f) return;+  const r = new FileReader();+  r.onload = () => send({ bgImage: { src: r.result }, theme: 'image' });+  r.readAsDataURL(f);+});+$('bg-cover').addEventListener('change', e => send({ bgImage: { cover: e.target.checked } })); $('vitrine-file').addEventListener('change', e => {   const f = e.target.files && e.target.files[0];   if (!f) return;
render-pup.mjs+3−1
@@ -14,8 +14,10 @@ const [,, url, out] = process.argv; if (!url || !out) { console.error('usage: render-pup.mjs <url> <out.png>'); process.exit(1); }  const SESSION = 'hero-render';+// ADOM_TARGET addresses a specific desktop when several share the relay (#24)+const AD_TARGET = process.env.ADOM_TARGET || ''; const sh = (args, timeout = 20000) => new Promise((res) => {-  execFile('adom-desktop', args, { timeout }, (err, stdout, stderr) =>+  execFile('adom-desktop', AD_TARGET ? [...args, '--target', AD_TARGET] : args, { timeout }, (err, stdout, stderr) =>     res({ ok: !err, out: String(stdout || '') + String(stderr || '') })); }); const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
server.js+53−2
@@ -34,6 +34,7 @@ let state = {   marqueePreview: false,  // composite today's homepage-marquee overlay over the hero (preview only)   tour: false,            // verification walkthrough: spotlight title -> subtitle -> vitrine -> type   slotSize: null,         // measured vitrine slot in export px, posted back by the UI ({w,h})+  bgImage: { src: '', cover: false },  // imported background art for theme 'image' (hero-studio#24)   circuitSeed: Date.now() % 2147483647,  // procedural circuitry theme seed; /reroll re-rolls   fontSize: { title: 8, sub: 2.5, desc: 1.35 },  // cqw; author-adjustable, defaults tuned for dock legibility   approved: null,  // { page, file, ts } set on Generate: the persistent gate-closed record@@ -75,6 +76,16 @@ function notePoseDiff(before) {   } } +// The valid enum sets (hero-studio#24 asks 1+2): validated on every /state+// write and exposed on GET /state as `enums`, so a driving AI never has to+// read index.html to learn the tokens.+const ENUMS = {+  layout: ['bleed-bottom', 'bleed-right', 'bleed-top', 'bleed-corner-br', 'bleed-corner-tr', 'contained', 'mirror', 'showcase'],+  theme: ['midnight', 'pcb', 'onion', 'pill', 'grid', 'gradient', 'aurora', 'mesh', 'circuitry', 'image'],+  titleMode: ['overlay-only', 'image-only', 'both'],+  mode: ['app', 'component'],+};+ const consoleBuf = []; const clients = new Set(); // seq counters seed from the clock so a server restart never replays or masks events@@ -124,11 +135,17 @@ const server = http.createServer((req, res) => {    if (p === '/state' && req.method === 'GET') {     res.writeHead(200, { 'Content-Type': 'application/json' });-    return res.end(JSON.stringify(Object.assign({}, state, { _toast: lastToast, _gen: lastGen })));+    return res.end(JSON.stringify(Object.assign({}, state, { _toast: lastToast, _gen: lastGen, enums: ENUMS })));   }   if (p === '/state' && req.method === 'POST') {     return readBody(req, (err, body) => {       if (err) { res.writeHead(200); return res.end(JSON.stringify({ ok: false, error: 'bad json' })); }+      for (const [k, valid] of Object.entries(ENUMS)) {+        if (k in body && !valid.includes(body[k])) {+          res.writeHead(400, { 'Content-Type': 'application/json' });+          return res.end(JSON.stringify({ ok: false, error: 'invalid ' + k + ' \'' + body[k] + '\'', valid }));+        }+      }       const before = snapshotPose();       merge(state, body);       notePoseDiff(before);@@ -245,6 +262,25 @@ const server = http.createServer((req, res) => {       finish(200, { ok: true, bytes: buf.length, slot: state.slotSize });     });   }+  if (p === '/bg-file' && req.method === 'POST') {+    // Custom background art as a first-class theme (hero-studio#24 ask 7):+    // an established brand texture becomes theme 'image', so composed mode+    // (text, linting, layouts) keeps working over it.+    return readBody(req, (err, body) => {+      const finish = (code, payload) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(payload)); };+      if (err || !body || !body.path) return finish(400, { ok: false, error: 'path required' });+      let buf;+      try { buf = fs.readFileSync(body.path); } catch (e) { return finish(200, { ok: false, error: 'cannot read ' + body.path + ': ' + e.message }); }+      if (buf.length > 12 * 1024 * 1024) return finish(200, { ok: false, error: 'image too large (12 MB cap)' });+      const low = body.path.toLowerCase();+      const mime = low.endsWith('.webp') ? 'image/webp' : low.match(/\.jpe?g$/) ? 'image/jpeg' : 'image/png';+      state.bgImage = { src: 'data:' + mime + ';base64,' + buf.toString('base64'), cover: !!body.cover };+      state.theme = 'image';+      logLine('info', 'background <- ' + body.path + ' (' + Math.round(buf.length / 1024) + ' KB, theme=image)');+      broadcast('state', state);+      finish(200, { ok: true, bytes: buf.length, theme: 'image' });+    });+  }   if (p === '/reroll' && req.method === 'POST') {     state.circuitSeed = (state.circuitSeed * 48271 + Date.now()) % 2147483647;     broadcast('state', state);@@ -274,7 +310,22 @@ const server = http.createServer((req, res) => {         const lines = (await dr.text()).split('\n').map((x) => x.trim()).filter((x) => x && !x.startsWith('tree '));         for (const ln of lines) paths.push(dn + '/' + ln);       }-      const img = (re) => paths.find((x) => re.test(x.toLowerCase()) && !/hero/.test(x.toLowerCase()));+      // exclude the page's own hero however it is named: the declared+      // hero_path from the page API, plus hero/billboard name heuristics+      // (hero-studio#24 ask 5: hero-inside-hero, with no reprimand because a+      // src WAS loaded).+      let declaredHero = '';+      try {+        const pr = await fetch(REG + '/api/pages/' + ref, { headers });+        if (pr.ok) {+          const pj = await pr.json();+          declaredHero = ((pj.page || pj).hero_path || '').toLowerCase();+        }+      } catch {}+      const img = (re) => paths.find((x) => {+        const l = x.toLowerCase();+        return re.test(l) && !/hero|billboard/.test(l) && l !== declaredHero;+      });       // representative real UI shot: screenshots/ first, then docs/ images, then any image       const pick = img(/^screenshots\/.*\.(png|jpe?g|webp)$/) || img(/^docs\/.*\.(png|jpe?g|webp)$/) || img(/\.(png|jpe?g|webp)$/);       if (!pick) return finish(200, { ok: false, error: 'no non-hero image on the page' });