← Commit history
SKILL.md+13−1
@@ -245,7 +245,19 @@ hero-studio doctor  Run the printed fix commands yourself: you are already inside the container (no SSH, no outside access involved), and install already warned about this at-`pkg install` time. Typical light-container fix: `npm install puppeteer-core@24`+`pkg install` time. install.sh installs puppeteer-core itself when nothing+resolves, into `~/.cache/hero-studio` (OUTSIDE the package directory, which a+package update replaces node_modules and all; that is how a running studio+once lost its renderer between a passing doctor and the human's click,+hero-studio#58), pinned by this Node (24.x below 22.12). The gate shows a+renderer line under Generate, refreshed before every Generate and once a+minute: `renderer: local`, `via pup`, or RENDERER NOT READY with the exact+fix, and Generate refuses with `errorCode: RENDERER_NOT_READY` instead of+walking through two dead legs. Fixes take effect on the next click, no+restart. The same line says when a newer package is installed on disk under+this running server (`onDisk`), which only a restart picks up.+`npm run test:renderer` covers the pin, the cache lookup, the refusal and the+recovery. Typical light-container fix: `npm install puppeteer-core@24` (Node below 22.12 cannot use 25.x), `npx @puppeteer/browsers install chrome@stable --path ~/.cache/puppeteer`, then the apt library list doctor prints (note: `libasound2t64` on Ubuntu 24.04). Re-run doctor until it passes,
doctor.mjs+15−25
@@ -16,35 +16,26 @@ import { fileURLToPath } from 'url';  const DIR = path.dirname(fileURLToPath(import.meta.url)); -// ---- shared discovery (render-hero.mjs imports these; keep them in sync-proof) ------export function puppeteerCandidates() {-  return [-    process.env.PUPPETEER_CORE,-    path.join(DIR, 'node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'),-    '/home/adom/project/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js',-    homedir() + '/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js',-  ].filter(Boolean);-}+// ---- shared discovery: renderer-paths.cjs is the one list (hero-studio#58);+// render-hero.mjs and server.js read the same file ----+import { createRequire } from 'module';+const RP = createRequire(import.meta.url)('./renderer-paths.cjs');+export const puppeteerCandidates = RP.puppeteerCandidates;+export const puppeteerInstallCommand = RP.puppeteerInstallCommand;+export const quickRendererStatus = RP.quickStatus;  export async function loadPuppeteer() {   for (const c of puppeteerCandidates()) {     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.');+  if (!RP.ISOLATED) { try { return (await import('puppeteer-core')).default; } catch {} }+  throw new Error('puppeteer-core not found. Fix: ' + RP.puppeteerInstallCommand()); }  export 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 c = RP.chromePath();+  if (c) return c;+  throw new Error('Chrome not found. Set CHROME to a Chrome/Chromium binary path, or: ' + RP.chromeInstallCommand()); }  // ---- checks ----@@ -57,7 +48,7 @@ const run = (cmd, args, timeout = 15000) => new Promise((res) => { function puppeteerEngineFit() {   // puppeteer-core 25.x imports fine on Node 18 and then fails at launch, so   // the mismatch never surfaces where you would look. Check engines up front.-  for (const c of puppeteerCandidates().map((p) => p.replace(/lib\/puppeteer\/puppeteer-core\.js$/, 'package.json'))) {+  for (const c of RP.puppeteerPackages()) {     try {       const pkg = JSON.parse(readFileSync(c, 'utf8'));       const want = pkg.engines && pkg.engines.node;@@ -89,8 +80,7 @@ export async function runChecks() {     if (!eng.fit) engineWarn = `engines want node ${eng.want}; this is ${process.versions.node}`;     pass('puppeteer-core', 'resolves' + (eng.version ? ` (${eng.version})` : '') + (engineWarn ? `; warning: ${engineWarn}, trusting the launch check` : ''));   } catch (e) {-    fail('puppeteer-core', e.message,-      `cd ${DIR} && npm install puppeteer-core@24    # 24.x supports Node 18; 25.x wants >=22.12`);+    fail('puppeteer-core', e.message, RP.puppeteerInstallCommand());   }    // 2. a Chrome binary exists@@ -139,7 +129,7 @@ export async function runChecks() {     } catch (e) {       fail('headless launch', String(e && e.message || e).split('\n')[0],         engineWarn-          ? `cd ${DIR} && npm install puppeteer-core@24    # ${engineWarn}; the pin is the first fix to try`+          ? RP.puppeteerInstallCommand() + '    # ' + engineWarn + '; the pin is the first fix to try'           : 'read the launch error; check PUPPETEER_CORE/CHROME env overrides and OS libraries');     }   }
install.sh+17
@@ -7,6 +7,23 @@ echo "installed: hero-studio" echo "run 'hero-studio' (serves on port 8846), then open /proxy/8846/ in a webview" echo "refresh the sample data with ./refresh-data.sh (needs the adom-wiki CLI)" +# hero-studio#58: a package update replaces this directory, node_modules+# included, so puppeteer-core lives OUTSIDE it (~/.cache/hero-studio) and an+# update cannot take the renderer away from a running studio. Installed here+# when nothing resolves; the pin follows this Node (24.x below 22.12).+RH="$HOME/.cache/hero-studio"+if node -e "process.exit(require('$PWD/renderer-paths.cjs').puppeteerPath() ? 0 : 1)" 2>/dev/null; then+  echo "renderer dependency: puppeteer-core resolves"+else+  PIN="$(node -e "console.log(require('$PWD/renderer-paths.cjs').puppeteerPin())")"+  mkdir -p "$RH"+  if command -v npm >/dev/null 2>&1 && npm install --prefix "$RH" --no-audit --no-fund --loglevel=error "puppeteer-core@$PIN" >/dev/null 2>&1; then+    echo "renderer dependency: installed puppeteer-core@$PIN into $RH (outside the package dir; updates keep it)"+  else+    echo "!! renderer dependency: could not install puppeteer-core@$PIN; run: npm install --prefix $RH puppeteer-core@$PIN"+  fi+fi+ # Renderer preflight, warn mode: install stays exit 0 (driving the studio and # fixing deps later are legitimate on light containers), but the message must # say which renderer actually verified. "OK" while local Chrome was missing is
package.json+4−3
@@ -2,7 +2,7 @@   "slug": "hero-studio",   "title": "Hero Studio",   "type": "app",-  "version": "0.21.2",+  "version": "0.21.3",   "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. Every hero wears its PAGE OWNER's brand guide (<owner>/brand): colors, type, logo and background themes, Adom's included; an unresolved guide holds unbranded rather than borrowing Adom's look. Placement is fixed for all. The floating app window is the showcase: 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, showcase, type match) and a Generate that unlocks only fully confirmed. Includes showcase 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": [@@ -65,7 +65,8 @@     "install": "./install.sh",     "uninstall": "./uninstall.sh",     "test:brand": "node test/brand.mjs",-    "test:render": "node test/render-view.mjs"+    "test:render": "node test/render-view.mjs",+    "test:renderer": "node test/renderer.mjs"   },   "needs_sudo": false,   "hero": {@@ -82,6 +83,7 @@     "pose.mjs",     "drive.mjs",     "doctor.mjs",+    "renderer-paths.cjs",     "bridge.mjs",     "capture.mjs",     "bin/hero-studio",@@ -98,4 +100,3 @@     "screenshots/hero.png"   ] }-
page.json+4−2
@@ -2,7 +2,7 @@   "slug": "hero-studio",   "title": "Hero Studio",   "type": "app",-  "version": "0.21.2",+  "version": "0.21.3",   "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. Every hero wears its PAGE OWNER's brand guide (<owner>/brand): colors, type, logo and background themes, Adom's included; an unresolved guide holds unbranded rather than borrowing Adom's look. Placement is fixed for all. The floating app window is the showcase: 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, showcase, type match) and a Generate that unlocks only fully confirmed. Includes showcase 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": [@@ -65,7 +65,8 @@     "install": "./install.sh",     "uninstall": "./uninstall.sh",     "test:brand": "node test/brand.mjs",-    "test:render": "node test/render-view.mjs"+    "test:render": "node test/render-view.mjs",+    "test:renderer": "node test/renderer.mjs"   },   "needs_sudo": false,   "hero": {@@ -83,6 +84,7 @@     "pose.mjs",     "drive.mjs",     "doctor.mjs",+    "renderer-paths.cjs",     "bridge.mjs",     "capture.mjs",     "bin/hero-studio",
public/index.html+2
@@ -177,6 +177,7 @@     <label class="chk" style="display:none"><input type="checkbox" id="chk-author"></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 id="renderer-line" class="hint"></div>   </div>    <div id="stage">@@ -359,6 +360,7 @@     <button class="btn btn-primary" id="wbtn-generate" style="display:none">Generate hero PNG</button>     <button class="btn small btn-primary" id="wbtn-next">Next</button>   </div>+  <div id="wrenderer" class="hint" style="display:none"></div>   <div id="wcardsrow">     <button class="warrow" id="warr-l">&#8249;</button>     <div id="wcards"></div>
public/js/app.js+38
@@ -242,7 +242,45 @@ function fitWindowToShowcase() {   win.style.height = (bodyH + tbh).toFixed(2) + 'px'; } +/// hero-studio#58: what will render the next Generate, live from the server+/// (refreshed before every Generate and once a minute). A dead renderer+/// disables the gate buttons and names the fix; a newer package on disk+/// under this running server says so, since only a restart picks it up.+function rendererDead() {+  const rs = state.renderer;+  return !!(rs && !rs.local.ok && !rs.pup.ok);+}+function renderRendererLine() {+  const rs = state.renderer;+  const dead = rendererDead();+  let txt = '';+  if (rs) {+    if (rs.local.ok) txt = 'renderer: local headless Chrome in this container';+    else if (rs.pup.ok) txt = 'renderer: host browser via pup (local ' + rs.local.why + '; for full fidelity: ' + rs.local.fix + ')';+    else txt = 'RENDERER NOT READY, Generate will fail: ' + rs.local.why + '; pup fallback: ' + rs.pup.why + '. Fix: ' + (rs.local.fix || 'hero-studio doctor') + ';+    if (rs.onDisk) txt += ' | hero-studio ' + rs.onDisk + ' is installed on disk; this studio runs ' + rs.running + ': restart it to pick up the update';+  }+  // the classic panel line always says what will render+  const el = $('renderer-line');+  if (el) { el.textContent = txt; el.classList.toggle('bad', dead); }+  // the wizard is what the human sees: its line appears only when there is+  // something to act on (dead renderer, pup fallback, newer package on disk)+  const wl = $('wrenderer');+  if (wl) {+    const noteworthy = !!rs && (dead || !rs.local.ok || !!rs.onDisk);+    wl.style.display = noteworthy ? '' : 'none';+    wl.textContent = txt;+    wl.classList.toggle('bad', dead);+  }+  ['btn-generate', 'v-generate'].forEach((id) => {+    const b = $(id);+    if (!b) return;+    if (dead) { b.disabled = true; b.dataset.rdead = '1'; }+    else if (b.dataset.rdead) { delete b.dataset.rdead; b.disabled = false; }+  });+} function render() {+  renderRendererLine();   if (!state || !pages.length) return;   if (window.wizardRender) { try { wizardRender(); } catch (e) { console.error('wizard render:', e); } }   const p = pageByKey(state.page);
public/js/wizard.js+3−2
@@ -843,8 +843,9 @@ function wizardRender() {   gbtn.style.display = s.review ? '' : 'none';   if (s.review) {     wizApplySeasonedPretick();  // must precede the disabled derivation below-    gbtn.disabled = !wizAllConfirmed();-    gbtn.title = gbtn.disabled ? 'Confirm the four checks first' : '';+    const dead = typeof rendererDead === 'function' && rendererDead();+    gbtn.disabled = !wizAllConfirmed() || dead;+    gbtn.title = dead ? 'no renderer: see the renderer line under Generate' : (gbtn.disabled ? 'Confirm the four checks first' : '');   }    const cards = $('wcards');
public/studio.css+2
@@ -102,6 +102,8 @@ label.chk { display:flex; align-items:center; gap:8px; padding:5px 0; cursor:poi label.chk input { accent-color:var(--accent); width:14px; height:14px; cursor:pointer; }  .hint { font-size:11.5px; color:var(--text-2); line-height:1.45; margin-top:6px; }+#renderer-line.bad, #wrenderer.bad { color:#ff7b72; font-weight:600; }+#wrenderer { padding:4px 14px 0; font-size:11.5px; }  .btn {   width:100%; margin-top:8px; padding:8px 10px; border-radius:8px; border:1px solid var(--border);
renderer-paths.cjsadded+112
@@ -0,0 +1,112 @@+// Where the local renderer's pieces live, shared by doctor.mjs (the full+// preflight), server.js (the cheap status the gate shows) and install.sh.+// One list, so a path added here is seen by every reader (hero-studio#58).+//+// puppeteer-core is installed OUTSIDE the package directory, under+// ~/.cache/hero-studio, because a package update replaces the package+// directory including its node_modules, which is how a running studio lost+// its renderer between a passing doctor and the human's Generate click.+const fs = require('fs');+const path = require('path');+const os = require('os');++const DIR = __dirname;+const RENDERER_HOME = process.env.HERO_STUDIO_RENDERER_HOME || path.join(os.homedir(), '.cache', 'hero-studio');+// Test-only: consult nothing but the env override and RENDERER_HOME, so a+// box that has puppeteer-core in a shared node_modules can still exercise+// the "no renderer" path.+const ISOLATED = process.env.HERO_STUDIO_RENDERER_ISOLATED === '1';++// Package roots, in the order they win. The entry file inside differs by+// line (25.x: lib/puppeteer/puppeteer-core.js; 24.x: lib/esm/puppeteer/...),+// so it is read from each package.json rather than assumed.+function puppeteerRoots() {+  const list = [path.join(RENDERER_HOME, 'node_modules', 'puppeteer-core')];+  if (!ISOLATED) {+    list.push(path.join(DIR, 'node_modules', 'puppeteer-core'), '/home/adom/project/node_modules/puppeteer-core', path.join(os.homedir(), 'node_modules', 'puppeteer-core'));+  }+  return list;+}++function entryOf(root) {+  try {+    const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));+    const ex = pkg.exports && pkg.exports['.'];+    const rel = (ex && (typeof ex === 'string' ? ex : (ex.import || ex.default || ex.require))) || pkg.main;+    const f = rel ? path.join(root, rel) : null;+    return f && fs.existsSync(f) ? f : null;+  } catch { return null; }+}++// Entry files worth trying, best first: the env override (a file), then each+// root's own entry.+function puppeteerCandidates() {+  const list = [];+  if (process.env.PUPPETEER_CORE) list.push(process.env.PUPPETEER_CORE);+  for (const r of puppeteerRoots()) { const e = entryOf(r); if (e) list.push(e); }+  return list;+}++// package.json paths for the same roots (the engines check reads them).+function puppeteerPackages() {+  return puppeteerRoots().map((r) => path.join(r, 'package.json')).filter((f) => fs.existsSync(f));+}++// The first puppeteer-core entry file that exists, else the package as Node+// resolves it from here, else null.+function puppeteerPath() {+  for (const c of puppeteerCandidates()) if (fs.existsSync(c)) return c;+  if (ISOLATED) return null;+  try { return require.resolve('puppeteer-core'); } catch { return null; }+}++function chromePath() {+  if (process.env.CHROME && fs.existsSync(process.env.CHROME)) return process.env.CHROME;+  if (ISOLATED) return null;+  const base = path.join(os.homedir(), '.cache', 'puppeteer', 'chrome');+  if (fs.existsSync(base)) {+    for (const d of fs.readdirSync(base).sort().reverse()) {+      const p = path.join(base, d, 'chrome-linux64', 'chrome');+      if (fs.existsSync(p)) return p;+    }+  }+  return null;+}++// puppeteer-core 25.x wants Node >= 22.12 (it imports on 18 and then fails at+// launch); 24.x is the last line that runs on older Node.+function puppeteerPin(nodeVersion) {+  const [a, b] = String(nodeVersion || process.versions.node).split('.').map(Number);+  return a > 22 || (a === 22 && b >= 12) ? 'latest' : '24';+}++function puppeteerInstallCommand() {+  return 'npm install --prefix ' + RENDERER_HOME + ' --no-audit --no-fund puppeteer-core@' + puppeteerPin() ++    '    # outside the package dir, so a package update cannot remove it; no restart needed';+}++function chromeInstallCommand() {+  return 'npx @puppeteer/browsers install chrome@stable --path ' + path.join(os.homedir(), '.cache', 'puppeteer');+}++// The cheap, synchronous status the gate shows: files exist, nothing is+// launched. doctor.mjs is the ground truth (it launches Chrome); this is what+// can run before every Generate and once a minute without cost.+function quickStatus() {+  const pptr = puppeteerPath();+  const chrome = chromePath();+  const proxied = !!process.env.VSCODE_PROXY_URI;+  return {+    local: {+      ok: !!(pptr && chrome),+      puppeteer: pptr,+      chrome,+      why: !pptr ? 'puppeteer-core not found' : (!chrome ? 'Chrome not found' : null),+      fix: !pptr ? puppeteerInstallCommand() : (!chrome ? chromeInstallCommand() : null),+    },+    pup: { ok: proxied, why: proxied ? null : 'VSCODE_PROXY_URI is not set, so the host browser cannot reach the studio' },+    checkedAt: Date.now(),+  };+}++module.exports = { DIR, RENDERER_HOME, ISOLATED, puppeteerRoots, puppeteerCandidates, puppeteerPackages, puppeteerPath, chromePath, puppeteerPin, puppeteerInstallCommand, chromeInstallCommand, quickStatus };
server.js+41−1
@@ -42,6 +42,29 @@ const PAGES = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'pages.jso // The provenance stamp reads the REAL version so it stays truthful across // releases (hero-studio#2: a hardcoded '0.5.0' cost Arav a wrong-release chase). const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8')).version;+const RP = require('./renderer-paths.cjs');+// hero-studio#58: the renderer status the gate shows. Cheap (files exist,+// nothing launched), refreshed before every Generate and once a minute, so a+// package update that removed puppeteer-core under a running studio shows up+// at the gate instead of as two failing render legs. `onDisk` names a newer+// package version installed under this running one (restart to pick it up).+function rendererStatus() {+  const s = RP.quickStatus();+  let onDisk = null;+  try {+    const v = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8')).version;+    if (v !== PKG_VERSION) onDisk = v;+  } catch {}+  return { ...s, running: PKG_VERSION, onDisk };+}+function refreshRenderer() {+  const next = rendererStatus();+  const strip = (x) => x ? JSON.stringify({ ...x, checkedAt: 0 }) : '';+  const changed = strip(state.renderer) !== strip(next);+  state.renderer = next;+  if (changed) { try { broadcast('state', state); } catch {} }+  return next;+} const COMPONENTS = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'components.json'), 'utf8'));  let state = {@@ -58,6 +81,7 @@ let state = {   // the PAGE OWNER's colors, type and logo, never Adom's for someone else, so   // this replaces the two free-floating accent/bg fields it used to carry.   guide: null,   // { owner, status, sha, version, brand, assets, can_edit, editable_by, url }+  renderer: null, // hero-studio#58: { local:{ok,why,fix}, pup:{ok,why}, running, onDisk, checkedAt }   brandEdits: {},  // staged Brand-step edits, dot path -> value, until a human confirms   verify: false,  // the pose gate turns this on: verification chrome + checklist   confirm: { title: false, subtitle: false, showcase: false, type: false },@@ -309,6 +333,7 @@ if (!KEEP_ALIVE) {     if (Date.now() - lastActivity > TTL_HOURS * 3600e3) reap();   }, 60e3).unref(); }+setInterval(refreshRenderer, 60e3).unref(); process.on('SIGTERM', () => { deregisterInstance(); process.exit(0); }); process.on('SIGINT', () => { deregisterInstance(); process.exit(0); }); @@ -1377,6 +1402,18 @@ function route(req, res, url, p) {       return refuse('hard-fail layout findings lock Generate',         'fix the failing items in state.lint first; they lock the human gate and automated mode alike');     }+    // hero-studio#58: nothing can render -> say so now, with the fix, instead+    // of letting the click walk through two failing legs. A pinned leg is the+    // caller's choice and still runs.+    if (!pinned) {+      const rs = refreshRenderer();+      if (!rs.local.ok && !rs.pup.ok) {+        res.writeHead(200, { 'Content-Type': 'application/json' });+        return res.end(JSON.stringify({ ok: false, errorCode: 'RENDERER_NOT_READY',+          error: 'no renderer: ' + rs.local.why + '; pup fallback: ' + rs.pup.why,+          hint: (rs.local.fix || "run 'hero-studio doctor'") + '; then retry, the renderer is spawned per Generate' }));+      }+    }     // hero-studio#51: the AUTOMATED path must not trust findings measured     // against a pose that no longer exists. The human gate is exempt: human     // eyes are at that gate by definition.@@ -1514,7 +1551,9 @@ function route(req, res, url, p) {     // Renderer ladder: local headless Chrome first (full fidelity), then the     // host browser via pup for containers that cannot run Chrome (#7).     const attempts = [];-    if (pinned !== 'pup') {+    // a local leg known to be missing its pieces is skipped, not tried (#58)+    const localKnownDead = !!(state.renderer && !state.renderer.local.ok);+    if (pinned !== 'pup' && (pinned === 'local' || !localKnownDead)) {       attempts.push({ script: 'render-hero.mjs', url: 'http://localhost:' + PORT + '/?render=1', label: 'local', timeout: 60000 });     }     const proxyBase = (process.env.VSCODE_PROXY_URI || '').replace('{{port}}', String(PORT));@@ -1701,6 +1740,7 @@ try {   } } catch {} +refreshRenderer(); server.listen(PORT, () => {   PORT = server.address().port;   writeRegistry();