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.19.0
7 files changed
+172−7
SKILL.md+21@@ -29,6 +29,27 @@ one). GET /state also carries `resolved` {title, headline, subtitle, *Source}: the exact strings the surface is showing and where each came from (live-wiki | fixture | override), so a driving AI never verifies via pixels. +## Bulk heroes: the human gates the STYLE once (0.19)++For fleets of similar pages (300 component pages, platform twins), the gate+scales by gating the STYLE, not every part:++1. Pose ONE representative part; the human walks the wizard and Generates.+2. `hero-studio style save <name>`: snapshots the pose style (layout, theme,+ type, badges, brand) WITH that human approval as its gate. Refused if the+ session holds no approval.+3. `hero-studio batch --style <name> --parts parts.json --pace 5 --human+ <who> --quote "<their words>"`: applies the style to every part (each with+ its own title and render), generating locally, paced. Every export's+ provenance records the style, the gate that minted it, and the+ attestation, so a batch hero is always traceable to the human who set the+ look. parts.json rows: {ref, title, kind?, vitrine?: <render path>,+ headline?, subtitle?}.++Generation is local (no wiki calls, no rate limits). The publish loop is+where pacing matters: publish serially or 2-3 at a time and let the 429+hints set the pace if they appear.+ ## Automated mode: same-session repeats only The FIRST Generate of a page each session is always clicked by the human in
bin/hero-studio+1−1@@ -11,7 +11,7 @@ fi case "$1" in pose) shift; exec node "$DIR/pose.mjs" "$@" ;; doctor) shift; exec node "$DIR/doctor.mjs" "$@" ;;- vitrine|set|preset|fit|state|toast|reroll|changes|bg|compose|ls|instances|stop) exec node "$DIR/drive.mjs" "$@" ;;+ vitrine|set|preset|fit|style|batch|state|toast|reroll|changes|bg|compose|ls|instances|stop) exec node "$DIR/drive.mjs" "$@" ;; start) shift; exec node "$DIR/server.js" "$@" ;; -h|--help|help) # hero-studio#36: --help used to fall through to the server catch-all and
data/pages.json+30@@ -1,4 +1,34 @@ [+ {+ "type": "hardware",+ "title": "Ztest Batch B",+ "author": "",+ "score": 0,+ "stars": 0,+ "installs30d": 0,+ "brief": "",+ "tags": [],+ "updated_at": "2026-08-13T17:52:52.448Z",+ "aiThread": "hero wizard dogfood",+ "owner": "adom",+ "slug": "ztest-batch-b",+ "kind": "component"+ },+ {+ "type": "hardware",+ "title": "Ztest Batch A",+ "author": "",+ "score": 0,+ "stars": 0,+ "installs30d": 0,+ "brief": "",+ "tags": [],+ "updated_at": "2026-08-13T17:52:43.635Z",+ "aiThread": "hero wizard dogfood",+ "owner": "adom",+ "slug": "ztest-batch-a",+ "kind": "component"+ }, { "type": "app", "title": "COSMIIC Implantables",
drive.mjs+64−1@@ -183,6 +183,69 @@ try { if (!out.ok) die('ERROR: ' + out.error + (out.hint ? '\n hint: ' + out.hint : '')); console.log(JSON.stringify(out)); console.error('fit requested; read state.fontSize.title in about a second');+ } else if (verb === 'style') {+ // style save <name> | style list -- the human-gated template for bulk+ const sub = args.find((a) => !a.startsWith('--'));+ if (sub === 'list' || !sub) {+ console.log(JSON.stringify(await jf('styles'), null, 2));+ } else if (sub === 'save') {+ const name = args[args.indexOf('save') + 1];+ if (!name) die('usage: hero-studio style save <name> (after a human-approved Generate)');+ const out = await jf('style', { name });+ if (!out.ok) die('ERROR: ' + out.error + (out.hint ? '\n hint: ' + out.hint : ''));+ console.log(JSON.stringify(out));+ console.error('style "' + out.name + '" saved, gated by the ' + out.gate.page + ' approval; batch with: hero-studio batch --style ' + out.name + ' --parts <parts.json>');+ } else die('usage: hero-studio style save <name> | style list');+ } else if (verb === 'batch') {+ // The bulk half of the once-through-the-gate model: apply a human-gated+ // style to many parts, generating each locally (no wiki calls, no rate+ // limits here); --pace spaces the renders.+ const si = args.indexOf('--style');+ const pi2 = args.indexOf('--parts');+ if (si < 0 || pi2 < 0) die('usage: hero-studio batch --style <name> --parts <parts.json> [--pace SECS] [--human <who>] [--quote "their words"]\n parts.json: [{"ref":"adom/x","title":"X","vitrine":"render.png","kind":"component"}, ...]');+ const styleName = args[si + 1];+ const fsm = await import('fs');+ const parts = JSON.parse(fsm.readFileSync(args[pi2 + 1], 'utf8'));+ const pace = Number(args[args.indexOf('--pace') + 1]) || 5;+ const human = args.includes('--human') ? args[args.indexOf('--human') + 1] : '';+ const quote = args.includes('--quote') ? args[args.indexOf('--quote') + 1] : '';+ if (!human || !quote) die('the batch carries the human\'s attestation: --human <who approved the style> --quote "their approval words"');+ const styles = await jf('styles');+ const st2 = (styles.styles || []).find((x) => x.name === styleName);+ if (!st2) die('unknown style "' + styleName + '"; hero-studio style list shows saved ones');+ const stylePatch = JSON.parse(fsm.readFileSync((await import('os')).homedir() + '/.adom/instances/hero-studio/styles/' + styleName + '.json', 'utf8')).style;+ const results = [];+ console.error('batch: ' + parts.length + ' part(s), style "' + styleName + '" (gated by ' + st2.gate.page + '), pace ' + pace + 's');+ for (let i = 0; i < parts.length; i++) {+ const part = parts[i];+ try {+ await jf('api/pages', { owner: part.ref.split('/')[0], slug: part.ref.split('/')[1], title: part.title, type: part.type || 'hardware', kind: part.kind });+ await jf('state', Object.assign({}, stylePatch, { page: part.ref, headline: part.headline || '', subtitle: part.subtitle || '', vitrine: Object.assign({ src: '', full: false }, stylePatch.vitrine || {}) }));+ if (part.vitrine) {+ const v = await jf('vitrine-file', { path: path.resolve(part.vitrine), cover: !!(stylePatch.vitrine || {}).cover, chrome: (stylePatch.vitrine || {}).chrome || '' });+ if (!v.ok) throw new Error('vitrine: ' + v.error);+ }+ const g = await jf('generate', { attest: { style: styleName, human, quote } });+ if (!g.ok) throw new Error(g.error);+ // wait for the finalize (poll state._gen)+ let done = null;+ for (let w = 0; w < 60 && !done; w++) {+ await new Promise((r) => setTimeout(r, 1000));+ const stt = await jf('state');+ if (stt._gen && stt._gen.ok && stt._gen.file && (!results.length || stt._gen.file !== results[results.length - 1].file)) done = stt._gen;+ }+ if (!done) throw new Error('generate did not finish in 60s');+ results.push({ ref: part.ref, ok: true, file: done.file });+ console.error(' [' + (i + 1) + '/' + parts.length + '] ' + part.ref + ' -> ' + done.file);+ } catch (e) {+ results.push({ ref: part.ref, ok: false, error: e.message });+ console.error(' [' + (i + 1) + '/' + parts.length + '] ' + part.ref + ' FAILED: ' + e.message);+ }+ if (i < parts.length - 1) await new Promise((r) => setTimeout(r, pace * 1000));+ }+ console.log(JSON.stringify({ style: styleName, results }, null, 1));+ const bad = results.filter((r) => !r.ok).length;+ console.error('batch done: ' + (results.length - bad) + ' ok, ' + bad + ' failed; PNGs in generated/ (copy each to its part\'s docs/hero.png before publish)'); } else if (verb === 'instances') { // every live studio on this box (#26): scan the port neighborhood const ports = new Set([PORT]);@@ -220,7 +283,7 @@ try { console.log(JSON.stringify({ ok: true, file: outFile, bytes: buf.length })); console.error('composed preview written (no provenance stamp; the pose gate + Generate remain the only publish path)'); } else {- die('verbs: pose | vitrine | set | preset | fit | state | toast | reroll | changes | bg | compose | ls | instances | stop');+ die('verbs: pose | vitrine | set | preset | fit | style | batch | state | toast | reroll | changes | bg | compose | ls | instances | stop'); } } catch (e) { die('ERROR: ' + e.message + '\n hint: is the studio server up? `hero-studio` starts it; `hero-studio pose <ref> --surface ...` does everything');
package.json+1−1@@ -2,7 +2,7 @@ "slug": "hero-studio", "title": "Hero Studio", "type": "app",- "version": "0.18.4",+ "version": "0.19.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.18.4",+ "version": "0.19.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": [
server.js+54−3@@ -390,6 +390,44 @@ function route(req, res, url, p) { res.writeHead(200, { 'Content-Type': mime, 'Content-Length': buf.length, 'Cache-Control': 'public, max-age=31536000, immutable' }); return res.end(buf); }+ if (p === '/style' && req.method === 'POST') {+ // Save the CURRENT pose style as a reusable template. Only allowed when+ // this session holds a human-approved generate: the approval IS the+ // style's gate, and it travels with every batch export made from it.+ const name = (req._body && req._body.name || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').slice(0, 40);+ if (!name) { res.writeHead(400, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ ok: false, error: 'style needs a name' })); }+ if (!state.approved) {+ res.writeHead(200, { 'Content-Type': 'application/json' });+ return res.end(JSON.stringify({ ok: false, error: 'no human-approved generate in this session to gate the style',+ hint: 'pose a representative part, let the human walk the gate and click Generate, then save the style: the approval is what makes the template reusable' }));+ }+ const style = {+ layout: state.layout, theme: state.theme, gradAngle: state.gradAngle,+ titleMode: state.titleMode, fontSize: state.fontSize, overlay: state.overlay,+ brand: state.brand, show: state.show, guides: false,+ vitrine: { chrome: (state.vitrine || {}).chrome || '', cover: !!(state.vitrine || {}).cover },+ mode: state.mode, comp: state.mode === 'component' ? { imgSrc: state.comp.imgSrc, layout: state.comp.layout, mpn: state.comp.mpn } : undefined,+ circuitSeed: state.theme === 'circuitry' ? state.circuitSeed : undefined,+ };+ const rec = { name, style, gate: Object.assign({}, state.approved, { thread: AI_THREAD, savedAt: new Date().toISOString() }) };+ const dir = path.join(INSTANCES_DIR, 'styles');+ fs.mkdirSync(dir, { recursive: true });+ fs.writeFileSync(path.join(dir, name + '.json'), JSON.stringify(rec, null, 1));+ logLine('info', 'style saved: ' + name + ' (gated by the ' + state.approved.page + ' approval)');+ res.writeHead(200, { 'Content-Type': 'application/json' });+ return res.end(JSON.stringify({ ok: true, name, gate: rec.gate }));+ }+ if (p === '/styles' && req.method === 'GET') {+ const dir = path.join(INSTANCES_DIR, 'styles');+ const rows = [];+ try {+ for (const f of fs.readdirSync(dir)) {+ try { const r = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); rows.push({ name: r.name, gate: r.gate }); } catch {}+ }+ } catch {}+ res.writeHead(200, { 'Content-Type': 'application/json' });+ return res.end(JSON.stringify({ styles: rows }));+ } if (p === '/instances' && req.method === 'GET') { // the live-studio roster for the wizard's Package step: one studio per // page/thread is the multi-package model, so show the siblings@@ -768,9 +806,22 @@ function route(req, res, url, p) { return refuse('Generate is human-gated: the human clicks it in the studio', 'a human must generate this page first (hero-studio pose opens the gate). To RE-generate a page the human already generated this session, POST /generate with attest: {human: "<who approved>", quote: "<their actual words>"}'); }- if (!(state.approved && state.approved.page === state.page)) {+ let styleGate = null;+ if (attest.style) {+ // Batch path (bulk hardware): a saved style carries the human gate+ // that minted it; applying that style to another part inherits the+ // gate, recorded per export.+ try {+ styleGate = JSON.parse(fs.readFileSync(path.join(INSTANCES_DIR, 'styles', String(attest.style).toLowerCase().replace(/[^a-z0-9-]+/g, '-') + '.json'), 'utf8'));+ } catch {}+ if (!styleGate || !styleGate.gate) {+ return refuse('unknown style "' + attest.style + '" (or it carries no human gate)',+ 'save one first: pose a representative part, let the human Generate, then hero-studio style save <name>');+ }+ req._styleGate = styleGate;+ } else if (!(state.approved && state.approved.page === state.page)) { return refuse('automated Generate is only allowed for a page a human already generated this session',- 'pose ' + state.page + ' and let the human click Generate once; after that, attested re-generates of the same page are allowed');+ 'pose ' + state.page + ' and let the human click Generate once (attest re-generates of that page), or apply a human-gated style template: attest {style: "<name>", human, quote}'); } if (!attest.human || !String(attest.quote || '').trim()) { return refuse('the attestation must name the human and quote their actual approval words',@@ -871,7 +922,7 @@ function route(req, res, url, p) { return finish({ ok: false, error: 'could not read the rendered PNG: ' + e.message, hint: "run 'hero-studio doctor'" }); } // downscale to the hero standard 2000x1250 and stamp provenance metadata- const meta = JSON.stringify({ generator: 'hero-studio', version: PKG_VERSION, ts, renderer: renderedVia, state: { mode: state.mode, page: state.page, comp: state.comp, theme: state.theme, layout: state.layout, gradAngle: state.gradAngle, brand: state.brand, fontSize: state.fontSize, verify: state.verify, confirm: state.confirm, vitrine_used: !!(state.vitrine && state.vitrine.src), vitrine_full: !!(state.vitrine && state.vitrine.full), circuitSeed: state.theme === 'circuitry' ? state.circuitSeed : undefined }, attestation: attest ? { human: attest.human, quote: String(attest.quote).slice(0, 300), aiThread: req.aiThread || '', ts } : undefined, changes: (Object.keys(changes.page).length || Object.keys(changes.pose).length) ? { page: changes.page, pose: changes.pose, applied: changes.applied } : undefined });+ const meta = JSON.stringify({ generator: 'hero-studio', version: PKG_VERSION, ts, renderer: renderedVia, state: { mode: state.mode, page: state.page, comp: state.comp, theme: state.theme, layout: state.layout, gradAngle: state.gradAngle, brand: state.brand, fontSize: state.fontSize, verify: state.verify, confirm: state.confirm, vitrine_used: !!(state.vitrine && state.vitrine.src), vitrine_full: !!(state.vitrine && state.vitrine.full), circuitSeed: state.theme === 'circuitry' ? state.circuitSeed : undefined }, attestation: attest ? { human: attest.human, quote: String(attest.quote).slice(0, 300), aiThread: req.aiThread || '', ts, style: attest.style || undefined, styleGate: req._styleGate ? req._styleGate.gate : undefined } : undefined, changes: (Object.keys(changes.page).length || Object.keys(changes.pose).length) ? { page: changes.page, pose: changes.pose, applied: changes.applied } : undefined }); const py = 'import sys\n' + 'from PIL import Image\n' + 'from PIL.PngImagePlugin import PngInfo\n' +