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.8
7 files changed
+241−6
SKILL.md+30@@ -100,6 +100,36 @@ 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 studio names the showcase candidates (hero-studio#57)++Do NOT ask the human to go find a screenshot. Ask the studio what this page+could show:++ GET /showcase-candidates?page=<owner/slug>++It returns a ranked list, each with the command that produces it: images+already in the page repo (screenshots/ first, then docs/, hero excluded),+a `live_app` entry when the page ships a dockbar.json (launch it and shoot+the real UI), for a skill the tools it drives (`skill_subject`) and the+session where it did its work, and the rendered page as an honest last+resort. `hero-studio pose` prints the same list with the+SHOWCASE_IS_GENERIC reprimand.++Load one in a single command:++ hero-studio showcase --from-page <owner/slug> [--path <repo image>]++with no `--path`, the studio picks the most representative image itself.+The generic mock is never a shipping showcase.++## The window hugs the showcase image++An imported showcase (contain fit, not `--cover`, not a full-hero import)+sizes the app window to the image's own aspect inside the layout's slot, so+there are no letterbox bars around it. The "shoot the showcase image at+WxH" advice still names the layout's FULL slot: capture at that size and+the window fills it exactly.+ ## The showcase is stored with every generate (hero-studio#55) Generate (and batch) writes the showcase image the hero was composed from to
drive.mjs+15−1@@ -4,6 +4,7 @@ // the pose change as commands land. // // hero-studio showcase --file <png> [--full] [--cover] [--chrome none|fake|bare] load a real screenshot+// hero-studio showcase --from-page <owner/slug> [--path <repo image>] take one off a wiki page // hero-studio set '<json>' | key=value [...] patch posing state // hero-studio state print current state // hero-studio toast "<text>" [--kind info|success|warn] narrate to the human@@ -73,7 +74,20 @@ try { process.exit(0); } const fi = args.indexOf('--file');- if (fi < 0 || !args[fi + 1]) die('usage: hero-studio showcase --file <png> | --from-url <url> [--selector <css>] [--full] [--cover] [--chrome none|fake|bare]');+ const pi = args.indexOf('--from-page');+ if (pi >= 0) {+ const pref = args[pi + 1];+ if (!pref || pref.startsWith('--')) die('usage: hero-studio showcase --from-page <owner/slug> [--path <repo-relative image>]');+ const pathIdx = args.indexOf('--path');+ const want = pathIdx >= 0 ? args[pathIdx + 1] : '';+ const qs = 'showcase-from-page?page=' + encodeURIComponent(pref) + (want ? '&path=' + encodeURIComponent(want) : '');+ const r = await jf(qs, { method: 'POST' });+ if (!r.ok) die('showcase --from-page: ' + (r.error || 'failed'));+ console.log(JSON.stringify({ ok: true, picked: r.picked, bytes: r.bytes }));+ console.error('showcase <- ' + pref + '/' + r.picked + ' (' + Math.round(r.bytes / 1024) + ' KB); the open studio updates within a second');+ process.exit(0);+ }+ if (fi < 0 || !args[fi + 1]) die('usage: hero-studio showcase --file <png> | --from-page <owner/slug> [--path <p>] | --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] : '';
package.json+1−1@@ -2,7 +2,7 @@ "slug": "hero-studio", "title": "Hero Studio", "type": "app",- "version": "0.20.7",+ "version": "0.20.8", "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.7",+ "version": "0.20.8", "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": [
pose.mjs+15@@ -390,8 +390,23 @@ for (let i = 0; i < 8; i++) { } const slotTxt = slot ? slot.w + 'x' + slot.h + ' (aspect ' + (slot.w / slot.h).toFixed(2) + ')' : 'the size shown in the left panel'; if (!(state.showcase && state.showcase.src)) {+ // What this page could actually show, ranked, so the decision is made for+ // the human instead of asked of them (hero-studio#57).+ let cand = null;+ try { cand = await jf('showcase-candidates?page=' + ref); } catch {} console.error(''); console.error('REPRIMAND [SHOWCASE_IS_GENERIC]: the showcase is showing the GENERIC MOCK.');+ if (cand && cand.ok && cand.candidates && cand.candidates.length) {+ console.error(' CANDIDATES for ' + ref + ' (' + cand.type + '), best first:');+ for (const c of cand.candidates.slice(0, 5)) {+ console.error(' [' + c.kind + '] ' + c.what);+ console.error(' why: ' + c.why);+ console.error(' ' + c.cmd);+ }+ console.error(' ' + cand.hint);+ console.error(' Pick one and load it YOURSELF; do not ask the human to find a screenshot.');+ console.error('');+ } console.error(' You MUST capture a real screenshot of ' + ref + "'s actual UI and load it:"); console.error(' 1. open the app in a pup window and size the capture to ' + slotTxt + ' so nothing crops:'); console.error(' adom-desktop browser_open_window \'{"sessionId":"showcase-shot","profile":"showcase-shot","url":"<the app\'s URL>"}\'');
public/js/app.js+44−2@@ -61,6 +61,34 @@ function accentize(text) { return '<span class="accent">' + words.slice(0, 2).join(' ') + '</span> ' + words.slice(2).join(' '); } +/* Size the app window to the showcase image's aspect inside the layout's+ slot, so `object-fit: contain` never has bars to paint (the black margins+ Colby saw on a wide table screenshot). Cover fits, full-hero imports and+ the CSS mock keep the layout's box. Re-runs on every render, on image+ load and on resize; export renders the same DOM, so it carries over. */+function fitWindowToShowcase() {+ const hero = $('hero');+ const win = hero && hero.querySelector('.window');+ const simg = $('showcase-img');+ if (!win || !simg) return;+ const vit = state.showcase || {};+ const active = !!vit.src && !vit.full && !vit.cover && simg.naturalWidth > 0 && simg.naturalHeight > 0;+ // measure the layout's box with any previous fit cleared+ win.style.width = '';+ win.style.height = '';+ if (!active) return;+ const wb = win.getBoundingClientRect();+ const tb = win.querySelector('.titlebar');+ const tbh = tb && getComputedStyle(tb).display !== 'none' ? tb.getBoundingClientRect().height : 0;+ const maxW = wb.width, maxBodyH = Math.max(0, wb.height - tbh);+ if (maxW < 4 || maxBodyH < 4) return;+ const ar = simg.naturalWidth / simg.naturalHeight;+ let w = maxW, bodyH = maxW / ar;+ if (bodyH > maxBodyH) { bodyH = maxBodyH; w = maxBodyH * ar; }+ win.style.width = w.toFixed(2) + 'px';+ win.style.height = (bodyH + tbh).toFixed(2) + 'px';+}+ function render() { if (!state || !pages.length) return; if (window.wizardRender) { try { wizardRender(); } catch (e) { console.error('wizard render:', e); } }@@ -180,13 +208,21 @@ function render() { } $('chk-mqpreview').checked = !!state.marqueePreview; - // showcase slot size (ask 1): measure the window body, scale to export px+ // showcase slot size (ask 1): measure the window body, scale to export px.+ // The window may be hugging the current image (fitWindowToShowcase), which+ // is NOT the advice a fresh capture wants: clear the fit, measure the+ // layout's own slot, then restore it. requestAnimationFrame(() => { const win = hero.querySelector('.appbody'); const el = $('slot-size'); if (!win || !el) return;+ const frame = hero.querySelector('.window');+ const fitW = frame ? frame.style.width : '';+ const fitH = frame ? frame.style.height : '';+ if (frame) { frame.style.width = ''; frame.style.height = ''; } const hb = hero.getBoundingClientRect(); const wb = win.getBoundingClientRect();+ if (frame) { frame.style.width = fitW; frame.style.height = fitH; } if (hb.width < 10 || wb.width < 10) { el.textContent = 'showcase slot hidden in this layout'; return; } const k = 2000 / hb.width; const w = Math.round(wb.width * k);@@ -240,6 +276,12 @@ function render() { simg.removeAttribute('src'); } }+ // The window hugs the imported image (no letterbox bars): its box is the+ // layout's slot at most, shrunk on one axis to the image's aspect. The+ // slot text above is measured BEFORE this runs (inline sizes cleared), so+ // "shoot at WxH" still names the layout's full slot.+ simg.onload = fitWindowToShowcase;+ fitWindowToShowcase(); // Never set src='' (Chrome fires onerror on it and every page-health scan // counts it as a broken image; this is the exact mechanism behind the // wiki#125 "broken_images_6" false alarm). Remove the attribute instead.@@ -723,7 +765,7 @@ function placeTourPop(pop, target, r) { pop.style.top = top + 'px'; pop.style.left = left + 'px'; }-window.addEventListener('resize', () => { if (state.tour && state.verify) showTourStep(); });+window.addEventListener('resize', () => { fitWindowToShowcase(); if (state.tour && state.verify) showTourStep(); }); /* ---- layout linter (#18): the studio notices, the human decides ---- Most engineers are not graphic designers; the studio is the only party that
server.js+135−1@@ -381,6 +381,124 @@ async function refreshPageLive(ref) { return pg; } +/// Everything this page could show in its showcase, ranked, each with the+/// command that produces it. Sources: images already in the page repo, a+/// dock manifest (the app has a real UI to launch and shoot), the tools a+/// skill drives, and the page itself as the last resort. Pure discovery:+/// nothing is loaded, so an AI can weigh the options and a human can read+/// them (hero-studio#57).+async function showcaseCandidates(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 {}+ const slot = state.slotSize && state.slotSize.w+ ? state.slotSize.w + 'x' + state.slotSize.h+ : 'the size the left panel shows';+ const out = [];+ let page = {};+ try {+ const pr = await fetch(REG + '/api/pages/' + ref, { headers });+ if (pr.ok) page = (await pr.json()).page || {};+ } catch {}+ const type = page.type === 'hardware' ? 'component' : (page.type || 'app');+ const declaredHero = String(page.hero_path || '').toLowerCase();++ // 1. images already in the repo+ let paths = [];+ try {+ const lr = await fetch(REG + '/api/pages/' + ref + '/files', { headers });+ if (lr.ok) {+ const j = await lr.json();+ const entries = Array.isArray(j.files || j.data || j) ? (j.files || j.data || j) : [];+ paths = entries.filter((f) => f.type !== 'tree').map((f) => (typeof f === 'string' ? f : f.path || f.name || '')).filter(Boolean);+ const dirs = entries.filter((f) => f.type === 'tree' && /^(screenshots|docs|images|assets|media)$/i.test(f.path || f.name || ''));+ for (const d of dirs) {+ const dn = d.path || d.name;+ const dr = await fetch(REG + '/api/pages/' + ref + '/files/' + dn, { headers });+ if (!dr.ok) continue;+ for (const ln of (await dr.text()).split('\n').map((x) => x.trim()).filter((x) => x && !x.startsWith('tree '))) paths.push(dn + '/' + ln);+ }+ }+ } catch {}+ const images = paths.filter((x) => {+ const l = x.toLowerCase();+ return /\.(png|jpe?g|webp)$/.test(l) && !/hero|billboard/.test(l) && l !== declaredHero;+ });+ const rank = (x) => (/^screenshots\//i.test(x) ? 0 : /^docs\//i.test(x) ? 1 : 2);+ images.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));+ for (const img of images.slice(0, 8)) {+ out.push({+ kind: 'page_image',+ what: img,+ why: 'already on the page: a real shot its author chose',+ cmd: 'hero-studio showcase --from-page ' + ref + ' --path ' + img,+ });+ }++ // 2. a dock manifest means the app runs and can be shot live+ try {+ const dr = await fetch(REG + '/api/pages/' + ref + '/dock-manifest', { headers });+ if (dr.ok) {+ const dm = await dr.json();+ const m = dm.manifest || dm.data || dm;+ if (m && m.launch) {+ out.push({+ kind: 'live_app',+ what: (m.webview && m.webview.title) || ref,+ why: 'this page ships a dock manifest, so its real UI can be launched and captured at the slot size',+ cmd: 'adom-cli hydrogen workspace apply ' + ref + ' # then screenshot the tab at ' + slot,+ });+ }+ }+ } catch {}++ // 3. a skill has no UI of its own: shoot what it drives+ if (type === 'skill') {+ let deps = [];+ try {+ const mr = await fetch(REG + '/api/pages/' + ref + '/files/package.json', { headers });+ if (mr.ok) {+ const pj = await mr.json();+ deps = Object.keys(pj.dependencies || {});+ }+ } catch {}+ for (const dep of deps.slice(0, 3)) {+ out.push({+ kind: 'skill_subject',+ what: dep,+ why: 'a skill has no UI of its own; the honest showcase is the tool it drives',+ cmd: 'hero-studio showcase --from-page ' + dep,+ });+ }+ out.push({+ kind: 'skill_surface',+ what: 'the skill in use',+ why: 'a terminal or editor running one of its verbs is a truthful shot for a skill with no app',+ cmd: 'capture the session where the skill did its work, then: hero-studio showcase --file <shot.png>',+ });+ }++ // 4. the page itself, last resort and never silent+ out.push({+ kind: 'page_surface',+ what: 'wiki.adom.inc/' + ref,+ why: 'last resort: the page as rendered. Weaker than real UI, but truthful and always available',+ cmd: 'capture the page at ' + slot + ', then: hero-studio showcase --file <shot.png>',+ });++ return {+ ok: true,+ page: ref,+ type,+ slot,+ has_page_images: images.length > 0,+ candidates: out,+ hint: images.length+ ? 'pick the one that shows what the page DOES, not its prettiest screen; the mock is never a shipping showcase'+ : 'this page has no usable image of its own: capture one. The generic mock must not ship as a hero',+ };+}+ // 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@@ -800,8 +918,24 @@ function route(req, res, url, p) { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ ok: true, seed: state.circuitSeed })); }+ if (p === '/showcase-candidates' && req.method === 'GET') {+ // What this page could put in its showcase, ranked, with the exact+ // command for each (hero-studio#57, Colby: the AI should decide what+ // goes in the showcase so the human is not asked to hunt for one).+ // Never picks; the caller (or the human) chooses.+ const ref = url.searchParams.get('page') || state.page;+ const finish = (code, payload) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(payload)); };+ showcaseCandidates(ref)+ .then((c) => finish(200, c))+ .catch((e) => finish(200, { ok: false, error: String(e).slice(0, 200), candidates: [] }));+ return;+ } if (p === '/showcase-from-page' && req.method === 'POST') { const ref = url.searchParams.get('page') || state.page;+ // An explicit ?path= takes that file verbatim; without one the heuristic+ // below picks a representative shot (hero-studio#57: the candidates route+ // names the paths, so the caller can choose instead of guessing).+ const wantPath = url.searchParams.get('path') || ''; 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 {}@@ -840,7 +974,7 @@ function route(req, res, url, p) { return re.test(l) && !/hero|billboard/.test(l) && l !== declaredHero; }); // representative real UI shot: screenshots/ first, then docs/ images, then any image- const pick = img(/^screenshots\/.*\.(png|jpe?g|webp)$/) || img(/^docs\/.*\.(png|jpe?g|webp)$/) || img(/\.(png|jpe?g|webp)$/);+ const pick = wantPath || img(/^screenshots\/.*\.(png|jpe?g|webp)$/) || img(/^docs\/.*\.(png|jpe?g|webp)$/) || img(/\.(png|jpe?g|webp)$/); if (!pick) return finish(200, { ok: false, error: 'no non-hero image on the page' }); const ir = await fetch(REG + '/api/pages/' + ref + '/files/' + pick, { headers }); if (!ir.ok) return finish(200, { ok: false, error: 'image fetch ' + ir.status });