← Commit history

Publish 0.20.8

Colby Knox ·943e8e7908 ·1mo ago ·parent 73456a3
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 });