app
Hero Studio
Public Made by Adomby adom
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. Every hero wears its P
← Commit history
Publish 0.15.0
9 files changed
+168−31
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+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' });