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.16.1
6 files changed
+107−5
SKILL.md+16@@ -274,6 +274,22 @@ every overlay off, Generate BYPASSES the browser entirely (provenance `renderer: passthrough`): your pixels are decoded, normalized to 2000x1250, and stamped, with zero recompression drift (hero-studio#36). +## Brand doctrine (what makes a hero look like the family)++Written here because it used to live only in the studio's internal CSS and+every full-import thread rediscovered it the hard way (hero-studio#32):++- Hero TITLES are Familjen Grotesk 700. Body copy (subtitle, detail) is+ Satoshi. A full import with a different title face will read as off-family+ even when nobody can say why.+- Optical left alignment: the title's INK edge aligns with the text block,+ not its bounding box; glyph side bearing shifts pixels, and the linter+ measures it (canvas ink measurement) in composed mode. Imports must check+ it themselves; John's phone caught a 13px miss the box math called clean.+- The lower ~24% is the dynamic marquee band: keep it calm.+- The family preset (`hero-studio preset bridge-family`) carries the measured+ constants (12cqw title, bare right-side vitrine, overlays off).+ ## What the human controls - Layout (vitrine bleeds, contained, mirror), theme, gradient angle.
bridge.mjsadded+56@@ -0,0 +1,56 @@+#!/usr/bin/env node+// Desktop-bridge adapter (hero-studio#31): the Adom Bridge v2 rename replaced+// the `adom-desktop` binary with `adom-bridge-cli` and moved pup's verbs to a+// `pup_*` prefix -- and the relay now refuses raw HTTP posts entirely. This+// module is the ONE place that knows which binary and which verb names a box+// has: it prefers adom-bridge-cli, resolves the live verb names from+// bridge_list output (manifests have claimed prefixes the box does not+// answer, so never hardcode), and falls back to legacy adom-desktop.+import { execFile } from 'child_process';++const run = (bin, args, timeout, target) => new Promise((res) => {+ const a = target ? [...args, '--target', target] : args;+ execFile(bin, a, { timeout }, (err, stdout, stderr) => res({+ ok: !err,+ out: String(stdout || ''),+ errOut: String(stderr || ''),+ fail: err ? (err.killed ? 'timed out after ' + timeout + 'ms' : 'exit ' + (err.code ?? '?')) : '',+ }));+});++const which = (bin) => new Promise((res) => {+ execFile('sh', ['-c', 'command -v ' + bin], { timeout: 4000 }, (err) => res(!err));+});++// The verbs the studio uses, by suffix; the adapter resolves each suffix to+// whatever full name the box actually answers on (pup_*, browser_*, ...).+const SUFFIXES = ['open_window', 'navigate', 'screenshot', 'eval', 'status', 'alert_window'];++export async function makeBridge(target) {+ const hasNew = await which('adom-bridge-cli');+ const bin = hasNew ? 'adom-bridge-cli' : 'adom-desktop';+ const verbs = {};+ if (hasNew) {+ // Resolve live verb names from bridge_list; a suffix seen there wins.+ const bl = await run(bin, ['bridge_list'], 15000, target);+ for (const sfx of SUFFIXES) {+ const m = (bl.out + bl.errOut).match(new RegExp('\\b([a-z][a-z0-9]*)_' + sfx + '\\b'));+ if (m) verbs[sfx] = m[0];+ }+ // pup_* is the known-good prefix on renamed boxes; fill unresolved+ // suffixes with it rather than the legacy browser_* names.+ for (const sfx of SUFFIXES) if (!verbs[sfx]) verbs[sfx] = 'pup_' + sfx;+ } else {+ for (const sfx of SUFFIXES) verbs[sfx] = 'browser_' + sfx;+ }+ return {+ bin,+ generation: hasNew ? 'adom-bridge-cli' : 'adom-desktop (legacy)',+ verbs,+ /// call('open_window', {...}) -> {ok, out, errOut, fail}; args JSON-encoded+ call: (suffix, args, timeout = 20000) =>+ run(bin, [verbs[suffix], JSON.stringify(args ?? {})], timeout, target),+ /// plain verb (no JSON args), e.g. bridge_list itself+ raw: (verb, timeout = 15000) => run(bin, [verb], timeout, target),+ };+}
package.json+2−1@@ -2,7 +2,7 @@ "slug": "hero-studio", "title": "Hero Studio", "type": "app",- "version": "0.16.0",+ "version": "0.16.1", "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": [@@ -80,6 +80,7 @@ "pose.mjs", "drive.mjs", "doctor.mjs",+ "bridge.mjs", "bin/hero-studio", "public/**", "data/**",
page.json+2−1@@ -2,7 +2,7 @@ "slug": "hero-studio", "title": "Hero Studio", "type": "app",- "version": "0.16.0",+ "version": "0.16.1", "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": [@@ -81,6 +81,7 @@ "pose.mjs", "drive.mjs", "doctor.mjs",+ "bridge.mjs", "bin/hero-studio", "public/**", "data/**",
public/index.html+1−1@@ -1616,7 +1616,7 @@ function runLint() { const vit2 = state.vitrine || {}; if (vit2.full && vit2.src) { scanImportBand(vit2.src);- items.push({ id: 'import-unlinted', level: 'warn', el: 'fullimport-img', msg: 'full-hero import: composed-layout checks cannot inspect imported pixels; verify the lower band and margins by eye before ratifying' });+ items.push({ id: 'import-unlinted', level: 'warn', el: 'fullimport-img', msg: 'full-hero import: composed-layout checks cannot inspect imported pixels; verify by eye before ratifying. Doctrine still applies (#32): title set in Familjen Grotesk 700, body copy in Satoshi, optical left alignment on the ink edge, calm lower band' }); if (bandScan.status === 'done' && bandScan.texty) { items.push({ id: 'import-band-text', level: 'warn', el: 'fullimport-img', msg: 'the imported image appears to carry text or graphics in the lower dynamic band (heuristic edge scan): the homepage overlays its own info there, keep the band calm' }); }
server.js+30−2@@ -55,6 +55,7 @@ function noteChange(bucket, field, from, to) { const orig = cur ? cur.from : from; if (j(orig) === j(to)) delete changes[bucket][field]; else changes[bucket][field] = { from: orig, to };+ saveSnapshot(); } const POSE_TRACKED = [ 'mode', 'theme', 'layout', 'gradAngle', 'titleMode',@@ -113,6 +114,7 @@ let generating = false; function broadcast(event, data) { const msg = 'event: ' + event + '\ndata: ' + JSON.stringify(data) + '\n\n'; for (const res of clients) res.write(msg);+ if (event === 'state') saveSnapshot(); } function logLine(level, text) {@@ -138,6 +140,27 @@ function readBody(req, cb) { }); } +// Snapshot persistence (hero-studio#32.9): a cache-refresh restart used to+// wipe the reviewer's ticked checklist, staged imports, and the change ledger.+// Every state change debounce-writes a snapshot; boot restores it when fresh.+const SNAP = path.join(__dirname, 'generated', 'state-snapshot.json');+try { fs.mkdirSync(path.join(__dirname, 'generated'), { recursive: true }); } catch {}+try {+ const snap = JSON.parse(fs.readFileSync(SNAP, 'utf8'));+ if (snap && snap.state && Date.now() - (snap.ts || 0) < 24 * 3600 * 1000) {+ merge(state, snap.state);+ if (snap.changes) changes = snap.changes;+ logLine('info', 'restored session snapshot (' + (snap.state.page || '?') + ')');+ }+} catch {}+let snapTimer = null;+function saveSnapshot() {+ clearTimeout(snapTimer);+ snapTimer = setTimeout(() => {+ try { fs.writeFileSync(SNAP, JSON.stringify({ ts: Date.now(), state, changes })); } catch {}+ }, 400);+}+ const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png',@@ -150,7 +173,11 @@ 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, _gen: lastGen, enums: ENUMS })));+ return res.end(JSON.stringify(Object.assign({}, state, { _toast: lastToast, _gen: lastGen, enums: ENUMS, version: PKG_VERSION })));+ }+ if (p === '/version') {+ res.writeHead(200, { 'Content-Type': 'application/json' });+ return res.end(JSON.stringify({ version: PKG_VERSION, port: PORT })); } if (p === '/state' && req.method === 'POST') { return readBody(req, (err, body) => {@@ -579,7 +606,8 @@ const server = http.createServer((req, res) => { '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' ++ 'if im.size[0] != 2000:\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) => {