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.4
7 files changed
+118−20
SKILL.md+14−3@@ -262,6 +262,15 @@ off the box edge), and an informational published-headroom line (export px between the lowest text and the marquee band). Findings render in the gate and publish to `state.lint` ([{id, level: warn|fail, msg}]); the AI should fix warns without being asked.+`state.lintMeta` says whether those findings are TRUSTWORTHY (hero-studio#51):+{computedAt, poseRev, source: surface|compose, stale}. stale:true means the+pose changed after the findings were measured (or nothing measured them yet);+never act on stale findings, and never read an empty stale lint as all-clear.+`hero-studio compose` (GET /compose.png) re-measures lint SERVER-SIDE in the+same headless render, fresh by construction, with no dependency on an open+surface; the automated Generate path refuses stale lint outright. Text+geometry is measured from the RENDERED GLYPHS (Range rects), not element+boxes, so a long word overflowing its column into the showcase is caught. FULL-HERO IMPORTS are never reported all-clear: the linter discloses that composed checks cannot see imported pixels, and an edge-density scan of the lower band flags text-like structure there (heuristic, hero-studio#22).@@ -350,9 +359,11 @@ cannot be ratified. Invalid enum values are REJECTED with the valid list (400 from /state); an ok:true means the value really landed. state.lint is computed by the OPEN-studio page, so it is empty until a browser surface has rendered the pose;-once one is open, every gate finding (layout linter, full-import caveats,-band scan) mirrors there for the driving AI to fix before review.+studio page OR by a headless compose (whichever ran last; state.lintMeta.source+says which, and .stale says whether the pose has moved since). A surface can+be open yet unresponsive: state.fitPending stays set when a /fit request got+no answer, and lintMeta.stale exposes a surface that stopped re-rendering.+When in doubt, `hero-studio compose` is the reliable measurement path. The showcase's FAKE titlebar is auto-off whenever an imported screenshot is present: a real window shot carries its own chrome, and stacking the fake bar
drive.mjs+13−1@@ -280,8 +280,20 @@ try { if (!r.ok) die('ERROR: compose render failed (' + r.status + '): ' + (await r.text()).slice(0, 300)); const buf = Buffer.from(await r.arrayBuffer()); fsm.writeFileSync(outFile, buf);- console.log(JSON.stringify({ ok: true, file: outFile, bytes: buf.length }));+ // hero-studio#51: compose measures lint server-side in the same render;+ // print the fresh findings so this verb IS the reliable gate read.+ let lint = null, lintMeta = null;+ try {+ const st = await (await fetch(BASE + 'state')).json();+ lint = st.lint || [];+ lintMeta = st.lintMeta || null;+ } catch {}+ console.log(JSON.stringify({ ok: true, file: outFile, bytes: buf.length, lint, lintMeta })); console.error('composed preview written (no provenance stamp; the pose gate + Generate remain the only publish path)');+ if (lint) {+ for (const l of lint) console.error('lint ' + l.level + ' [' + l.id + '] ' + l.msg);+ if (lintMeta && !lintMeta.stale) console.error('lint measured fresh by this compose (poseRev ' + lintMeta.poseRev + ')');+ } } else { die('verbs: pose | showcase | set | preset | fit | style | batch | state | toast | reroll | changes | bg | compose | ls | instances | stop'); }
package.json+1−1@@ -2,7 +2,7 @@ "slug": "hero-studio", "title": "Hero Studio", "type": "app",- "version": "0.20.3",+ "version": "0.20.4", "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.3",+ "version": "0.20.4", "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/js/app.js+34−9@@ -728,13 +728,43 @@ let lastLintKey = ''; function scheduleLint() { clearTimeout(lintTimer); lintTimer = setTimeout(runLint, 450); } function runLint() { if (!state) return;+ const items = computeLint();+ if (!items) return;+ return items;+}+window.__computeLint = () => (computeLint() || []).map(({ id, level, msg }) => ({ id, level, msg }));+/* The pure measurement, shared with the server's headless compose render+ (hero-studio#51): the server evaluates window.__computeLint in the same+ page code it screenshots, so measured lint reflects the EXACT composed+ pose with no dependency on an open, responsive surface. */+function computeLint() {+ if (!state) return null; const hero = $('hero'); const H = hero.getBoundingClientRect();- if (H.width < 60) return;+ if (H.width < 60) return null; const items = []; const rel = (r) => ({ left: r.left - H.left, top: r.top - H.top, right: r.right - H.left, bottom: r.bottom - H.top, width: r.width, height: r.height }); const vis = (el) => el && el.offsetParent && el.getBoundingClientRect().width > 2; const boxOf = (el) => rel(el.getBoundingClientRect());+ // hero-studio#51: an element box is the LAYOUT column; glyphs overflow it+ // (a long word at a big size escapes right) and box-vs-box overlap misses+ // exactly the collision a human sees. Range client rects measure the+ // rendered text lines themselves, overflow included.+ const textBoxOf = (el) => {+ try {+ const range = document.createRange();+ range.selectNodeContents(el);+ const rects = Array.from(range.getClientRects()).filter((r) => r.width > 1 && r.height > 1);+ if (!rects.length) return boxOf(el);+ const u = {+ left: Math.min.apply(null, rects.map((r) => r.left)),+ top: Math.min.apply(null, rects.map((r) => r.top)),+ right: Math.max.apply(null, rects.map((r) => r.right)),+ bottom: Math.max.apply(null, rects.map((r) => r.bottom)),+ };+ return rel({ left: u.left, top: u.top, right: u.right, bottom: u.bottom, width: u.right - u.left, height: u.bottom - u.top });+ } catch { return boxOf(el); }+ }; const overlapPx = (a, b) => Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left)) * Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top)); const ptrEl = $('pill-tr');@@ -743,7 +773,7 @@ function runLint() { const showcase = (winEl && winEl.offsetParent && state.mode !== 'component') ? boxOf(winEl) : null; const texts = [['title', 'el-title'], ['subtitle', 'el-sub'], ['detail line', 'el-desc']] .filter(([, id]) => vis($(id)))- .map(([name, id]) => ({ name, id, box: boxOf($(id)) }));+ .map(([name, id]) => ({ name, id, box: textBoxOf($(id)) })); // 1. pill vs showcase / pill vs baked text (#17). Pill placement is // contract-fixed top right, so the in-contract fix is a backdrop scrim@@ -889,14 +919,9 @@ function runLint() { items.push({ id: 'headroom', level: 'info', msg: 'published headroom: ' + px + 'px between the lowest image-zone text and the marquee band at export scale (2000x1250)' }); } - renderLintBox(items);- // publish to the server (deduped) so the driving AI sees measured findings- const key = JSON.stringify(items.map(i => i.id + i.level));- if (key !== lastLintKey) {- lastLintKey = key;- send({ lint: items.map(({ id, level, msg }) => ({ id, level, msg })) });- }+ return items; }+window.__computeLint = () => (computeLint() || []).map(({ id, level, msg }) => ({ id, level, msg })); function renderLintBox(items) { const box = $('lintbox'); if (!state.verify) { box.innerHTML = ''; return; }
render-hero.mjs+8@@ -20,6 +20,14 @@ try { const hero = await page.$('#hero'); const box = await hero.boundingBox(); await page.screenshot({ path: out, clip: box });+ // Measured lint from the SAME page code the surface runs (hero-studio#51):+ // the server parses this line and stores it as fresh-by-construction.+ try {+ const lint = await page.evaluate(() => (window.__computeLint ? window.__computeLint() : null));+ if (lint) console.log('LINT_JSON ' + JSON.stringify(lint));+ } catch (e) {+ console.log('LINT_JSON_ERR ' + String(e && e.message || e));+ } console.log('OK ' + out); } finally { await browser.close();
server.js+47−5@@ -150,6 +150,13 @@ let generating = false; // an ETag so pollers 304 instead of re-downloading multi-MB bodies; the // serialized payload is cached per rev and variant. let stateRev = 1;+// hero-studio#51: lint freshness. poseRev bumps on every pose-affecting+// mutation; lintMeta records which poseRev the stored lint measured, so+// state can say STALE instead of serving findings for a pose that no+// longer exists. fitPending exposes a fit request no surface answered.+let poseRev = 1;+let lintMeta = null;+let fitPending = null; const stateBodyCache = {}; // Lean variant: staged imports are multi-MB base64 data URLs inside state.@@ -220,6 +227,7 @@ try { fs.mkdirSync(path.join(INSTANCES_DIR, 'snapshots'), { recursive: true }); try { const snap = JSON.parse(fs.readFileSync(SNAP, 'utf8')); if (snap && snap.state && Date.now() - (snap.ts || 0) < 24 * 3600 * 1000) {+ poseRev += 1; merge(state, snap.state); if (snap.changes) changes = snap.changes; logLine('info', 'restored session snapshot (' + (snap.state.page || '?') + ')');@@ -402,17 +410,21 @@ function route(req, res, url, p) { if (p === '/state' && req.method === 'GET') { const lean = url.searchParams.get('src') === 'ref'; const variant = lean ? 'lean' : 'full';- const etag = '"' + stateRev + '-' + variant + '"';+ const etag = '"' + stateRev + '-' + poseRev + '-' + (lintMeta ? lintMeta.poseRev : 0) + '-' + (fitPending ? 1 : 0) + '-' + variant + '"'; if (req.headers['if-none-match'] === etag) { res.writeHead(304, { ETag: etag }); return res.end(); } const cached = stateBodyCache[variant]; let body;- if (cached && cached.rev === stateRev) body = cached.body;+ if (cached && cached.rev === etag) body = cached.body; else {- body = JSON.stringify(Object.assign({}, lean ? leanState() : state, { _toast: lastToast, _gen: lastGen, enums: ENUMS, version: PKG_VERSION, aiThread: AI_THREAD, gatesDone: readGates(), seasoned: isSeasoned(), resolved: resolvedText() }));- stateBodyCache[variant] = { rev: stateRev, body };+ body = JSON.stringify(Object.assign({}, lean ? leanState() : state, { _toast: lastToast, _gen: lastGen, enums: ENUMS, version: PKG_VERSION, aiThread: AI_THREAD, gatesDone: readGates(), seasoned: isSeasoned(), resolved: resolvedText(),+ lintMeta: lintMeta+ ? Object.assign({}, lintMeta, { stale: lintMeta.poseRev !== poseRev })+ : { stale: true, reason: 'no lint measured yet this session; GET /compose.png (hero-studio compose) measures it server-side' },+ fitPending }));+ stateBodyCache[variant] = { rev: etag, body }; } res.writeHead(200, { 'Content-Type': 'application/json', ETag: etag, 'Cache-Control': 'no-cache' }); return res.end(body);@@ -502,6 +514,11 @@ function route(req, res, url, p) { } const before = snapshotPose(); merge(state, body);+ if (Object.keys(body).some((k) => k !== 'lint')) poseRev += 1;+ if ('lint' in body) {+ lintMeta = { computedAt: new Date().toISOString(), poseRev, source: 'surface' };+ }+ if (body.fontSize && typeof body.fontSize.title === 'number') fitPending = null; if (pageChanged) { refreshPageLive(state.page) .then(() => { stateRev += 1; broadcast('state', state); })@@ -615,6 +632,7 @@ function route(req, res, url, p) { if (buf.length > 12 * 1024 * 1024) return finish(200, { ok: false, error: 'image too large (12 MB cap)' }); const low = body.path.toLowerCase(); const mime = low.endsWith('.webp') ? 'image/webp' : low.match(/\.jpe?g$/) ? 'image/jpeg' : 'image/png';+ poseRev += 1; state.showcase = { src: 'data:' + mime + ';base64,' + buf.toString('base64'), full: !!body.full, cover: !!body.cover, chrome: body.chrome || (state.showcase && state.showcase.chrome) || '' }; logLine('info', 'showcase <- ' + body.path + ' (' + Math.round(buf.length / 1024) + ' KB, cli)'); broadcast('state', state);@@ -648,6 +666,7 @@ function route(req, res, url, p) { return res.end(JSON.stringify({ ok: false, error: 'unknown preset', valid: Object.keys(PRESETS) })); } const before = snapshotPose();+ poseRev += 1; merge(state, PRESETS[name]); notePoseDiff(before); logLine('info', 'preset applied: ' + name);@@ -694,6 +713,8 @@ function route(req, res, url, p) { fs.mkdirSync(path.dirname(tmp), { recursive: true }); const child = spawn(process.execPath, [path.join(__dirname, 'render-hero.mjs'), 'http://localhost:' + PORT + '/?render=1', tmp], { env: process.env, stdio: ['ignore', 'pipe', 'pipe'] }); let eb = '';+ let ob = '';+ child.stdout.on('data', (c) => { ob += c; }); child.stderr.on('data', (c) => { eb += c; }); const t = setTimeout(() => child.kill(), 60000); child.on('close', (code) => {@@ -702,6 +723,18 @@ function route(req, res, url, p) { res.writeHead(500, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ ok: false, error: eb.trim().split('\n').pop() || 'compose render failed' })); }+ // hero-studio#51: the headless render evaluated the SAME linter the+ // surface runs; store its findings as fresh-by-construction, so the+ // measured gate no longer depends on an open, responsive surface.+ const lm = ob.split('\n').find((l) => l.startsWith('LINT_JSON '));+ if (lm) {+ try {+ state.lint = JSON.parse(lm.slice('LINT_JSON '.length));+ lintMeta = { computedAt: new Date().toISOString(), poseRev, source: 'compose' };+ logLine('info', 'compose measured lint: ' + state.lint.length + ' finding(s) at poseRev ' + poseRev);+ broadcast('state', state);+ } catch {}+ } res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-cache' }); res.end(fs.readFileSync(tmp)); });@@ -713,10 +746,12 @@ function route(req, res, url, p) { 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 });+ fitPending = { requestedAt: new Date().toISOString(), poseRev }; 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' }));+ 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. state.fitPending stays set until a surface answers: if it is still there after a few seconds, no surface is actually responding' })); } if (p === '/reroll' && req.method === 'POST') {+ poseRev += 1; state.circuitSeed = (state.circuitSeed * 48271 + Date.now()) % 2147483647; broadcast('state', state); res.writeHead(200, { 'Content-Type': 'application/json' });@@ -834,6 +869,13 @@ function route(req, res, url, p) { return refuse('hard-fail layout findings lock Generate', 'fix the failing items in state.lint first; they lock the human gate and automated mode alike'); }+ // hero-studio#51: the AUTOMATED path must not trust findings measured+ // against a pose that no longer exists. The human gate is exempt: human+ // eyes are at that gate by definition.+ if (!fromGate && (!lintMeta || lintMeta.poseRev !== poseRev)) {+ return refuse('state.lint is stale: the pose changed after it was measured (or it was never measured)',+ 'GET /compose.png (hero-studio compose) re-measures lint server-side against the exact current pose, then retry');+ } if (!fromGate) { // Automated mode (#45, decided by Colby 2026-08-13): SAME-SESSION // REPEATS ONLY. The first Generate of a page is always human-clicked;