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.20.7
5 files changed
+134−11
SKILL.md+22−1@@ -79,7 +79,7 @@ through your gated style, scaffolds and lints each page, publishes SERIALLY with pacing, backs off exponentially on any 429, and RESUMES from bulk-state.json so a stop never repeats work. Copy it, do not reinvent it. -## Automated mode: same-session repeats only+## Automated mode: same-session repeats, or a provenance-anchored attest The FIRST Generate of a page each session is always clicked by the human in the studio. After that, the driving AI may RE-generate that same page in@@ -87,11 +87,32 @@ that same session: POST /generate {"aiThread": "...", "attest": {"human": "colby", "quote": "ship it"}} +A page whose PUBLISHED hero already carries a hero-studio provenance comment+can also be re-generated across sessions with the same attest shape: the+studio fetches the current hero, reads its provenance, and records it as+`attestation.prior` in the new stamp, so the chain from the original human+approval stays intact (hero-studio#55; built for fleet re-layouts like the+dock-hero unification). A page with no readable studio provenance still+needs the in-session click or a human-gated style.+ The attestation is REQUIRED (who approved, their actual words), is baked into the PNG provenance and the Approved record, and never counts toward the seasoned gate tally. Everything else is refused with the exact retry. Hard-fail lint findings lock every Generate path, human or attested. +## The showcase is stored with every generate (hero-studio#55)++Generate (and batch) writes the showcase image the hero was composed from to+`generated/showcase-<slug>-<ts>.<ext>` and names it in the response+(`_gen.showcase` + a hint). PUSH IT with the hero as `screenshots/showcase.<ext>`+in the page repo: the hero's provenance comment records+`state.showcase {path, sha256, w, h, chrome, full, cover}`, so a later+session can find the exact source file and regenerate the hero from its+parts. Full-hero imports are saved the same way; those are the ones with no+other record at all. A hero whose showcase was never pushed can only be+recovered by cropping the published hero, which is fragile: do not skip the+push.+ ## The ai-thread name is REQUIRED (hero-studio#39) Every server start and every state-changing verb needs the global flag
package.json+1−1@@ -2,7 +2,7 @@ "slug": "hero-studio", "title": "Hero Studio", "type": "app",- "version": "0.20.6",+ "version": "0.20.7", "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 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": [
page.json+1−1@@ -2,7 +2,7 @@ "slug": "hero-studio", "title": "Hero Studio", "type": "app",- "version": "0.20.6",+ "version": "0.20.7", "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 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": [
public/hero.css+2@@ -306,6 +306,8 @@ body { .pill.service { color:#ff9d76; border-color:rgba(248,110,73,.5); background:rgba(248,110,73,.12); } +.pill.bridge { color:#00e6dc; border-color:rgba(0,230,220,.45); background:rgba(0,184,176,.10); }+ /* pill group (#19): multi-type pages render one clustered badge box */ .pill-tr { display:flex; gap:.5cqw; }
server.js+108−8@@ -380,6 +380,49 @@ async function refreshPageLive(ref) { logLine('info', 'live refresh: ' + ref + ' title="' + pg.title + '"'); return pg; }++// hero-studio#55: the published hero's PNG provenance comment, or null. The+// anchor for cross-session attested re-generates: a hero this studio family+// produced records who approved it, so a re-layout does not need a fresh+// in-session click, only a written attestation that joins the chain.+async function heroProvenance(ref) {+ const REG = process.env.ADOM_WIKI_REGISTRY || 'https://wiki.adom.inc';+ const headers = {};+ try { headers.Authorization = 'Bearer ' + fs.readFileSync('/var/run/adom/api-key', 'utf8').trim(); } catch {}+ let heroPath = 'screenshots/hero.png';+ try {+ const pj = await (await fetch(REG + '/api/pages/' + ref + '/files/page.json', { headers })).json();+ if (pj && pj.hero && pj.hero.path) heroPath = pj.hero.path;+ } catch {}+ const r = await fetch(REG + '/api/pages/' + ref + '/files/' + heroPath, { headers });+ if (!r.ok) return null;+ const buf = Buffer.from(await r.arrayBuffer());+ if (buf.length < 33 || buf.readUInt32BE(0) !== 0x89504e47) return null;+ let i = 8;+ while (i + 12 <= buf.length) {+ const len = buf.readUInt32BE(i);+ const type = buf.toString('latin1', i + 4, i + 8);+ if (type === 'tEXt' || type === 'iTXt') {+ const data = buf.slice(i + 8, i + 8 + len);+ const z = data.indexOf(0);+ if (z > 0 && data.slice(0, z).toString() === 'comment') {+ // iTXt carries 4 extra header bytes + two more null-terminated fields+ let text = data.slice(z + 1);+ if (type === 'iTXt') {+ let j = z + 2; // last byte of compression flag+method pair+ for (let f = 0; f < 2; f += 1) j = data.indexOf(0, j + 1); // language tag, translated keyword+ text = data.slice(j + 1);+ }+ try {+ const meta = JSON.parse(text.toString('utf8'));+ if (String(meta.generator || '').startsWith('hero-studio')) return meta;+ } catch {}+ }+ }+ i += 12 + len;+ }+ return null;+} const server = http.createServer((req, res) => { const url = new URL(req.url, 'http://x'); const p = url.pathname;@@ -885,6 +928,10 @@ 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 (!attest.human || !String(attest.quote || '').trim()) {+ return refuse('the attestation must name the human and quote their actual approval words',+ 'attest: {human: "colby", quote: "ship it"} -- the quote is recorded in the PNG provenance and shown in the Approved banner');+ } let styleGate = null; if (attest.style) { // Batch path (bulk hardware): a saved style carries the human gate@@ -899,14 +946,33 @@ function route(req, res, url, p) { } 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 (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',- 'attest: {human: "colby", quote: "ship it"} -- the quote is recorded in the PNG provenance and shown in the Approved banner');+ // hero-studio#55: a published hero that already carries studio+ // provenance is a standing human approval; a written attestation+ // re-opens Generate for a re-layout and JOINS that chain (the prior+ // stamp is recorded in the new provenance). Without it, one human+ // click per session even when nothing about the approval changed.+ heroProvenance(state.page)+ .then((prior) => {+ if (!prior) {+ return refuse('automated Generate is only allowed for a page a human already generated this session, or whose published hero carries studio provenance',+ 'pose ' + state.page + ' and let the human click Generate once, apply a human-gated style template (attest {style, human, quote}), or publish a studio-stamped hero first: this page\'s current hero has no readable hero-studio provenance to anchor the attestation');+ }+ req._priorProvenance = {+ generator: prior.generator, version: prior.version, ts: prior.ts,+ stamped: prior.stamped !== false,+ attestation: prior.attestation || prior.approval || undefined,+ };+ logLine('info', 'attested re-generate for ' + state.page + ' anchored to the published hero\'s provenance (' + prior.generator + ' ' + (prior.version || '?') + ')');+ proceed();+ })+ .catch((e) => refuse('could not read the published hero\'s provenance: ' + e.message,+ 'the anchor for a cross-session attest is the page\'s current hero; check the page ref and that its hero is reachable, or fall back to an in-session human Generate'));+ return; } }+ proceed();+ return;+ function proceed() { generating = true; const slug = state.mode === 'component' ? ((COMPONENTS.find(c => c.id === state.comp.id) || COMPONENTS[0]).slug)@@ -1000,8 +1066,38 @@ function route(req, res, url, p) { } catch (e) { return finish({ ok: false, error: 'could not read the rendered PNG: ' + e.message, hint: "run 'hero-studio doctor'" }); }+ // hero-studio#55: make the showcase DURABLE. The studio is the only+ // component that reliably holds the showcase bytes at Generate time+ // (the per-thread snapshot cache is not meant to survive), so write+ // them next to the hero and tell the driving AI to push both. Full-hero+ // imports (showcase.full) are saved the same way: those are the ones+ // with no other record at all.+ let showcaseSaved = null;+ if (state.showcase && state.showcase.src) {+ const sm = String(state.showcase.src).match(/^data:image\/(png|jpeg|webp);base64,(.+)$/);+ if (sm) {+ try {+ const sbytes = Buffer.from(sm[2], 'base64');+ const ext = sm[1] === 'jpeg' ? 'jpg' : sm[1];+ const sfile = 'showcase-' + slug + '-' + ts + '.' + ext;+ fs.writeFileSync(path.join(outDir, sfile), sbytes);+ let sw = null, sh = null;+ if (sm[1] === 'png' && sbytes.length > 24 && sbytes.readUInt32BE(0) === 0x89504e47) {+ sw = sbytes.readUInt32BE(16); sh = sbytes.readUInt32BE(20);+ }+ showcaseSaved = {+ file: sfile, url: 'generated/' + sfile,+ path: 'screenshots/showcase.' + ext,+ sha256: crypto.createHash('sha256').update(sbytes).digest('hex'),+ w: sw, h: sh,+ chrome: (state.showcase.chrome || ''), full: !!state.showcase.full, cover: !!state.showcase.cover,+ };+ logLine('info', 'showcase saved to generated/' + sfile + ' (' + Math.round(sbytes.length / 1024) + ' KB, sha256 ' + showcaseSaved.sha256.slice(0, 12) + '...); push it with the hero as ' + showcaseSaved.path);+ } catch (e) { logLine('warn', 'showcase save failed: ' + e.message + ' (the hero still ships; provenance will not name a showcase file)'); }+ }+ } // 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, showcase_used: !!(state.showcase && state.showcase.src), showcase_full: !!(state.showcase && state.showcase.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 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, showcase_used: !!(state.showcase && state.showcase.src), showcase_full: !!(state.showcase && state.showcase.full), showcase: showcaseSaved ? { path: showcaseSaved.path, sha256: showcaseSaved.sha256, w: showcaseSaved.w, h: showcaseSaved.h, chrome: showcaseSaved.chrome, full: showcaseSaved.full, cover: showcaseSaved.cover } : undefined, 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, prior: req._priorProvenance || 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' +@@ -1021,7 +1117,10 @@ function route(req, res, url, p) { 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 };+ lastGen = { seq: genSeq, ok: true, file, url: 'generated/' + file, latest: 'generated/latest.png', ts, slug, showcase: showcaseSaved || undefined,+ hint: showcaseSaved+ ? 'showcase saved to generated/' + showcaseSaved.file + '; push it with the hero so this hero can be regenerated later: copy it into the page clone as ' + showcaseSaved.path + ' and repo push it alongside ' + 'screenshots/hero.png (its path + sha256 are in the hero\'s provenance comment)'+ : undefined }; // hero-studio#5: approval must be a persistent state, not a toast. state.approved = attest ? { page: state.page, file, ts, attested: { human: attest.human, quote: String(attest.quote).slice(0, 300) } }@@ -1035,6 +1134,7 @@ function route(req, res, url, p) { finish(lastGen); } }+ } }); }