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.17.0
10 files changed
+634−61
SKILL.md+43−1@@ -9,6 +9,27 @@ A local web app (plain Node, no dependencies) that renders a full 16:10 hero liv pixel-faithful wiki contexts, and generates the provenance-stamped 2000x1250 PNG the registry's hero gate requires. +## The ai-thread name is REQUIRED (hero-studio#39)++Every server start and every state-changing verb needs the global flag+`--ai-thread "<name>"` FIRST (the settled platform term; see the+app-creator-ai-thread-name skill). Pick a short name a human would recognize+for THIS conversation ("kicad board review", never "agent" or a UUID) and+reuse it on every call to every Adom tool. Anonymous state-changing calls get+a caller_identity_required teaching refusal with the exact retry. Read-only+verbs (state, changes, ls, compose, console) stay open. The name is+self-reported attribution, never authorization.++Lifecycle (hero-studio#38/#39, per the app-creator-server-lifecycle skill):+instances self-reap after 24h idle (`--ttl <hours>`, `--keep-alive` for+daemons) with a lossless final snapshot; snapshots are PER THREAD+(~/.adom/instances/hero-studio/snapshots/), so restarting with the same+--ai-thread restores your pose and parallel threads never see each other's+state. `hero-studio ls` lists live instances (port, thread, task, idle,+version drift after package updates); starting with a thread that already has+a live instance reuses it instead of respawning; `--port auto` binds a free+port. Zombie cleanup is automatic; `stop --port N` remains for manual ends.+ ## The one rule: a human makes the hero An AI never generates a hero on the human's behalf. The canonical flow is the pose@@ -163,7 +184,10 @@ composed checks cannot see imported pixels, and an edge-density scan of the lower band flags text-like structure there (heuristic, hero-studio#22). Quoted prompt-looking text in the image zone warns too: sample prompts belong in page metadata (sample_prompts), which the dynamic overlay previews with a-copy button; a baked prompt is pixels nobody can copy. Contrast checking is+copy button; a baked prompt is pixels nobody can copy. Cover-cropped vitrines+get accounting (#41): the linter reports what percent of the source screenshot+the crop discards and heuristically flags content cut mid-element at the crop+lines (beheaded logos and headings read as broken). Contrast checking is not in v1. ## Verification mode (what the pose gate shows)@@ -220,6 +244,8 @@ hero-studio set fontSize.title=9.5 # dot-paths build nested patches hero-studio set fontSize.leading=0.96 fontSize.gap=1.2 # title line-height + text-block gap hero-studio set headline="Pose. Approve. Ship." # what BAKES as the title; page title stays metadata hero-studio set --list # the valid enum tokens (also in GET /state as `enums`)+hero-studio fit # solve the LARGEST title size with <=3 lines + positive headroom (#40); auto-runs on headline edits, and presets re-fit with their size as a ceiling. If no size makes 3 lines, it fits the hard constraints and tells you to shorten the copy: shorter copy reads bigger+hero-studio vitrine --from-url <url> [--selector <css>] # the studio drives the desktop bridge itself: slot math, DPR, sentinel check, aspect crop (#41); falls back with a clear message when no bridge answers hero-studio preset bridge-family # apply a family design system in one call; no name lists them hero-studio vitrine --file shot.png [--full] [--cover] [--chrome none|fake|bare] hero-studio bg --file tile.png [--cover] # YOUR brand texture as theme 'image'; composed text + linting still apply@@ -274,6 +300,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). +## The hero doctrine: ONE message (hero-studio#43)++1. A hero image gets across exactly ONE message. Six messages = zero messages.+2. That one message IS the title, ideally approaching one word. The vitrine is+ supporting evidence, not the message.+3. White space is not wasted space; negative space is what makes the one+ message land.+4. The subtitle is deliberately small BECAUSE nobody is assumed to read it. If+ the design only works when the subtitle is read, the hero has failed. Same+ reason the lower marquee band stays calm.++The gate walkthrough teaches this per step, and the linter enforces the+measurable parts: vitrine coverage above ~72% of the hero warns, and a+full-import whose zones read as several competing text blocks warns (a 4x3+edge-density grid; heuristic and says so).+ ## 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
bin/hero-studio+15−2@@ -4,17 +4,26 @@ # studio in front of the human and blocks until they click Generate. SELF="$(readlink -f "$0")" DIR="$(cd "$(dirname "$SELF")/.." && pwd)"+if [ "$1" = "--ai-thread" ]; then+ export ADOM_AI_THREAD="$2"+ shift 2+fi case "$1" in pose) shift; exec node "$DIR/pose.mjs" "$@" ;; doctor) shift; exec node "$DIR/doctor.mjs" "$@" ;;- vitrine|set|preset|state|toast|reroll|changes|bg|compose|instances|stop) exec node "$DIR/drive.mjs" "$@" ;;+ vitrine|set|preset|fit|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 # silently START an instance; help must never have side effects. cat <<'HELP' hero-studio: pose package heroes in front of a human, generate approved PNGs - hero-studio start the studio server (foreground; --port <n>)+Every verb takes the global flag --ai-thread "<name>" FIRST (or env+ADOM_AI_THREAD on single-agent boxes): the settled platform term for which+conversation is calling. State-changing verbs refuse without it.++ hero-studio --ai-thread "<name>" [start] start the server (--port <n>|auto, --ttl <hours>, --keep-alive) hero-studio pose <owner/slug> --surface webview|pup open the pose gate and block for approval [--title T] [--brief B] [--type app|skill|component[,more]]@@ -22,6 +31,10 @@ hero-studio: pose package heroes in front of a human, generate approved PNGs [--port <n>] [--timeout SECS] hero-studio doctor verify both render paths, print exact fixes hero-studio vitrine --file <png> [--full] [--cover] [--chrome none|fake|bare]+ hero-studio vitrine --from-url <url> [--selector <css>]+ capture the vitrine ITSELF via the desktop bridge at slot aspect+ hero-studio fit solve the largest title size that fits (<=3 lines, headroom)+ hero-studio ls instance registry: live studios, idle time, version drift hero-studio bg --file <image> [--cover] hero-studio set '<json>' | key=value [...] | set --list hero-studio preset [name] apply a family design system; no name lists them
bridge.mjs+12−7@@ -8,9 +8,13 @@ // 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({+const run = (bin, args, timeout, target, thread) => new Promise((res) => {+ let a = target ? [...args, '--target', target] : [...args];+ // Caller identity rides every call (#42.2): adom-bridge-cli takes the+ // global --ai-thread flag; legacy adom-desktop reads ADOM_AI_THREAD env.+ if (thread && bin === 'adom-bridge-cli') a = ['--ai-thread', thread, ...a];+ const env = thread ? Object.assign({}, process.env, { ADOM_AI_THREAD: thread }) : process.env;+ execFile(bin, a, { timeout, env }, (err, stdout, stderr) => res({ ok: !err, out: String(stdout || ''), errOut: String(stderr || ''),@@ -26,13 +30,14 @@ const which = (bin) => new Promise((res) => { // 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) {+export async function makeBridge(target, thread) {+ thread = thread || process.env.ADOM_AI_THREAD || ''; 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);+ const bl = await run(bin, ['bridge_list'], 15000, target, thread); 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];@@ -49,8 +54,8 @@ export async function makeBridge(target) { 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),+ run(bin, [verbs[suffix], JSON.stringify(args ?? {})], timeout, target, thread), /// plain verb (no JSON args), e.g. bridge_list itself- raw: (verb, timeout = 15000) => run(bin, [verb], timeout, target),+ raw: (verb, timeout = 15000) => run(bin, [verb], timeout, target, thread), }; }
capture.mjsadded+120@@ -0,0 +1,120 @@+#!/usr/bin/env node+// Vitrine capture helper (hero-studio#41): the studio drives the desktop+// browser itself instead of leaving slot math, DPR math, and cropping to the+// driving AI. Opens a dedicated pup session at the right shape, screenshots,+// verifies the shot came from OUR window (sentinel, the #8 wrong-window+// class), and crops to the slot aspect with PIL.+import { execFile } from 'child_process';+import fs from 'fs';+import { makeBridge } from './bridge.mjs';++const sleep = (ms) => new Promise((r) => setTimeout(r, ms));+const SESSION = 'vitrine-shot';++/// captureUrl({ url, selector?, aspect, outFile, target?, thread? })+/// -> { ok, file?, width?, height?, error? }+export async function captureUrl(opts) {+ const { url, selector, aspect, outFile } = opts;+ const bridge = await makeBridge(opts.target || '', opts.thread || '');+ const bad = (error) => ({ ok: false, error });++ const status = await bridge.call('status', {}, 10000);+ if (!status.ok || (status.out + status.errOut).includes('_adom_use_cli_instead')) {+ return bad('the desktop bridge did not answer (' + bridge.generation + '); no host browser to capture with. Capture manually and load it with: hero-studio vitrine --file <shot.png>');+ }+ const hasSession = status.out.includes(SESSION);+ const opened = hasSession+ ? await bridge.call('navigate', { sessionId: SESSION, url }, 30000)+ : await bridge.call('open_window', { sessionId: SESSION, profile: SESSION, url }, 60000);+ if (!opened.ok) return bad('could not open or navigate the capture window');++ const evalIn = async (expr, timeout = 15000) => {+ const r = await bridge.call('eval', { sessionId: SESSION, expr }, timeout);+ return r.ok ? r.out : null;+ };+ let ready = false;+ for (let i = 0; i < 30 && !ready; i++) {+ const r = await evalIn("document.readyState === 'complete' ? 'HS-READY' : 'HS-NO'");+ if (r && r.includes('HS-READY')) ready = true;+ else await sleep(1000);+ }+ if (!ready) return bad('page never became ready at ' + url);++ // Size the window so the VIEWPORT approximates the slot aspect (outer size+ // includes chrome; one correction pass using the measured innerHeight).+ await evalIn('window.resizeTo(1400, Math.round(1400/' + aspect + ') + (window.outerHeight - window.innerHeight))');+ await sleep(1500);+ if (selector) {+ await evalIn("(function(){var el=document.querySelector(" + JSON.stringify(selector) + ");if(el)el.scrollIntoView({block:'center'});return 'ok';})()");+ await sleep(500);+ }++ // Sentinel + geometry: viewport size, DPR, and (optionally) the selector+ // rect, joined as plain numbers to survive CLI JSON wrapping.+ const geomRaw = await evalIn(+ "(function(){var s=document.getElementById('hs-sentinel');if(!s){s=document.createElement('div');" ++ "s.id='hs-sentinel';s.style.cssText='position:fixed;top:0;left:0;width:16px;height:16px;" ++ "background:#ff00fe;z-index:2147483647';document.body.appendChild(s);}" ++ (selector+ ? "var el=document.querySelector(" + JSON.stringify(selector) + ");var b=el?el.getBoundingClientRect():{x:0,y:0,width:window.innerWidth,height:window.innerHeight};"+ : "var b={x:0,y:0,width:window.innerWidth,height:window.innerHeight};") ++ "return 'HSCAP@'+[b.x,b.y,b.width,b.height,window.devicePixelRatio].join(',')+'@PACSH';})()");+ const m = geomRaw && geomRaw.match(/HSCAP@([\d.,\-]+)@PACSH/);+ if (!m) return bad('could not measure the capture region');+ const [gx, gy, gw, gh, dpr] = m[1].split(',').map(Number);+ if (!(gw > 50 && gh > 50)) return bad('capture region measured degenerate (' + gw + 'x' + gh + ')');++ const shot = await bridge.call('screenshot', { sessionId: SESSION }, 45000);+ if (!shot.ok) return bad('screenshot failed');+ let srcPng = null;+ const pathMatch = (shot.out + shot.errOut).match(/[^"\s]+\.png/);+ if (pathMatch && fs.existsSync(pathMatch[0])) srcPng = pathMatch[0];+ if (!srcPng) {+ const b64 = (shot.out + shot.errOut).match(/"(?:data|base64|image|png)"\s*:\s*"([A-Za-z0-9+/=]{200,})"/);+ if (b64) { srcPng = outFile + '.full.png'; fs.writeFileSync(srcPng, Buffer.from(b64[1], 'base64')); }+ }+ if (!srcPng) return bad('screenshot returned neither a readable path nor base64');++ // Crop: sentinel-anchored viewport origin, then the region, then a CENTER+ // crop to the slot aspect (never squeeze).+ const py = [+ 'import sys',+ 'from PIL import Image',+ 'src, out = sys.argv[1], sys.argv[2]',+ 'x, y, w, h, dpr, aspect = [float(v) for v in sys.argv[3:9]]',+ 'im = Image.open(src).convert("RGB")',+ 'W, H = im.size',+ 'ox = oy = -1',+ 'strip = im.crop((0, 0, min(W, 240), min(H, 480)))',+ 'px = strip.load()',+ 'for yy in range(strip.size[1]):',+ ' for xx in range(strip.size[0]):',+ ' r, g, b = px[xx, yy]',+ ' if r > 200 and g < 80 and b > 200:',+ ' ox, oy = xx, yy',+ ' break',+ ' if ox >= 0:',+ ' break',+ 'if ox < 0:',+ ' print("NO-SENTINEL"); sys.exit(3)',+ 'box = (max(0, ox + round(x*dpr)), max(0, oy + round(y*dpr)), min(W, ox + round((x+w)*dpr)), min(H, oy + round((y+h)*dpr)))',+ 'im2 = im.crop(box)',+ 'cw, ch = im2.size',+ 'want = aspect',+ 'if cw / ch > want:',+ ' nw = round(ch * want); x0 = (cw - nw) // 2; im2 = im2.crop((x0, 0, x0 + nw, ch))',+ 'elif cw / ch < want:',+ ' nh = round(cw / want); y0 = (ch - nh) // 2; im2 = im2.crop((0, y0, cw, y0 + nh))',+ 'im2.save(out)',+ 'print("%dx%d" % im2.size)',+ ].join('\n');+ const crop = await new Promise((res) => {+ execFile('python3', ['-c', py, srcPng, outFile, String(gx), String(gy), String(gw), String(gh), String(dpr), String(aspect)],+ { timeout: 30000 }, (err, stdout, stderr) => res({ code: err ? (err.code || 1) : 0, out: String(stdout || ''), err: String(stderr || '') }));+ });+ if (srcPng === outFile + '.full.png') { try { fs.unlinkSync(srcPng); } catch {} }+ if (crop.code === 3) return bad('the screenshot does not contain the capture window (sentinel missing): wrong-window class, shot discarded');+ if (crop.code !== 0 || !fs.existsSync(outFile)) return bad('crop failed: ' + crop.err.trim().split('\n').pop());+ const [cw, ch] = crop.out.trim().split('x').map(Number);+ return { ok: true, file: outFile, width: cw, height: ch };+}
drive.mjs+75−6@@ -14,26 +14,66 @@ import path from 'path'; const pi = process.argv.indexOf('--port'); const PORT = Number((pi >= 0 && process.argv[pi + 1]) || process.env.HERO_STUDIO_PORT || 8846); if (pi >= 0) process.argv.splice(pi, 2);+// Global --ai-thread (#39): the settled platform term; travels as a top-level+// aiThread field on every POST. Env fallback is for single-agent boxes only.+const ti = process.argv.indexOf('--ai-thread');+const AI_THREAD = (ti >= 0 && process.argv[ti + 1]) || process.env.ADOM_AI_THREAD || '';+if (ti >= 0) process.argv.splice(ti, 2); const BASE = 'http://localhost:' + PORT + '/'; const args = process.argv.slice(2); const verb = args.shift(); const jf = async (p, body) => {+ if (body !== undefined && !AI_THREAD) {+ console.error('caller_identity_required: this verb changes state, so it needs the ai-thread name.');+ console.error(' Re-run: hero-studio --ai-thread "<name>" ' + (verb || '') + ' ...');+ console.error(' Pick a short name a HUMAN would recognize for THIS conversation (like');+ console.error(" 'kicad board review', not 'agent' or a UUID) and reuse it on every call.");+ process.exit(1);+ } const r = await fetch(BASE + p, body === undefined ? undefined : { method: 'POST', headers: { 'Content-Type': 'application/json' },- body: JSON.stringify(body),+ body: JSON.stringify(Object.assign({ aiThread: AI_THREAD }, body)), }); const j = await r.json().catch(() => ({}));- if (!r.ok) throw new Error(p + ' -> ' + r.status);+ if (!r.ok) {+ if (j && j.errorCode === 'caller_identity_required') throw new Error(j.error + ' ' + j.hint);+ throw new Error(p + ' -> ' + r.status);+ } return j; }; const die = (msg) => { console.error(msg); process.exit(1); }; try { if (verb === 'vitrine') {+ const ui = args.indexOf('--from-url');+ if (ui >= 0) {+ // #41: the studio drives the capture itself: slot math, DPR, sentinel+ // verification, and aspect cropping all happen here, not in the caller.+ const url2 = args[ui + 1];+ if (!url2) die('usage: hero-studio vitrine --from-url <url> [--selector <css>] [--full] [--cover]');+ const si = args.indexOf('--selector');+ const st = await jf('state');+ const slot = st.slotSize || { w: 1280, h: 960 };+ const { captureUrl } = await import('./capture.mjs');+ const os = await import('os');+ const tmp = path.join(os.tmpdir(), 'hs-vitrine-' + Date.now() + '.png');+ console.error('capturing ' + url2 + ' at slot aspect ' + (slot.w / slot.h).toFixed(2) + ' (' + slot.w + 'x' + slot.h + ') via the desktop bridge...');+ const cap = await captureUrl({+ url: url2, selector: si >= 0 ? args[si + 1] : null,+ aspect: slot.w / slot.h, outFile: tmp,+ target: process.env.ADOM_TARGET || '', thread: AI_THREAD,+ });+ if (!cap.ok) die('ERROR: ' + cap.error);+ const out2 = await jf('vitrine-file', { path: tmp, full: args.includes('--full'), cover: args.includes('--cover'), chrome: '' });+ if (!out2.ok) die('ERROR: ' + out2.error);+ console.log(JSON.stringify({ ok: true, captured: cap.width + 'x' + cap.height, bytes: out2.bytes }));+ console.error('vitrine captured and loaded; the open studio updates within a second');+ process.exit(0);+ } const fi = args.indexOf('--file');- if (fi < 0 || !args[fi + 1]) die('usage: hero-studio vitrine --file <png> [--full] [--cover] [--chrome none|fake]');+ if (fi < 0 || !args[fi + 1]) die('usage: hero-studio vitrine --file <png> | --from-url <url> [--selector <css>] [--full] [--cover] [--chrome none|fake|bare]'); const file = path.resolve(args[fi + 1]); const ci = args.indexOf('--chrome'); const chrome = ci >= 0 ? args[ci + 1] : '';@@ -76,7 +116,7 @@ try { die('usage: hero-studio set \'{"theme":"aurora"}\' | theme=aurora fontSize.title=9 | set --list'); } const r = await fetch(BASE + 'state', {- method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch),+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(Object.assign({ aiThread: AI_THREAD }, patch)), }); const out = await r.json().catch(() => ({})); if (!r.ok || out.ok === false) {@@ -105,7 +145,7 @@ try { // named family design systems (#34.2); no arg lists what exists const name = args.find((a) => !a.startsWith('--')); const r = await fetch(BASE + 'preset', {- method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name || '' }),+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name || '', aiThread: AI_THREAD }), }); const out = await r.json().catch(() => ({})); if (!r.ok || out.ok === false) {@@ -114,6 +154,35 @@ try { } console.log(JSON.stringify(out)); console.error('preset ' + out.applied + ' applied; the open studio updates within a second');+ } else if (verb === 'ls') {+ // the instance registry (#39): live studios, liveness-checked, dead+ // entries pruned, version drift vs the on-disk package flagged+ const fsm = await import('fs');+ const os = await import('os');+ const dir = path.join(os.homedir(), '.adom', 'instances', 'hero-studio');+ const diskVer = JSON.parse(fsm.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version;+ const rows = [];+ let files = [];+ try { files = fsm.readdirSync(dir).filter((f) => f.endsWith('.json')); } catch {}+ for (const f of files) {+ let e;+ try { e = JSON.parse(fsm.readFileSync(path.join(dir, f), 'utf8')); } catch { continue; }+ try { process.kill(e.pid, 0); } catch { try { fsm.unlinkSync(path.join(dir, f)); } catch {}; continue; }+ const idleMin = Math.round((Date.now() - new Date(e.lastActivity || e.startedAt).getTime()) / 60000);+ rows.push({ port: e.port, thread: e.thread, task: e.task, version: e.version, idleMinutes: idleMin, pid: e.pid, drift: e.version !== diskVer });+ }+ rows.sort((a, b) => a.port - b.port);+ console.log(JSON.stringify({ instances: rows, diskVersion: diskVer }, null, 2));+ for (const r2 of rows) {+ if (r2.drift) console.error('WARN: port ' + r2.port + ' runs ' + r2.version + ', disk has ' + diskVer + '. Shut it down (hero-studio stop --port ' + r2.port + ') and respawn.');+ }+ if (!rows.length) console.error('no live instances; hero-studio --ai-thread "<name>" starts one (or pose does)');+ } else if (verb === 'fit') {+ // auto-fit the title (#40): solved in the open surface with real metrics+ const out = await jf('fit', {});+ 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 === 'instances') { // every live studio on this box (#26): scan the port neighborhood const ports = new Set([PORT]);@@ -151,7 +220,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 | state | toast | reroll | changes | bg | compose | instances | stop');+ die('verbs: pose | vitrine | set | preset | fit | 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+2−1@@ -2,7 +2,7 @@ "slug": "hero-studio", "title": "Hero Studio", "type": "app",- "version": "0.16.2",+ "version": "0.17.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": [@@ -81,6 +81,7 @@ "drive.mjs", "doctor.mjs", "bridge.mjs",+ "capture.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.2",+ "version": "0.17.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": [@@ -82,6 +82,7 @@ "drive.mjs", "doctor.mjs", "bridge.mjs",+ "capture.mjs", "bin/hero-studio", "public/**", "data/**",
pose.mjs+49−5@@ -23,10 +23,30 @@ let BASE = 'http://localhost:8846/'; const args = process.argv.slice(2); const flags = {}; const pos = [];+// Valueless flags MUST be declared: the old parser consumed the next token as+// a value, so `--steal --headline "X"` silently dropped the headline (#42).+const BOOL_FLAGS = new Set(['steal', 'fresh-vitrine', 'no-fallback', 'keep-alive']); for (let i = 0; i < args.length; i++) {- if (args[i].startsWith('--')) flags[args[i].slice(2)] = args[i + 1] ?? '', i++;- else pos.push(args[i]);+ if (!args[i].startsWith('--')) { pos.push(args[i]); continue; }+ const name = args[i].slice(2);+ if (BOOL_FLAGS.has(name) || args[i + 1] === undefined || args[i + 1].startsWith('--')) {+ flags[name] = true;+ } else {+ flags[name] = args[i + 1];+ i++;+ }+}++// The ai-thread name (#39): required, forwarded to the server at spawn and on+// every POST, and handed onward to the desktop bridge (#42.2).+const AI_THREAD = flags['ai-thread'] || process.env.ADOM_AI_THREAD || '';+if (!AI_THREAD) {+ console.error('caller_identity_required: pose needs the ai-thread name.');+ console.error(' Re-run: hero-studio --ai-thread "<name>" pose <owner/slug> --surface ...');+ console.error(' Pick a short name a HUMAN would recognize for THIS conversation and reuse it on every call.');+ process.exit(1); }+process.env.ADOM_AI_THREAD = AI_THREAD; const ref = pos[0]; if (!ref || !ref.includes('/')) { console.error('usage: hero-studio pose <owner/slug> --surface webview|pup [--title T] [--brief B] [--type app|skill] [--timeout SECS]');@@ -63,6 +83,11 @@ if (preflight.renderer === 'pup') { } const jf = async (p, opts) => {+ if (opts && opts.method === 'POST') {+ const body = opts.body ? JSON.parse(opts.body) : {};+ if (!body.aiThread) body.aiThread = AI_THREAD;+ opts = Object.assign({}, opts, { body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } });+ } const r = await fetch(BASE + p, opts); if (!r.ok) throw new Error(p + ' -> ' + r.status); return r.json();@@ -82,7 +107,26 @@ const sh = (cmd, a, timeout = 20000) => new Promise((res) => { // a free port so this pose can never clobber another thread's half-reviewed // checklist. let port = Number(flags.port || process.env.HERO_STUDIO_PORT || 8846);-const portPinned = Boolean(flags.port || process.env.HERO_STUDIO_PORT);+let portPinned = Boolean(flags.port || process.env.HERO_STUDIO_PORT);+const INSTANCES_DIR = path.join(process.env.HOME || '/home/adom', '.adom', 'instances', 'hero-studio');+const THREAD_SLUG = AI_THREAD.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'unnamed';+if (!portPinned) {+ try {+ const mine = JSON.parse(fs.readFileSync(path.join(INSTANCES_DIR, THREAD_SLUG + '.json'), 'utf8'));+ process.kill(mine.pid, 0);+ port = mine.port;+ portPinned = true;+ console.error('note: reusing this thread\'s live studio on :' + port + ' (started ' + mine.startedAt + '); hero-studio ls shows all instances');+ } catch {}+ try {+ for (const f of fs.readdirSync(INSTANCES_DIR)) {+ if (!f.endsWith('.json') || f === THREAD_SLUG + '.json') continue;+ const e = JSON.parse(fs.readFileSync(path.join(INSTANCES_DIR, f), 'utf8'));+ try { process.kill(e.pid, 0); } catch { continue; }+ if (e.task === ref) console.error('warning: thread "' + e.thread + '" already has a studio posing ' + ref + ' on :' + e.port + '; duplicate-task is usually a mistake');+ }+ } catch {}+} BASE = 'http://localhost:' + port + '/'; let state = null; try { state = await jf('state'); } catch {}@@ -115,7 +159,7 @@ if (state && !portPinned) { } } if (!state) {- spawn(process.execPath, [path.join(DIR, 'server.js'), '--port', String(port)], { detached: true, stdio: 'ignore', env: process.env }).unref();+ spawn(process.execPath, [path.join(DIR, 'server.js'), '--port', String(port), '--ai-thread', AI_THREAD], { detached: true, stdio: 'ignore', env: process.env }).unref(); for (let i = 0; i < 20 && !state; i++) { await new Promise((r) => setTimeout(r, 500)); try { state = await jf('state'); } catch {}@@ -196,7 +240,7 @@ if (surface === 'pup') { // Adom Desktop pup window. Reuse an existing session for this instance; // never spawn a duplicate window (browser_status first, then // navigate-or-open). Every step's real output is kept for diagnosis.- const bridge = await makeBridge(AD_TARGET);+ const bridge = await makeBridge(AD_TARGET, AI_THREAD); console.error('desktop bridge: ' + bridge.generation + ' (verbs: ' + bridge.verbs.open_window + ' family)'); const steps = []; const status = await bridge.call('status', {}, 15000);
public/index.html+185−28@@ -660,7 +660,7 @@ body[data-render] #hero { width:1600px; max-width:none; aspect-ratio:auto; heigh <button class="opt" data-v="bleed-corner-tr">Corner upper-right</button> <button class="opt" data-v="contained">Contained</button> <button class="opt" data-v="mirror">Mirror (text right)</button>- <button class="opt" data-v="showcase" data-tip="Adom-USB style: the vitrine gets ~65% of the hero behind a gradient scrim">Showcase (65% vitrine)</button>+ <button class="opt" data-v="showcase" data-tip="Adom-USB style: a large vitrine behind a gradient scrim. The title is STILL the message; the scrim exists so the text column keeps its white space">Showcase</button> </div> <div class="hint">The shot may run under the lower third; the overlay scrim covers it. That is the new rule.</div> @@ -935,7 +935,12 @@ let comps = []; let state = null; const $ = (id) => document.getElementById(id);-const send = (patch) => fetch('state', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(patch) }).catch(()=>{});+// Every state-changing POST from this page is attributed to the server's+// 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 send = (patch) => post('state', patch).catch(() => {}); function canonType(t) { return t === 'hardware' ? 'component' : (t || 'app'); } // the LAUNCH spelling for display + bake: the wiki's public name is hardware@@ -1329,7 +1334,7 @@ $('sel-page').addEventListener('change', e => { send({ page: v }); // #30: dropdown rows are pose-time snapshots; pull the live page so a // just-renamed title or brief cannot be ratified stale.- fetch('refresh-page?page=' + encodeURIComponent(v), { method: 'POST' })+ post('refresh-page?page=' + encodeURIComponent(v)) .then(r => r.json()) .then(j => { if (!j || !j.ok) return;@@ -1398,7 +1403,7 @@ function runGenerate() { const b = $('btn-generate'); b.disabled = true; b.textContent = 'Generating...'; toast('Rendering the hero on the server', 'info');- fetch('generate', { method: 'POST' }).then(r => r.json()).then(g => {+ post('generate').then(r => r.json()).then(g => { b.disabled = false; b.textContent = 'Generate hero PNG'; if (g.ok) handleGen(g); // success toast arrives via the live channel else toast('Generate failed: ' + (g.error || 'unknown') + (g.hint ? '. ' + g.hint : ''), 'error');@@ -1484,7 +1489,7 @@ $('slot-size').addEventListener('click', () => { }); $('vitrine-clear').addEventListener('click', () => send({ vitrine: { src: '', full: false } })); $('vitrine-pull').addEventListener('click', () => {- fetch('vitrine-from-page?page=' + encodeURIComponent(state.page), { method: 'POST' })+ post('vitrine-from-page?page=' + encodeURIComponent(state.page)) .then(r => r.json()) .then(j => handleToast({ text: j.ok ? 'Vitrine loaded: ' + j.picked : 'No page screenshot: ' + j.error, kind: j.ok ? 'success' : 'warn', seq: Date.now() })) .catch(() => {});@@ -1493,11 +1498,11 @@ $('vitrine-pull').addEventListener('click', () => { /* verification walkthrough: spotlight each checklist item in place */ const TOUR_STEPS = [ { key: 'title', el: 'el-title', title: 'This is your title',- body: 'It bakes into the image at this exact size. Make sure the words are right and it reads at dock size.' },+ body: 'A hero gets across exactly ONE message, and this is it: the title, ideally approaching one word. Six messages equal zero messages. It bakes at this exact size; make sure it reads at dock size.' }, { key: 'subtitle', el: 'el-sub', title: 'This is your subtitle',- body: 'The one-line value prop. Make sure you are happy with the wording and it is legible on this background.' },+ body: 'Deliberately small BECAUSE it is assumed nobody reads it. If the design only works when someone reads this line, the hero has failed. Check the wording anyway.' }, { key: 'vitrine', el: 'vitrine-img', fallbackEl: 'circuitcanvas', winSel: '.window', title: 'This is your vitrine',- body: 'The app window. It should show YOUR app, not a generic chart. If it looks generic, tell your AI to capture a real screenshot at the slot size shown in the left panel.' },+ body: 'Supporting evidence, NOT the message; the title is the message. It should show YOUR app, not a generic chart, and it should leave white space: negative space is what makes the one message land.' }, { key: 'type', el: 'pill-tr', title: 'This is your type pill', body: 'app / skill / hardware / bootstrap (hardware is the launch name; component is its legacy spelling and survives as a kind). AIs pick this wrong often; if it says the wrong thing, yell at yours now, before it bakes.' }, ];@@ -1696,6 +1701,44 @@ function runLint() { } catch {} } + // 6b. vitrine coverage (#43): the title is the message, the vitrine is+ // supporting evidence. When the vitrine swallows the hero, the hierarchy+ // inverts and white space (what makes the one message land) is gone.+ if (vitrine) {+ const iw = Math.max(0, Math.min(vitrine.right, H.width) - Math.max(vitrine.left, 0));+ const ih = Math.max(0, Math.min(vitrine.bottom, H.height) - Math.max(vitrine.top, 0));+ const cov = (iw * ih) / (H.width * H.height);+ if (cov > 0.72) {+ items.push({ id: 'vitrine-coverage', level: 'warn', el: null, msg: 'the vitrine covers ' + Math.round(cov * 100) + '% of the hero. The title is the ONE message this image sends; the vitrine is supporting evidence. Pick a layout that gives the title its white space' });+ }+ }++ // 6c. cover-crop accounting (#41): a cover fit silently discards source+ // pixels; say how much, and check the crop lines for beheaded content.+ const vimg = $('vitrine-img');+ if (vit2.src && !vit2.full && vit2.cover && vimg && vimg.naturalWidth > 0) {+ const win2 = hero.querySelector('.appbody');+ if (win2 && win2.offsetParent) {+ const wb = win2.getBoundingClientRect();+ const arImg = vimg.naturalWidth / vimg.naturalHeight;+ const arSlot = wb.width / wb.height;+ const discard = 1 - Math.min(arImg / arSlot, arSlot / arImg);+ if (discard > 0.25) {+ const axis = arImg > arSlot ? 'left and right edges' : 'top and bottom edges';+ items.push({ id: 'cover-discard', level: 'warn', el: 'vitrine-img', msg: 'cover fit discards ' + Math.round(discard * 100) + '% of the source screenshot (' + axis + '). Check nothing that matters was cropped out; a screenshot captured AT the slot aspect (left panel size) loses nothing' });+ }+ scanCropEdges(vimg, arSlot);+ if (cropScan.status === 'done' && cropScan.cut) {+ items.push({ id: 'crop-edge-cut', level: 'warn', el: 'vitrine-img', msg: 'content appears cut mid-element at the ' + cropScan.where + ' crop line (edge-density heuristic): logos or headings beheaded by a crop read as broken. Recapture at the slot aspect or move the crop' });+ }+ }+ }++ // 6d. multi-message import (#43): one hero, one message.+ if (vit2.full && vit2.src && bandScan.status === 'done' && bandScan.busyCells >= 6) {+ items.push({ id: 'import-multi-message', level: 'warn', el: 'fullimport-img', msg: 'the imported image reads as ' + bandScan.busyCells + ' of 12 zones text/graphic-dense: that is several competing messages, and a hero sends ONE. The title should be the message; white space is what makes it land' });+ }+ // 7. published headroom (#28): how much calm space is left under the lowest // text before the marquee band, in EXPORT pixels; informational. if (texts.length && state.mode === 'app') {@@ -1777,32 +1820,49 @@ $('mqo-prompt-copy').addEventListener('click', async (e) => { pixels, so the class of hero most likely to violate the band skipped every check. A cheap edge-density scan of the bottom 24% flags text-like structure in the band; it is a heuristic and says so. */-let bandScan = { key: '', status: 'idle', texty: false };+let bandScan = { key: '', status: 'idle', texty: false, busyCells: 0, cells: 12 };+function edgeDensity(g, sx, sy, sw, sh, w, h) {+ // draw a region to a small canvas and return horizontal edge density+ g.canvas.width = w; g.canvas.height = h;+ g.drawImage(g._img, sx, sy, sw, sh, 0, 0, w, h);+ const d = g.getImageData(0, 0, w, h).data;+ let edges = 0, n = 0;+ for (let y = 0; y < h; y++) {+ for (let x = 1; x < w; x++) {+ const i = (y * w + x) * 4, j = i - 4;+ const a = d[i] * .3 + d[i + 1] * .59 + d[i + 2] * .11;+ const b = d[j] * .3 + d[j + 1] * .59 + d[j + 2] * .11;+ if (Math.abs(a - b) > 60) edges++;+ n++;+ }+ }+ return n ? edges / n : 0;+} function scanImportBand(src) { const key = src.length + ':' + src.slice(40, 72); if (bandScan.key === key) return;- bandScan = { key, status: 'pending', texty: false };+ bandScan = { key, status: 'pending', texty: false, busyCells: 0, cells: 12 }; const img = new Image(); img.onload = () => { try {+ const g = document.createElement('canvas').getContext('2d');+ g._img = img;+ // 1. the calm-band check (bottom 24%), unchanged const w = Math.min(800, img.width); const bandH = Math.max(10, Math.round(img.height * 0.24 * (w / img.width)));- const c = document.createElement('canvas');- c.width = w; c.height = bandH;- const g = c.getContext('2d');- g.drawImage(img, 0, img.height * 0.76, img.width, img.height * 0.24, 0, 0, w, bandH);- const d = g.getImageData(0, 0, w, bandH).data;- let edges = 0, n = 0;- for (let y = 0; y < bandH; y++) {- for (let x = 1; x < w; x++) {- const i = (y * w + x) * 4, j = i - 4;- const a = d[i] * .3 + d[i + 1] * .59 + d[i + 2] * .11;- const b = d[j] * .3 + d[j + 1] * .59 + d[j + 2] * .11;- if (Math.abs(a - b) > 60) edges++;- n++;+ bandScan.texty = edgeDensity(g, 0, img.height * 0.76, img.width, img.height * 0.24, w, bandH) > 0.0015;+ // 2. message-block grid (#43): a hero sends ONE message; count how many+ // zones of the upper 76% read as text/graphic-dense. 4x3 grid, cheap.+ let busy = 0;+ for (let gy = 0; gy < 3; gy++) {+ for (let gx = 0; gx < 4; gx++) {+ const dens = edgeDensity(g,+ img.width * gx / 4, img.height * 0.76 * gy / 3,+ img.width / 4, img.height * 0.76 / 3, 200, 100);+ if (dens > 0.003) busy++; } }- bandScan.texty = n > 0 && (edges / n) > 0.0015;+ bandScan.busyCells = busy; bandScan.status = 'done'; } catch { bandScan.status = 'failed'; } scheduleLint();@@ -1811,6 +1871,102 @@ function scanImportBand(src) { img.src = src; } +/* crop-line scan (#41): sample thin strips just inside the visible window of+ a cover-cropped vitrine; text straddling the crop line = beheaded content */+let cropScan = { key: '', status: 'idle', cut: false, where: '' };+function scanCropEdges(vimg, arSlot) {+ const key = vimg.src.length + ':' + arSlot.toFixed(3);+ if (cropScan.key === key) return;+ cropScan = { key, status: 'pending', cut: false, where: '' };+ try {+ const g = document.createElement('canvas').getContext('2d');+ g._img = vimg;+ const iw = vimg.naturalWidth, ih = vimg.naturalHeight;+ const arImg = iw / ih;+ let cut = false, where = '';+ if (arImg > arSlot) {+ const visW = ih * arSlot;+ const cutL = (iw - visW) / 2;+ const strip = Math.max(8, iw * 0.02);+ if (edgeDensity(g, cutL, 0, strip, ih, 24, 300) > 0.004) { cut = true; where = 'left'; }+ if (edgeDensity(g, cutL + visW - strip, 0, strip, ih, 24, 300) > 0.004) { cut = true; where = where ? 'left and right' : 'right'; }+ } else if (arImg < arSlot) {+ const visH = iw / arSlot;+ const cutT = (ih - visH) / 2;+ const strip = Math.max(8, ih * 0.02);+ if (edgeDensity(g, 0, cutT, iw, strip, 300, 24) > 0.004) { cut = true; where = 'top'; }+ if (edgeDensity(g, 0, cutT + visH - strip, iw, strip, 300, 24) > 0.004) { cut = true; where = where ? 'top and bottom' : 'bottom'; }+ }+ cropScan.cut = cut; cropScan.where = where; cropScan.status = 'done';+ } catch { cropScan.status = 'failed'; }+}++/* auto-fit (#40): the largest title size that keeps <=3 lines, positive+ headroom, and no clipping, measured with the REAL layout: sweep the CSS+ var coarse-to-fine, read the boxes, restore. Runs on the fit SSE event+ (drive verb + preset re-fit) and on headline edits; never in render mode. */+function solveTitleFit(maxCqw) {+ const tEl = $('el-title');+ if (!tEl || !tEl.offsetParent || document.body.dataset.render !== undefined) return null;+ const H = hero.getBoundingClientRect();+ if (H.width < 60) return null;+ const bandTop = H.top + H.height * 0.76;+ const measure = (v) => {+ hero.style.setProperty('--fs-title', v + 'cqw');+ void tEl.offsetHeight;+ const b = tEl.getBoundingClientRect();+ const lh = parseFloat(getComputedStyle(tEl).lineHeight) || 1;+ return { b, lines: Math.max(1, Math.round(b.height / lh)) };+ };+ const inHero = (b) => b.bottom < bandTop && b.right <= H.right + 1 && b.left >= H.left - 1 && b.top >= H.top - 1;+ const fits = (v) => { const m = measure(v); return m.lines <= 3 && inHero(m.b); };+ const fitsLoose = (v) => inHero(measure(v).b);+ const hi = Math.min(maxCqw || 13, 13);+ const sweep = (pred) => {+ let best = null;+ for (let v = hi; v >= 5 && best == null; v = Math.round((v - 0.5) * 10) / 10) {+ if (pred(v)) best = v;+ }+ if (best != null && best < hi) {+ for (let v = Math.min(hi, best + 0.4); v > best; v = Math.round((v - 0.1) * 10) / 10) {+ if (pred(v)) { best = v; break; }+ }+ }+ return best;+ };+ let best = sweep(fits);+ let loose = false;+ if (best == null) {+ // Nothing satisfies <=3 lines at any legible size: the headline itself is+ // the problem. Relax to the HARD constraints only (inside the hero, above+ // the band) so the pose is at least never broken, and say so.+ loose = true;+ best = sweep(fitsLoose);+ }+ const cur = (state.fontSize || {}).title || 8;+ hero.style.setProperty('--fs-title', cur + 'cqw');+ if (best == null) return null;+ hero.style.setProperty('--fs-title', best + 'cqw');+ void tEl.offsetHeight;+ const lines = Math.max(1, Math.round(tEl.getBoundingClientRect().height / (parseFloat(getComputedStyle(tEl).lineHeight) || 1)));+ hero.style.setProperty('--fs-title', cur + 'cqw');+ return { size: best, loose, lines };+}+function runFit(maxCqw) {+ const r = solveTitleFit(maxCqw);+ if (r == null) {+ handleToast({ text: 'fit: no title size in 5-13cqw keeps this headline inside the hero for this layout; shorten the headline or change layout', kind: 'warn', seq: Date.now() });+ return;+ }+ const cur = (state.fontSize || {}).title || 8;+ if (Math.abs(r.size - cur) >= 0.05) send({ fontSize: { title: r.size } });+ if (r.loose) {+ handleToast({ text: 'fit: this headline cannot make 3 lines at any legible size; set ' + r.size + 'cqw (' + r.lines + ' lines, still clear of the band). SHORTER COPY READS BIGGER: the title is the one message, aim for a few words', kind: 'warn', seq: Date.now() });+ } else if (Math.abs(r.size - cur) >= 0.05) {+ handleToast({ text: 'fit: title ' + r.size + 'cqw (largest size with <=3 lines and positive headroom for this headline + layout)', kind: 'info', seq: Date.now() });+ }+}+ /* the stagebar (#21/#23): explicit dynamic on/off, keyboard D, legend off-stage */ $('sb-png').addEventListener('click', () => send({ marqueePreview: false })); $('sb-mq').addEventListener('click', () => send({ marqueePreview: true }));@@ -1859,7 +2015,7 @@ function pushPageField() { const [owner, slug] = (state.page || '/').split('/'); const body = { owner, slug, title: $('pf-title').value.trim(), brief: $('pf-brief').value.trim(), tags: $('pf-tags').value.split(',').map(t => t.trim()).filter(Boolean) };- fetch('api/pages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })+ post('api/pages', body) .then(() => fetch('api/pages')).then(r => r.json()).then(rows => { pages = rows; render(); refreshChangesBadge(); }) .catch(() => {}); }@@ -1868,7 +2024,7 @@ for (const id of ['pf-title', 'pf-brief', 'pf-tags']) { $(id).addEventListener('blur', () => { pfEditing = false; pushPageField(); }); } $('pf-apply').addEventListener('click', async () => {- const r = await (await fetch('apply-page', { method: 'POST' })).json().catch(() => ({}));+ const r = await (await post('apply-page')).json().catch(() => ({})); if (!r.ok) { handleToast({ text: r.error || 'nothing to apply', kind: 'warn', seq: Date.now() }); return; } handleToast({ text: r.applied === 'wiki' ? 'Page edits applied to the wiki (' + r.fields.join(', ') + ')'@@ -1969,7 +2125,7 @@ function drawCircuitry() { g.stroke(); } }-$('btn-reroll').addEventListener('click', () => { fetch('reroll', { method: 'POST' }).catch(() => {}); });+$('btn-reroll').addEventListener('click', () => { post('reroll').catch(() => {}); }); /* image-zone type sizes: instant local feedback, debounced send */ let fsTimer = null;@@ -1977,7 +2133,7 @@ let hlTimer = null; $('headline-input').addEventListener('input', e => { clearTimeout(hlTimer); const v = e.target.value;- hlTimer = setTimeout(() => send({ headline: v }), 350);+ hlTimer = setTimeout(() => { send({ headline: v }); setTimeout(() => runFit(null), 250); }, 350); }); for (const [id, key] of [['fs-title', 'title'], ['fs-sub', 'sub'], ['fs-desc', 'desc'], ['fs-leading', 'leading'], ['fs-gap', 'gap']]) { $(id).addEventListener('input', e => {@@ -2020,6 +2176,7 @@ Promise.all([ } const es = new EventSource('events'); es.addEventListener('state', e => { state = JSON.parse(e.data); render(); });+ es.addEventListener('fit', e => { let m = null; try { m = (JSON.parse(e.data) || {}).max; } catch {} setTimeout(() => runFit(m), 80); }); es.addEventListener('toast', e => { handleToast(JSON.parse(e.data)); }); es.addEventListener('generated', e => { handleGen(JSON.parse(e.data)); }); // poll fallback in case the proxy buffers the event stream
server.js+131−10@@ -4,11 +4,30 @@ const fs = require('fs'); const path = require('path'); const { spawn, execFile } = require('child_process'); -// Port: --port flag > HERO_STUDIO_PORT env > 8846. Multiple instances on-// separate ports = concurrent studios (hero-studio#15): one thread's pose-// cannot clobber another's when each pose owns its own server.+// Port: --port flag > HERO_STUDIO_PORT env > 8846 ('auto' = bind an OS-picked+// free port). Multiple instances on separate ports = concurrent studios+// (hero-studio#15): one thread's pose cannot clobber another's. const portArg = process.argv.indexOf('--port');-const PORT = Number((portArg >= 0 && process.argv[portArg + 1]) || process.env.HERO_STUDIO_PORT || 8846);+const portRaw = String((portArg >= 0 && process.argv[portArg + 1]) || process.env.HERO_STUDIO_PORT || 8846);+let PORT = portRaw === 'auto' ? 0 : Number(portRaw);++// The ai-thread name (#39, the settled platform term: see the+// app-creator-ai-thread-name skill): required at start, self-reported+// attribution only, never authorization. Precedence: --ai-thread flag, then+// ADOM_AI_THREAD env (single-agent containers only).+const argVal = (f) => { const i = process.argv.indexOf(f); return i >= 0 ? process.argv[i + 1] : undefined; };+const AI_THREAD = argVal('--ai-thread') || process.env.ADOM_AI_THREAD || '';+if (!AI_THREAD) {+ console.error('caller_identity_required: hero-studio requires the ai-thread name at start.');+ console.error(' Re-run: hero-studio --ai-thread "<name>" [--port N]');+ console.error(' Pick a short name a HUMAN would recognize for THIS conversation');+ console.error(' (like "kicad board review", not "agent" or a UUID) and reuse it on every call.');+ process.exit(1);+}+const THREAD_SLUG = AI_THREAD.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'unnamed';+const TTL_HOURS = Number(process.env.APP_TTL_HOURS || argVal('--ttl') || 24);+const KEEP_ALIVE = process.argv.includes('--keep-alive');+const INSTANCES_DIR = path.join(process.env.HOME || '/home/adom', '.adom', 'instances', 'hero-studio'); const ROOT = path.join(__dirname, 'public'); const PAGES = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'pages.json'), 'utf8')); // The provenance stamp reads the REAL version so it stays truthful across@@ -133,6 +152,7 @@ function merge(dst, src) { } function readBody(req, cb) {+ if (req._body !== undefined) return cb(null, req._body); let b = ''; req.on('data', (c) => { b += c; }); req.on('end', () => {@@ -143,8 +163,11 @@ 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 {}+// Per-thread snapshot (#39): 13 instances sharing one snapshot file clobbered+// each other and swapped approval records between pages; the thread key makes+// parallel instances isolated at the persistence layer, not just in memory.+const SNAP = path.join(INSTANCES_DIR, 'snapshots', THREAD_SLUG + '.json');+try { fs.mkdirSync(path.join(INSTANCES_DIR, 'snapshots'), { recursive: true }); } catch {} try { const snap = JSON.parse(fs.readFileSync(SNAP, 'utf8')); if (snap && snap.state && Date.now() - (snap.ts || 0) < 24 * 3600 * 1000) {@@ -161,23 +184,91 @@ function saveSnapshot() { }, 400); } +// Instance registry (#39): ~/.adom/instances/hero-studio/<thread>.json makes+// every live studio discoverable (`hero-studio ls`), reusable, and auditable.+const REG_FILE = path.join(INSTANCES_DIR, THREAD_SLUG + '.json');+function writeRegistry() {+ try {+ fs.writeFileSync(REG_FILE, JSON.stringify({+ pid: process.pid, port: PORT, thread: AI_THREAD, task: state.page,+ version: PKG_VERSION, startedAt: STARTED_AT, lastActivity: new Date(lastActivity).toISOString(),+ }, null, 1));+ } catch {}+}+function deregisterInstance() { try { fs.unlinkSync(REG_FILE); } catch {} }+const STARTED_AT = new Date().toISOString();+let lastActivity = Date.now();++function removeWebviewTab() {+ const name = PORT === 8846 ? 'Hero Studio' : 'Hero Studio :' + PORT;+ try {+ execFile('adom-cli', ['hydrogen', 'webview', 'remove-tab', '--name', name], { timeout: 8000 }, () => {});+ } catch {}+}++// Idle reaper (#38): agents forget, so the server manages itself. Any HTTP+// touch resets the clock; a slowly-finished pose survives days, an abandoned+// one is gone in TTL_HOURS with a lossless snapshot (#39 restores it).+function reap() {+ if (generating) return; // finish pending renders; next tick reaps+ logLine('info', 'idle ' + TTL_HOURS + 'h: reaping (snapshot saved; restart with the same --ai-thread to resume)');+ try { fs.writeFileSync(SNAP, JSON.stringify({ ts: Date.now(), state, changes })); } catch {}+ deregisterInstance();+ removeWebviewTab();+ setTimeout(() => process.exit(0), 300);+}+if (!KEEP_ALIVE) {+ setInterval(() => {+ writeRegistry();+ if (Date.now() - lastActivity > TTL_HOURS * 3600e3) reap();+ }, 60e3).unref();+}+process.on('SIGTERM', () => { deregisterInstance(); process.exit(0); });+process.on('SIGINT', () => { deregisterInstance(); process.exit(0); });+ const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2', }; +// Read-only endpoints stay open (observation and debugging are never gated);+// every state-changing POST must carry the ai-thread name.+const THREAD_EXEMPT = new Set(['/console', '/events']); const server = http.createServer((req, res) => { const url = new URL(req.url, 'http://x'); const p = url.pathname;+ lastActivity = Date.now();+ if (req.method === 'POST' && !THREAD_EXEMPT.has(p)) {+ return readBody(req, (err, body) => {+ if (err) { res.writeHead(400); return res.end(JSON.stringify({ ok: false, error: 'bad json' })); }+ const name = (body && body.aiThread) || url.searchParams.get('aiThread') || '';+ if (!name) {+ res.writeHead(400, { 'Content-Type': 'application/json' });+ return res.end(JSON.stringify({+ ok: false, errorCode: 'caller_identity_required',+ error: 'This app requires every AI caller to give its ai-thread name.',+ hint: 'Re-run with the global flag: hero-studio --ai-thread "<name>" <verb>, or add a top-level "aiThread" field if you are POSTing directly. Pick a short name a HUMAN would recognize for THIS conversation (like \'kicad board review\', not \'agent\' or a UUID) and reuse it on every call to every Adom tool.',+ }));+ }+ req.aiThread = String(name);+ req._body = body;+ logLine('info', 'thread "' + req.aiThread + '": POST ' + p);+ route(req, res, url, p);+ });+ }+ route(req, res, url, p);+});++function route(req, res, url, p) { 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, version: PKG_VERSION })));+ return res.end(JSON.stringify(Object.assign({}, state, { _toast: lastToast, _gen: lastGen, enums: ENUMS, version: PKG_VERSION, aiThread: AI_THREAD }))); } if (p === '/version') { res.writeHead(200, { 'Content-Type': 'application/json' });- return res.end(JSON.stringify({ version: PKG_VERSION, port: PORT }));+ return res.end(JSON.stringify({ version: PKG_VERSION, port: PORT, thread: AI_THREAD, ttlHours: KEEP_ALIVE ? null : TTL_HOURS, startedAt: STARTED_AT })); } if (p === '/state' && req.method === 'POST') { return readBody(req, (err, body) => {@@ -340,6 +431,9 @@ const server = http.createServer((req, res) => { notePoseDiff(before); logLine('info', 'preset applied: ' + name); broadcast('state', state);+ // #40: a preset's title size is a ceiling, not a constant; blind 12cqw+ // in a showcase column took a real pose from 3 warns to 2 hard fails.+ broadcast('fit', { max: (PRESETS[name].fontSize || {}).title || null }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true, applied: name, patch: PRESETS[name] })); });@@ -347,6 +441,8 @@ const server = http.createServer((req, res) => { if (p === '/shutdown' && req.method === 'POST') { // instance lifecycle (#26): a polite stop verb so zombies don't accumulate logLine('info', 'shutdown requested');+ try { fs.writeFileSync(SNAP, JSON.stringify({ ts: Date.now(), state, changes })); } catch {}+ deregisterInstance(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true, port: PORT })); setTimeout(() => process.exit(0), 150);@@ -399,6 +495,15 @@ const server = http.createServer((req, res) => { }); return; }+ if (p === '/fit' && req.method === 'POST') {+ if (!clients.size) {+ res.writeHead(200, { 'Content-Type': 'application/json' });+ return res.end(JSON.stringify({ ok: false, error: 'no open studio surface to measure in', hint: 'fit solves with real text metrics, so it needs the studio page open (pose opens one); the result lands in state.fontSize.title within a second' }));+ }+ broadcast('fit', { max: (req._body && req._body.max) || null });+ res.writeHead(200, { 'Content-Type': 'application/json' });+ return res.end(JSON.stringify({ ok: true, hint: 'solving in the open surface; read state.fontSize.title (and the fit toast) in about a second' }));+ } if (p === '/reroll' && req.method === 'POST') { state.circuitSeed = (state.circuitSeed * 48271 + Date.now()) % 2147483647; broadcast('state', state);@@ -655,7 +760,7 @@ const server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': MIME[path.extname(full)] || 'application/octet-stream' }); res.end(buf); });-});+} server.on('error', (err) => { if (err && err.code === 'EADDRINUSE') {@@ -665,4 +770,20 @@ server.on('error', (err) => { } throw err; });-server.listen(PORT, () => console.log('hero-studio on ' + PORT));++// Reuse instead of respawn (#39): a live instance for THIS thread means use+// it, not another spawn; the forgotten-studio class dies here.+try {+ const prev = JSON.parse(fs.readFileSync(REG_FILE, 'utf8'));+ if (prev && prev.pid && prev.pid !== process.pid) {+ process.kill(prev.pid, 0); // throws if dead+ console.log('OK: already running on port ' + prev.port + ' (started ' + prev.startedAt + ', task ' + (prev.task || '?') + '). Use that instance.');+ process.exit(0);+ }+} catch {}++server.listen(PORT, () => {+ PORT = server.address().port;+ writeRegistry();+ console.log('hero-studio on ' + PORT + ' (thread "' + AI_THREAD + '", ttl ' + (KEEP_ALIVE ? 'off' : TTL_HOURS + 'h') + ')');+});