← Commit history
README.md+10
@@ -84,6 +84,16 @@ Refresh the sample data from the live wiki any time: ./refresh-data.sh      # needs the adom-wiki CLI, then restart the server ``` +## Generate++The Generate button (or `POST /generate`) renders the current hero server-side with+Puppeteer to the hero-standard 2000x1250 PNG, pops a toast and a preview modal in every+open studio, and writes `generated/latest.png`. Each PNG carries a `comment` metadata+chunk recording that hero-studio produced it, plus the exact studio state, so publish+tooling can verify a hero came through the studio rather than being hand-rolled slop.+The canonical flow for an agent: pose the studio, `POST /generate`, then+`adom-wiki page hero <owner>/<slug> --image generated/latest.png`.+ ## Drive it (AI-first)  State lives on the server; the UI, the CLI, and any agent stay in lockstep through a live
SKILL.md+22
@@ -46,6 +46,28 @@ bleed-right, bleed-top, bleed-corner-br, bleed-corner-tr, contained, mirror), `t The UI follows within 1 second of any POST (live channel plus poll fallback). Deep links: append `#contexts` or `#wikigrid` to the URL to land on the in-context previews. +## Generate the actual hero PNG (the point of the app)++Do NOT hand-roll a hero render: pose the studio via POST /state, then generate through the+studio so the PNG carries hero-studio provenance metadata and the user sees it happen+(toast + preview modal pop automatically in their open studio).++```bash+curl -s -X POST localhost:8846/generate        # blocks ~5-10s, returns JSON+# -> { ok, file, url, latest: "generated/latest.png", ... }+```++The output is the hero standard 2000x1250 PNG with a `comment` tEXt chunk recording the+generator, version, and full studio state. Then set it as the page hero and show the user:++```bash+adom-wiki page hero <owner>/<slug> --image <package-dir>/generated/latest.png+adom-cli hydrogen webview open-or-refresh --name "Wiki Page" --url "https://wiki.adom.inc/<owner>/<slug>" --panel-id <pane>+```++Requirements: puppeteer-core (env `PUPPETEER_CORE` if not in a standard location) and+Chrome (env `CHROME`, or the puppeteer cache). Failures return `{ok:false, error, hint}`.+ ## Refresh the dataset  ```bash
package.json+7−4
@@ -2,8 +2,8 @@   "slug": "hero-studio",   "title": "Hero Studio",   "type": "app",-  "version": "0.2.0",-  "description": "A live playground for the hero image v2 definition: the lower third of every hero is a true-text overlay (type pill, trending score, stars, title, author, wiki slug) rendered by the surface, never baked into the image. Flip screenshot bleeds, brand tile themes, gradient angles, and title placement on real wiki trending data, and see the result in pixel-faithful wiki contexts: the homepage marquee, the index page card grid, and an adom-desktop style right bar. Includes a component-hero mode: real photo, raytraced render, or phong 3D fallback ladder, name plus spec-line hierarchy, and MPN placement options.",+  "version": "0.3.0",+  "description": "A live playground for the hero image v2 definition: the lower third of every hero is a true-text overlay (type pill, trending score, stars, title, author, wiki slug) rendered by the surface, never baked into the image. Flip screenshot bleeds, brand tile themes, gradient angles, and title placement on real wiki trending data, and see the result in pixel-faithful wiki contexts: the homepage marquee, the index page card grid, and an adom-desktop style right bar. Includes a component-hero mode: real photo, raytraced render, or phong 3D fallback ladder, name plus spec-line hierarchy, and MPN placement options. Generate renders the posed hero server-side to a provenance-stamped 2000x1250 PNG via POST /generate.",   "license": "MIT",   "tags": [     "hero",@@ -29,7 +29,9 @@     "component hero",     "hero for a component",     "part photo hero",-    "mpn on the hero"+    "mpn on the hero",+    "generate the hero png",+    "render my hero"   ],   "discovery_pitch": "Reach for hero-studio when someone wants to design, preview, or debate a wiki hero image: it renders every layout, brand tile theme, and gradient angle live on real trending data, inside faithful copies of the wiki marquee and index card chrome.",   "sample_prompts": [@@ -71,6 +73,7 @@     "docs/contexts.png",     "docs/themes.png",     "docs/index-cards.png",-    "docs/component-mode.png"+    "docs/component-mode.png",+    "render-hero.mjs"   ] }\ No newline at end of file
page.json+7−4
@@ -2,8 +2,8 @@   "slug": "hero-studio",   "title": "Hero Studio",   "type": "app",-  "version": "0.2.0",-  "description": "A live playground for the hero image v2 definition: the lower third of every hero is a true-text overlay (type pill, trending score, stars, title, author, wiki slug) rendered by the surface, never baked into the image. Flip screenshot bleeds, brand tile themes, gradient angles, and title placement on real wiki trending data, and see the result in pixel-faithful wiki contexts: the homepage marquee, the index page card grid, and an adom-desktop style right bar. Includes a component-hero mode: real photo, raytraced render, or phong 3D fallback ladder, name plus spec-line hierarchy, and MPN placement options.",+  "version": "0.3.0",+  "description": "A live playground for the hero image v2 definition: the lower third of every hero is a true-text overlay (type pill, trending score, stars, title, author, wiki slug) rendered by the surface, never baked into the image. Flip screenshot bleeds, brand tile themes, gradient angles, and title placement on real wiki trending data, and see the result in pixel-faithful wiki contexts: the homepage marquee, the index page card grid, and an adom-desktop style right bar. Includes a component-hero mode: real photo, raytraced render, or phong 3D fallback ladder, name plus spec-line hierarchy, and MPN placement options. Generate renders the posed hero server-side to a provenance-stamped 2000x1250 PNG via POST /generate.",   "license": "MIT",   "tags": [     "hero",@@ -29,7 +29,9 @@     "component hero",     "hero for a component",     "part photo hero",-    "mpn on the hero"+    "mpn on the hero",+    "generate the hero png",+    "render my hero"   ],   "discovery_pitch": "Reach for hero-studio when someone wants to design, preview, or debate a wiki hero image: it renders every layout, brand tile theme, and gradient angle live on real trending data, inside faithful copies of the wiki marquee and index card chrome.",   "sample_prompts": [@@ -72,7 +74,8 @@     "docs/contexts.png",     "docs/themes.png",     "docs/index-cards.png",-    "docs/component-mode.png"+    "docs/component-mode.png",+    "render-hero.mjs"   ],   "org": "adom",   "author": {
public/adom-logo.svgadded+1
@@ -0,0 +1 @@+<?xml version="1.0" encoding="UTF-8"?><svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1072.9 236"><defs><style>.cls-1{fill:#00b6af;stroke-width:0px;}</style></defs><path class="cls-1" d="M212.94,23.05c-25.84-25.84-62.87-30.69-82.73-10.84-8.06,8.06-12.04,18.94-12.21,30.76h0s0,.02,0,.02c0,.04,0,.08,0,.12v73.29c0,.89.72,1.61,1.61,1.61h72.5l-.02-.02c12.17.03,23.42-3.94,31.69-12.2,19.85-19.85,15-56.89-10.84-82.73Z"/><path class="cls-1" d="M23.05,212.95c25.84,25.84,62.87,30.69,82.73,10.84,8.06-8.06,12.04-18.94,12.21-30.76h0s0-.02,0-.02c0-.04,0-.08,0-.12v-73.29c0-.89-.72-1.61-1.61-1.61H43.89l.02.02c-12.17-.03-23.42,3.94-31.69,12.2-19.85,19.85-15,56.89,10.84,82.73Z"/><path class="cls-1" d="M1065.5,25.22h-58.49c-2.98,0-5.68,1.87-6.73,4.65l-44.4,117.54c-.06.17-.31.17-.37,0l-45.44-117.59c-1.07-2.75-3.76-4.6-6.72-4.6h-58.79c-4.09,0-7.4,3.31-7.4,7.4v35.06l-.03-.12c-1.24,4.99-5.89,6-5.89,6,0,0-4.51,1.02-7.61-2.53v.03c-17.82-29.81-50.31-48.93-89.04-48.93-41.5,0-75.86,21.85-92.76,55.23v-.07c-2.86,5.19-8.74,5.13-8.74,5.13,0,0-5.63-.01-8.41-4.61-16.49-32.68-50.56-52.59-94.79-52.59h-73.46c-4.09,0-7.4,3.31-7.4,7.4v121.27s0,.03,0,.04c-.95,5.15-4.75,5.94-4.75,5.94,0,0-3.88.85-6.77-3.32l.05.23-46.05-126.83c-1.03-2.84-3.75-4.74-6.77-4.74h-57.14c-3.01,0-5.73,1.9-6.76,4.73l-68.67,188.38c-1.05,2.88-.18,6.21,2.4,8.19,1.31,1.01,2.98,1.47,4.64,1.47h34.96s6.43,0,6.43,0c2.5,0,4.83-1.39,5.9-3.65,8.49-17.86,26.5-29.64,48.37-29.64s39.71,11.73,48.19,29.53c1.1,2.32,3.49,3.76,6.06,3.76h120.78c31.22,0,57.85-9.95,77.02-28.77,7.38-7.25,13.43-15.72,18.05-25.1v.09c2.74-5.19,8.37-5.13,8.37-5.13,0,0,5.43,0,8.08,4.64,4.88,9.88,11.26,18.78,18.91,26.42,18.6,18.6,44.61,29.78,74.26,29.78,38.74,0,71.23-19.18,89.05-49.07l-.02.07c3.1-3.55,7.61-2.53,7.61-2.53,0,0,4.65,1,5.89,6l.03-.12v36.32c0,4.09,3.31,7.4,7.4,7.4h45.79c4.09,0,7.4-3.31,7.4-7.4v-86.26c0-.23.32-.28.39-.06l30.37,88.86c1,2.91,3.74,4.87,6.81,4.87h39.69c3.08,0,5.82-1.96,6.81-4.87l30.1-88.02c.07-.21.39-.16.39.06v85.43c0,4.09,3.31,7.4,7.4,7.4h45.79c4.09,0,7.4-3.31,7.4-7.4V32.62c0-4.09-3.31-7.4-7.4-7.4ZM372.31,147.57c-.15.21-.5.57-1.1.57h-30.33c-.61,0-.95-.35-1.1-.56-.15-.21-.38-.65-.19-1.23l15.06-45.79c.27-.81.92-.94,1.29-.94h0c.37,0,1.02.12,1.29.93l15.26,45.8c.19.58-.04,1.01-.19,1.23ZM525.72,173.84h-14.52c-.86,0-1.56-.7-1.56-1.56v-92.16c0-.86.7-1.56,1.56-1.56h14.52c30.81,0,48.48,17.47,48.48,47.92s-17.67,47.36-48.48,47.36ZM734.6,173.26c-26.51,0-42.33-17.71-42.34-47.36,0-14.59,3.88-26.68,11.21-34.98,7.33-8.29,18.09-12.67,31.13-12.67,12.11,0,22.28,3.84,29.58,11.13.5.5.98,1.01,1.45,1.54,7.4,8.33,11.31,20.42,11.31,34.97,0,29.21-16.22,47.36-42.34,47.36Z"/></svg>\ No newline at end of file
public/index.html+68−5
@@ -177,9 +177,13 @@ label.chk input { accent-color:var(--accent); width:14px; height:14px; cursor:po   position:absolute; left:5.75cqw; top:8.5cqw; width:33cqw; z-index:3; } .hero[data-layout=mirror] .textzone { left:auto; right:5.75cqw; }-.wordmark { display:flex; align-items:center; gap:.7cqw; margin-bottom:2.2cqw; }-.wordmark svg { width:1.15cqw; height:1.15cqw; stroke:#e6edf3; stroke-width:2; fill:none; }-.wordmark span { font-family:'Familjen Grotesk'; font-weight:700; font-size:1.05cqw; letter-spacing:.34em; color:#e6edf3; }+.wordmark { display:flex; align-items:center; margin-bottom:2.2cqw; }+/* the official Adom wordmark (adom-logo.svg), recolored white for dark heroes via mask */+.wordmark .wmark {+  display:block; width:7.8cqw; height:1.72cqw; background:#e6edf3;+  -webkit-mask:url('adom-logo.svg') no-repeat left center / contain;+          mask:url('adom-logo.svg') no-repeat left center / contain;+} .h-title {   font-family:'Familjen Grotesk'; font-weight:700; font-size:4.1cqw; line-height:1.02;   letter-spacing:-.09cqw; color:#e6edf3; margin-bottom:1.4cqw;@@ -368,6 +372,25 @@ body[data-mode=component] .mode-app { display:none; } .deskmock { background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:10px; display:flex; flex-direction:column; gap:10px; } .deskhead { font-family:'Familjen Grotesk'; font-weight:500; font-size:11px; letter-spacing:.07em; text-transform:uppercase; color:var(--text-2); } +/* render mode: ?render=1 strips all chrome for the server-side PNG shot */+body[data-render] header, body[data-render] #panel, body[data-render] #contexts,+body[data-render] #toasts, body[data-render] .guide { display:none !important; }+body[data-render] main, body[data-render] #stage { display:block; padding:0; overflow:hidden; }+body[data-render] #hero { width:1600px; max-width:none; aspect-ratio:auto; height:1000px; border-radius:0; border:none; box-shadow:none; }++/* generated-hero preview modal */+#genmodal { position:fixed; inset:0; z-index:99997; background:rgba(5,9,14,.8); display:none; align-items:center; justify-content:center; padding:32px; }+#genmodal.show { display:flex; }+.genbox { max-width:min(1000px, 92vw); background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:14px; box-shadow:0 24px 80px rgba(0,0,0,.6); }+.genbox img { display:block; width:100%; border-radius:8px; border:1px solid var(--border); }+.genrow { display:flex; align-items:center; gap:10px; margin-top:10px; }+.genrow .hint { margin:0; flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }+.genrow a { color:var(--accent); font-size:13px; text-decoration:none; }+.genrow a:hover { color:var(--accent-bright); }+.btn-primary { border-color:var(--accent); background:rgba(0,184,176,.12); font-weight:500; }+.btn-primary:hover { background:rgba(0,184,176,.2); }+.btn-primary:disabled { opacity:.55; cursor:wait; }+ /* tooltip + toast */ #tip {   position:fixed; z-index:99999; background:var(--overlay); border:1px solid var(--border);@@ -494,6 +517,7 @@ body[data-mode=component] .mode-app { display:none; }     <label class="chk"><input type="checkbox" id="chk-scores"> Trending + stars (dynamic)</label>     <label class="chk"><input type="checkbox" id="chk-author"> Author</label>     <label class="chk"><input type="checkbox" id="chk-slug"> Wiki slug, lower right</label>+    <button class="btn btn-primary" id="btn-generate" data-tip="Render the current hero to a 2000x1250 PNG on the server, with provenance metadata">Generate hero PNG</button>     <button class="btn" id="btn-reset">Reset to proposed default</button>   </div> @@ -503,7 +527,7 @@ body[data-mode=component] .mode-app { display:none; }        <div class="textzone">         <div class="wordmark" id="el-logo">-          <svg viewBox="0 0 24 24"><path d="M12 3 3 21h18z"/></svg><span>ADOM</span>+          <span class="wmark"></span>         </div>         <div class="h-title" id="el-title"></div>         <div class="h-sub" id="el-sub"></div>@@ -512,7 +536,7 @@ body[data-mode=component] .mode-app { display:none; }        <div class="comp-side"><div class="comp-img"></div></div>       <div class="ctextzone">-        <div class="wordmark"><svg viewBox="0 0 24 24"><path d="M12 3 3 21h18z"/></svg><span>ADOM</span></div>+        <div class="wordmark"><span class="wmark"></span></div>         <div class="c-chips">           <span class="c-family" id="c-family"></span>           <span class="c-family c-mpnchip" id="c-mpn-chip"></span>@@ -606,8 +630,20 @@ body[data-mode=component] .mode-app { display:none; }  <div id="tip"></div> <div id="toasts"></div>+<div id="genmodal">+  <div class="genbox">+    <img id="gen-img" alt="Generated hero">+    <div class="genrow">+      <div class="hint" id="gen-path"></div>+      <a id="gen-open" target="_blank" href="#">Open full size</a>+      <a href="#" id="gen-close">Close</a>+    </div>+  </div>+</div>  <script>+if (new URLSearchParams(location.search).has('render')) document.body.dataset.render = '1';+ /* console relay: the AI reads GET /console instead of DevTools */ (function () {   const post = (level, args) => fetch('console', { method:'POST', headers:{'Content-Type':'application/json'},@@ -866,6 +902,30 @@ $('btn-rand').addEventListener('click', () => {     toast('Randomized: ' + p.title, 'info');   } });+let seenGenSeq = 0;+function showGenerated(g) {+  $('gen-img').src = g.url + '?t=' + g.ts;+  $('gen-path').textContent = g.file;+  $('gen-open').href = g.url;+  $('genmodal').classList.add('show');+}+/* fires for local clicks AND for API-triggered generates (SSE or poll), deduped by seq */+function handleGen(g) {+  if (g && g.ok && g.seq > seenGenSeq) { seenGenSeq = g.seq; showGenerated(g); }+}+$('gen-close').addEventListener('click', e => { e.preventDefault(); $('genmodal').classList.remove('show'); });+$('genmodal').addEventListener('click', e => { if (e.target === $('genmodal')) $('genmodal').classList.remove('show'); });+$('btn-generate').addEventListener('click', () => {+  const b = $('btn-generate');+  b.disabled = true; b.textContent = 'Generating...';+  toast('Rendering the hero on the server', 'info');+  fetch('generate', { method: 'POST' }).then(r => r.json()).then(g => {+    b.disabled = false; b.textContent = 'Generate hero PNG';+    if (g.ok) handleGen(g); // success toast arrives via the live channel+    else toast('Generate failed: ' + (g.error || 'unknown') + (g.hint ? '. ' + g.hint : ''), 'error');+  }).catch(() => { b.disabled = false; b.textContent = 'Generate hero PNG'; toast('Generate failed: network error', 'error'); });+});+ $('btn-reset').addEventListener('click', () => {   send({ mode:'app', layout:'bleed-bottom', theme:'midnight', titleMode:'overlay-only', gradAngle:155,     comp:{ id:'sm02b-srss-tb', imgSrc:'photo', layout:'photo-right', mpn:'under-title' },@@ -909,6 +969,7 @@ Promise.all([   comps = cp;   state = st;   seenToastSeq = (st._toast && st._toast.seq) || 0; // don't replay a stale toast on load+  seenGenSeq = (st._gen && st._gen.seq) || 0;       // nor a stale generated modal   const sel = $('sel-page');   sel.innerHTML = pages.map(p =>     '<option value="' + p.owner + '/' + p.slug + '">' + p.title + ' (' + p.type + ')</option>').join('');@@ -923,10 +984,12 @@ Promise.all([   const es = new EventSource('events');   es.addEventListener('state', e => { state = JSON.parse(e.data); render(); });   es.addEventListener('toast', e => { handleToast(JSON.parse(e.data)); });+  es.addEventListener('generated', e => { handleGen(JSON.parse(e.data)); });   // poll fallback in case the proxy buffers the event stream   setInterval(() => {     fetch('state').then(r => r.json()).then(st => {       handleToast(st._toast);+      handleGen(st._gen);       if (JSON.stringify(st) !== JSON.stringify(state)) { state = st; render(); }     }).catch(()=>{});   }, 1000);
render-hero.mjsadded+51
@@ -0,0 +1,51 @@+// Renders the studio's ?render=1 view to a PNG. Usage: node render-hero.mjs <url> <out.png>+// Needs puppeteer-core (PUPPETEER_CORE env or a known location) and Chrome (CHROME env or+// the puppeteer cache). Exits nonzero with a plain message when either is missing.+import { existsSync, readdirSync } from 'fs';+import { homedir } from 'os';++const [,, url, out] = process.argv;++async function loadPuppeteer() {+  const candidates = [+    process.env.PUPPETEER_CORE,+    '/home/adom/project/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js',+    homedir() + '/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js',+  ].filter(Boolean);+  for (const c of candidates) {+    try { return (await import(c)).default; } catch {}+  }+  try { return (await import('puppeteer-core')).default; } catch {}+  throw new Error('puppeteer-core not found. Set PUPPETEER_CORE to its puppeteer-core.js path.');+}++function findChrome() {+  if (process.env.CHROME && existsSync(process.env.CHROME)) return process.env.CHROME;+  const base = homedir() + '/.cache/puppeteer/chrome';+  if (existsSync(base)) {+    for (const d of readdirSync(base).sort().reverse()) {+      const p = base + '/' + d + '/chrome-linux64/chrome';+      if (existsSync(p)) return p;+    }+  }+  throw new Error('Chrome not found. Set CHROME to a Chrome/Chromium binary path.');+}++const puppeteer = await loadPuppeteer();+const browser = await puppeteer.launch({+  executablePath: findChrome(), headless: 'new',+  args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage', '--hide-scrollbars',+         '--font-render-hinting=none', '--force-color-profile=srgb'],+});+try {+  const page = await browser.newPage();+  await page.setViewport({ width: 1680, height: 1080, deviceScaleFactor: 2 });+  await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });+  await new Promise(r => setTimeout(r, 1200)); // fonts + images settle+  const hero = await page.$('#hero');+  const box = await hero.boundingBox();+  await page.screenshot({ path: out, clip: box });+  console.log('OK ' + out);+} finally {+  await browser.close();+}
server.js+82−3
@@ -1,7 +1,8 @@-// Hero Studio server: state, SSE live channel, console relay, toast hook, static UI.+// Hero Studio server: state, SSE live channel, console relay, toast hook, generate API, static UI. const http = require('http'); const fs = require('fs'); const path = require('path');+const { spawn, execFile } = require('child_process');  const PORT = 8846; const ROOT = path.join(__dirname, 'public');@@ -23,8 +24,12 @@ let state = {  const consoleBuf = []; const clients = new Set();-let toastSeq = 0;+// seq counters seed from the clock so a server restart never replays or masks events+let toastSeq = Date.now(); let lastToast = null;+let genSeq = Date.now();+let lastGen = null;+let generating = false;  function broadcast(event, data) {   const msg = 'event: ' + event + '\ndata: ' + JSON.stringify(data) + '\n\n';@@ -66,7 +71,7 @@ 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 })));+    return res.end(JSON.stringify(Object.assign({}, state, { _toast: lastToast, _gen: lastGen })));   }   if (p === '/state' && req.method === 'POST') {     return readBody(req, (err, body) => {@@ -121,6 +126,80 @@ const server = http.createServer((req, res) => {     return res.end(JSON.stringify(COMPONENTS));   } +  if (p === '/generate' && req.method === 'POST') {+    if (generating) {+      res.writeHead(200, { 'Content-Type': 'application/json' });+      return res.end(JSON.stringify({ ok: false, error: 'a generate is already running', hint: 'wait for it to finish, then retry' }));+    }+    generating = true;+    const slug = state.mode === 'component'+      ? ((COMPONENTS.find(c => c.id === state.comp.id) || COMPONENTS[0]).slug)+      : (state.page.split('/')[1] || 'hero');+    const ts = Date.now();+    const file = 'hero-' + slug + '-' + ts + '.png';+    const outDir = path.join(__dirname, 'generated');+    fs.mkdirSync(outDir, { recursive: true });+    const outFile = path.join(outDir, file);+    logLine('info', 'generate -> ' + file);+    const child = spawn(process.execPath, [path.join(__dirname, 'render-hero.mjs'), 'http://localhost:' + PORT + '/?render=1', outFile], {+      env: process.env, stdio: ['ignore', 'pipe', 'pipe'],+    });+    let errBuf = '';+    child.stderr.on('data', (c) => { errBuf += c; });+    const finish = (payload) => {+      generating = false;+      res.writeHead(200, { 'Content-Type': 'application/json' });+      res.end(JSON.stringify(payload));+    };+    const timer = setTimeout(() => { child.kill(); }, 60000);+    child.on('close', (code) => {+      clearTimeout(timer);+      if (code !== 0 || !fs.existsSync(outFile)) {+        logLine('error', 'generate failed: ' + errBuf.slice(-400));+        return finish({ ok: false, error: errBuf.trim().split('\n').pop() || 'render exited ' + code, hint: 'set CHROME and PUPPETEER_CORE env vars for the server process' });+      }+      // downscale to the hero standard 2000x1250 and stamp provenance metadata+      const meta = JSON.stringify({ generator: 'hero-studio', version: '0.3.0', ts, state: { mode: state.mode, page: state.page, comp: state.comp, theme: state.theme, layout: state.layout, gradAngle: state.gradAngle } });+      const py = 'import sys\n' ++        'from PIL import Image\n' ++        'from PIL.PngImagePlugin import PngInfo\n' ++        'f, meta = sys.argv[1], sys.argv[2]\n' ++        'im = Image.open(f)\n' ++        'im = im.resize((2000, round(2000 * im.size[1] / im.size[0])), Image.LANCZOS)\n' ++        'info = PngInfo(); info.add_text("comment", meta)\n' ++        'im.save(f, pnginfo=info)\n';+      execFile('python3', ['-c', py, outFile, meta], (err) => {+        if (err) return execFile('convert', [outFile, '-resize', '2000x', '-set', 'comment', meta, outFile], (err2) => {+          if (err2) logLine('warn', 'no python3/PIL or convert; keeping full-size PNG without metadata');+          afterFinalize();+        });+        afterFinalize();+      });+      function afterFinalize() {+        try { fs.copyFileSync(outFile, path.join(outDir, 'latest.png')); } catch {}+        genSeq += 1;+        lastGen = { seq: genSeq, ok: true, file, url: 'generated/' + file, latest: 'generated/latest.png', ts, slug };+        logLine('info', 'generated ' + file);+        broadcast('generated', lastGen);+        broadcast('toast', { text: 'Hero generated: ' + file, kind: 'success', seq: ++toastSeq });+        lastToast = { text: 'Hero generated: ' + file, kind: 'success', seq: toastSeq };+        finish(lastGen);+      }+    });+    return;+  }++  if (p.startsWith('/generated/')) {+    const gfile = path.normalize(p.slice('/generated/'.length)).replace(/^(\.\.[/\\])+/, '');+    const gfull = path.join(__dirname, 'generated', gfile);+    if (!gfull.startsWith(path.join(__dirname, 'generated'))) { res.writeHead(404); return res.end('not found'); }+    return fs.readFile(gfull, (err, buf) => {+      if (err) { res.writeHead(404); return res.end('not found'); }+      res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-cache' });+      res.end(buf);+    });+  }+   // static   let file = p === '/' ? '/index.html' : p;   file = path.normalize(file).replace(/^(\.\.[/\\])+/, '');