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
Merge PR #172: Render view is read-only; slotSize only on own change; real render errors (fixes #57)
4 files changed
+126−6
package.json+2−1@@ -64,7 +64,8 @@ "scripts": { "install": "./install.sh", "uninstall": "./uninstall.sh",- "test:brand": "node test/brand.mjs"+ "test:brand": "node test/brand.mjs",+ "test:render": "node test/render-view.mjs" }, "needs_sudo": false, "hero": {
public/js/app.js+16−3@@ -3,6 +3,13 @@ lands with M4, when the panel code entangled with render() is deleted. */ if (new URLSearchParams(location.search).has('render')) document.body.dataset.render = '1';+// The render view (?render=1) is what Generate and compose screenshot. It is a READ-ONLY consumer of+// the state it loaded: it must never POST state, and never subscribe to changes mid-screenshot.+// Before this it ran the full surface: its slotSize measurement (headless viewport, 1109x1269)+// disagreed with the human's open studio (1109x1227), each POST was broadcast to the other, and the+// two ping-ponged hundreds of times a second, so the renderer's networkidle2 wait never settled and+// every Generate died at the 30 s navigation timeout (2026-09-22, adom/adom-bluegreen).+const RENDER_VIEW = new URLSearchParams(location.search).has('render'); /* console relay: the AI reads GET /console instead of DevTools */ (function () {@@ -39,7 +46,9 @@ const $ = (id) => document.getElementById(id); // owning ai-thread (#39): the page IS that instance's surface, and the human // acting through it acts within that thread's session. const withThread = (o) => Object.assign({ aiThread: (state && state.aiThread) || 'hero-studio ui' }, o || {});-const post = (path, body) => fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(withThread(body)) });+const post = (path, body) => RENDER_VIEW+ ? Promise.resolve(new Response('{}', { headers: { 'Content-Type': 'application/json' } }))+ : fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(withThread(body)) }); const send = (patch) => post('state', patch).catch(() => {}); function canonType(t) { return t === 'hardware' ? 'component' : (t || 'app'); }@@ -374,8 +383,11 @@ function render() { el.dataset.size = w + 'x' + h; el.textContent = 'shoot the showcase image at ' + w + 'x' + h + ' (aspect ' + (wb.width / wb.height).toFixed(2) + ') for this placement; click to copy';- const cur = state.slotSize || {};- if (cur.w !== w || cur.h !== h) send({ slotSize: { w: w, h: h } });+ // Report only when THIS surface's own measurement changes. Comparing against the server's value+ // made two surfaces at different widths (two tabs, or a tab and the render view) correct each+ // other forever: each POST is broadcast, the other sees a mismatch and posts back.+ const key = w + 'x' + h;+ if (window._slotSent !== key) { window._slotSent = key; send({ slotSize: { w: w, h: h } }); } }); // showcase content: real screenshot in the window, or full-hero import@@ -1628,6 +1640,7 @@ Promise.all([ const el = document.querySelector(location.hash); if (el) setTimeout(() => el.scrollIntoView({ block: 'start' }), 300); }+ if (RENDER_VIEW) return; // render view: the state it loaded is the state it renders const es = new EventSource('events'); es.addEventListener('state', e => { state = JSON.parse(e.data); syncPagesIfStale(state); render(); }); es.addEventListener('fit', e => { let m = null; try { m = (JSON.parse(e.data) || {}).max; } catch {} setTimeout(() => runFit(m), 80); });
server.js+10−2@@ -3,6 +3,14 @@ const http = require('http'); const fs = require('fs'); const path = require('path'); const { spawn, execFile } = require('child_process');+// The useful line of a renderer's stderr. Node ends an uncaught exception with a blank line and+// "Node.js v18.19.1", so taking the LAST line reported the Node version as the error (2026-09-22:+// the human saw "Generate failed: local: Node.js v18.19.1"). Prefer the last line naming an error.+function renderError(buf) {+ const lines = String(buf || '').split('\n').map((l) => l.trim()).filter((l) => l && !/^Node\.js v\d/.test(l));+ const named = lines.filter((l) => /(^|\b)(\w*Error|error)\b[:\s]/.test(l) && !/^at /.test(l));+ return (named.pop() || lines.pop() || '').slice(0, 300);+} const crypto = require('crypto'); // Port: --port flag > HERO_STUDIO_PORT env > 8846 ('auto' = bind an OS-picked@@ -1116,7 +1124,7 @@ function route(req, res, url, p) { clearTimeout(t); if (code !== 0 || !fs.existsSync(tmp)) { res.writeHead(500, { 'Content-Type': 'application/json' });- return res.end(JSON.stringify({ ok: false, error: eb.trim().split('\n').pop() || 'compose render failed' }));+ return res.end(JSON.stringify({ ok: false, error: renderError(eb) || 'compose render failed' })); } // hero-studio#51: the headless render evaluated the SAME linter the // surface runs; store its findings as fresh-by-construction, so the@@ -1527,7 +1535,7 @@ function route(req, res, url, p) { child.on('close', (code) => { clearTimeout(timer); if (code === 0 && fs.existsSync(outFile)) return cb(null);- cb(errBuf.trim().split('\n').pop() || a.label + ' render exited ' + code);+ cb(renderError(errBuf) || a.label + ' render exited ' + code); }); }; const tryNext = () => {
test/render-view.mjsadded+98@@ -0,0 +1,98 @@+#!/usr/bin/env node+// Render-view tests: node test/render-view.mjs+//+// Generate and compose screenshot /?render=1 with puppeteer, waiting for networkidle2. On+// 2026-09-22 every Generate died at the 30 s navigation timeout: the render view ran the full+// interactive surface, measured the showcase slot at its own headless viewport (1109x1269), and+// POSTed it; the human's open studio (1109x1227) saw a mismatch and POSTed back; each POST was+// broadcast; the two ping-ponged hundreds of times a second (poseRev reached 43,988) and the+// network never went idle. The error the human saw was "Node.js v18.19.1", the last stderr line.+//+// This starts a real server on a spare port, opens an interactive surface at one width, then+// loads the render view at another, and asserts:+// - the render view makes no state-changing POST,+// - it reaches networkidle2 while the other surface is open,+// - no slotSize ping-pong: the server sees at most one slotSize POST per surface,+// - renderError() extracts the real error line, not the Node.js version footer.+import { spawn } from 'child_process';+import net from 'net';+import fs from 'fs';+import path from 'path';+import { fileURLToPath } from 'url';+import { loadPuppeteer, findChrome } from '../doctor.mjs';++const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');+// A free port picked by the OS: a fixed one collides with whatever else runs on the box.+const PORT = Number(process.env.RV_PORT) || await new Promise((res) => { const s = net.createServer().listen(0, () => { const p = s.address().port; s.close(() => res(p)); }); });+let pass = 0; const fails = [];+const ok = (name, cond, detail) => { if (cond) { pass++; console.log(' ok ' + name); } else { fails.push(name); console.log(' FAIL ' + name + (detail ? ': ' + detail : '')); } };++// renderError, pure+const src = fs.readFileSync(path.join(ROOT, 'server.js'), 'utf8');+const fn = src.match(/function renderError[\s\S]*?\n}\n/);+ok('server.js defines renderError', !!fn);+if (fn) {+ const renderError = new Function(fn[0] + '; return renderError;')();+ const eb = 'file:///x/Deferred.js:57\n this.#timeoutError = new TimeoutError(opts.message);\n ^\n\nTimeoutError: Navigation timeout of 30000 ms exceeded\n at new Deferred (file:///x)\n\nNode.js v18.19.1\n';+ ok('renderError names the real error, not the Node version', renderError(eb) === 'TimeoutError: Navigation timeout of 30000 ms exceeded', renderError(eb));+ ok('renderError falls back to the last plain line', renderError('chrome exited\n\nNode.js v22.1.0\n') === 'chrome exited', renderError('chrome exited\n\nNode.js v22.1.0\n'));+}++const server = spawn(process.execPath, [path.join(ROOT, 'server.js'), '--port', String(PORT), '--ai-thread', 'render-view test', '--ttl', '1'],+ { env: { ...process.env, HERO_STUDIO_PORT: String(PORT) }, stdio: ['ignore', 'pipe', 'pipe'] });+const base = 'http://localhost:' + PORT;+let up = false;+for (let i = 0; i < 50 && !up; i++) { try { const r = await fetch(base + '/state'); up = r.ok && 'slotSize' in (await r.json()); } catch {} if (!up) await new Promise(r => setTimeout(r, 200)); }+ok('a hero-studio server answers on :' + PORT, up);+if (!up) { server.kill(); console.log('\n' + pass + ' passed, ' + (fails.length) + ' failed'); process.exit(1); }++const puppeteer = await loadPuppeteer();+const browser = await puppeteer.launch({ executablePath: findChrome(), headless: 'new', args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'] });+try {+ // Another surface that measures the showcase slot differently (the human's studio tab, at its+ // own width), modeled as the old page logic: follow the event stream and POST its own slotSize+ // whenever the server's differs. Any render view that posts state will ping-pong with it.+ const OTHER = { w: 1109, h: 1227 };+ let otherPosts = 0; const ctl = new AbortController();+ (async () => {+ try {+ const r = await fetch(base + '/events', { signal: ctl.signal });+ const dec = new TextDecoder(); let buf = '';+ for await (const chunk of r.body) {+ buf += dec.decode(chunk, { stream: true });+ let k;+ while ((k = buf.indexOf('\n\n')) >= 0) {+ const ev = buf.slice(0, k); buf = buf.slice(k + 2);+ if (!/^event: state/m.test(ev)) continue;+ const st = JSON.parse((ev.match(/^data: (.*)$/m) || [])[1] || '{}');+ const cur = st.slotSize || {};+ if (cur.w !== OTHER.w || cur.h !== OTHER.h) {+ otherPosts++;+ fetch(base + '/state', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ aiThread: 'render-view test', slotSize: OTHER }) }).catch(() => {});+ }+ }+ }+ } catch {}+ })();+ await new Promise(r => setTimeout(r, 800));++ // the render view at the renderer's own viewport+ const view = await browser.newPage();+ await view.setViewport({ width: 1680, height: 1080, deviceScaleFactor: 2 });+ const posts = [];+ view.on('request', (r) => { if (r.method() === 'POST' && !/\/console$/.test(r.url())) posts.push(r.url()); });+ const t0 = Date.now(); let idle = true;+ try { await view.goto(base + '/?render=1', { waitUntil: 'networkidle2', timeout: 10000 }); } catch (e) { idle = false; }+ ok('render view reaches networkidle2 with another surface open', idle, 'timed out after ' + (Date.now() - t0) + ' ms');+ await new Promise(r => setTimeout(r, 2000));+ ok('render view makes no state-changing POST', posts.length === 0, posts.slice(0, 3).join(', '));+ ok('render view still renders the hero', !!(await view.$('#hero')));++ ctl.abort();+ ok('no slotSize ping-pong with a surface that measures differently', otherPosts <= 2, otherPosts + ' corrective posts from the other surface');+} finally {+ await browser.close();+ server.kill();+}+console.log('\n' + pass + ' passed, ' + fails.length + ' failed');+process.exit(fails.length ? 1 : 0);