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.1
5 files changed
+109−25
package.json+1−1@@ -2,7 +2,7 @@ "slug": "hero-studio", "title": "Hero Studio", "type": "app",- "version": "0.20.0",+ "version": "0.20.1", "description": "The hero-image lab with a human gate: `hero-studio pose <owner/slug>` opens the studio in front of the human, posed for their package, and blocks until they click Generate, producing the provenance-stamped 2000x1250 PNG the registry's hero gate requires. The image carries the title and artwork; the exported image also bakes the type pill (top right) and full wiki slug (bottom right); the page shows the hero bare, and only the homepage marquee overlays its own dynamic info. Adom-org pages get the house wordmark and palette automatically; every other owner brings their own accent and background colors, with placement fixed for all. The floating app window is the 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.0",+ "version": "0.20.1", "description": "The hero-image lab with a human gate: `hero-studio pose <owner/slug>` opens the studio in front of the human, posed for their package, and blocks until they click Generate, producing the provenance-stamped 2000x1250 PNG the registry's hero gate requires. The image carries the title and artwork; the exported image also bakes the type pill (top right) and full wiki slug (bottom right); the page shows the hero bare, and only the homepage marquee overlays its own dynamic info. Adom-org pages get the house wordmark and palette automatically; every other owner brings their own accent and background colors, with placement fixed for all. The floating app window is the 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+20−5@@ -220,11 +220,26 @@ function render() { const sEl = $(sid); if (sEl) sEl.classList.toggle('sim-fenced', simOverImport); }- $('showcase-img').src = (!vit.full && vit.src) || '';- $('showcase-img').onerror = () => {- try { fetch('console', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ level: 'error', text: 'showcase image failed to load: ' + String(vit.src).slice(0, 80) }) }); } catch {}- handleToast({ text: 'The showcase image failed to load (see console); re-import it or pick another source.', kind: 'warn', seq: Date.now() });- };+ // Setting an <img> src to '' fires onerror in Chrome, and render() runs on+ // every SSE tick, so the mock (no image) used to toast "failed to load" on+ // every tick. Only bind the handler when there IS a src to load, and warn+ // once per distinct src, never per render.+ const simg = $('showcase-img');+ const want = (!vit.full && vit.src) || '';+ if (simg.getAttribute('src') !== want) {+ simg.onerror = null;+ if (want) {+ simg.onerror = () => {+ if (simg.dataset.warned === want) return;+ simg.dataset.warned = want;+ try { fetch('console', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ level: 'error', text: 'showcase image failed to load: ' + String(vit.src).slice(0, 80) }) }); } catch {}+ handleToast({ text: 'The showcase image failed to load (see console); re-import it or pick another source.', kind: 'warn', seq: Date.now() });+ };+ simg.src = want;+ } else {+ simg.removeAttribute('src');+ }+ } $('fullimport-img').src = (vit.full && vit.src) || ''; $('showcase-full').checked = !!vit.full;
public/js/wizard.js+60−12@@ -197,24 +197,32 @@ const WIZ_STEPS = [ sin.type = 'text'; sin.className = 'hin'; sin.placeholder = firstSentence(wp().brief, 80); sin.value = state.subtitle || '';+ const pick = document.createElement('div');+ pick.className = 'wordpick';+ // The chips are the subtitle's words. They re-render IN PLACE from the+ // live input on every keystroke (cheap: a handful of spans), so they+ // track what the human is typing without a dock rebuild (which the+ // focused-input guard rightly blocks, to keep the caret). Before this+ // they were built once and froze on the old words (#49, Drew).+ const liveWords = () => (sin.value || firstSentence(wp().brief, 80)).split(/\s+/).filter(Boolean); let st = null; sin.addEventListener('input', () => {+ renderPick(); clearTimeout(st); st = setTimeout(() => { const patch = { subtitle: sin.value }; if (Array.isArray(state.accentWords)) {- const n = (sin.value || firstSentence(wp().brief, 80)).split(/\s+/).filter(Boolean).length;+ const n = liveWords().length; patch.accentWords = state.accentWords.filter(i => i < n); } wizSend(patch); }, 350); });- const pick = document.createElement('div');- pick.className = 'wordpick';+ sin.addEventListener('blur', () => { clearTimeout(st); const patch = { subtitle: sin.value }; if (Array.isArray(state.accentWords)) patch.accentWords = state.accentWords.filter(i => i < liveWords().length); wizSend(patch); }); const renderPick = () => { pick.textContent = ''; const on = new Set(Array.isArray(state.accentWords) ? state.accentWords : []);- wSubWords().forEach((w, i) => {+ liveWords().forEach((w, i) => { const t = document.createElement('span'); t.className = 'wtok' + (on.has(i) ? ' on' : ''); t.textContent = w;@@ -276,6 +284,23 @@ const WIZ_STEPS = [ const wizStepIndex = (id) => WIZ_STEPS.findIndex(s => s.id === id); const wizActive = () => WIZ_STEPS[wizStepIndex(wizStep)] || WIZ_STEPS[0];+/* Seasoned reviewers (4+ gates) get the four checks pre-ticked. This must run+ BEFORE the Generate button's disabled state is derived in wizardRender,+ not inside the review step's custom renderer (which runs after): the old+ order computed disabled=true from the still-false confirm, then flipped+ confirm one line later, and the button stayed dead until a (proxy-delayed+ or coalesced) SSE echo. Colby hit it: "it said I'd done this before but I+ still couldn't press Generate". Idempotent; a flag or an expanded+ checklist opts out. */+function wizApplySeasonedPretick() {+ const seasoned = state.seasoned !== undefined ? !!state.seasoned : (state.gatesDone || 0) >= 4;+ if (!seasoned || wizExpanded) return;+ const c = state.confirm || {};+ if (c.title && c.subtitle && c.showcase && c.type) return;+ if (Object.values(state.flags || {}).some(Boolean)) return;+ wizMerge(state, { confirm: { title: true, subtitle: true, showcase: true, type: true } });+ send({ confirm: { title: true, subtitle: true, showcase: true, type: true } });+} function wizAllConfirmed() { const c = state.confirm || {}; return c.title && c.subtitle && c.showcase && c.type@@ -330,8 +355,31 @@ function wizFitRail() { upd(); rail.onscroll = upd; }-window.addEventListener('resize', () => { wizFitRail(); positionWizRing(); });-if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => wizFitRail());+/* The hero must always be seen WHOLE (#48): its width is+ min(pane, 1000px, free-height * 1.6), where free height = the stage's inner+ height minus whatever else is stacked in it (stagebar, approved banner) and+ the column gaps. CSS cannot derive a width from a height cap without+ squashing the ratio, so measure it here and publish --stage-free. Runs on+ resize, on dock drag (the dock steals stage height), and after render. */+function wizFitStage() {+ const stage = $('stage'), hero = $('hero');+ if (!stage || !hero || !document.body.classList.contains('wizard')) return;+ const cs = getComputedStyle(stage);+ const pad = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);+ const gap = parseFloat(cs.rowGap || cs.gap) || 0;+ let others = 0, n = 0;+ for (const el of stage.children) {+ if (el === hero) continue;+ const b = el.getBoundingClientRect();+ if (b.height > 0 && getComputedStyle(el).display !== 'none') { others += b.height; n += 1; }+ }+ const free = Math.max(80, stage.clientHeight - pad - others - gap * n);+ stage.style.setProperty('--stage-free', free + 'px');+}+window.addEventListener('resize', () => { wizFitRail(); wizFitStage(); positionWizRing(); });+if (window.ResizeObserver) new ResizeObserver(() => wizFitStage()).observe($('stage'));+if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => { wizFitRail(); wizFitStage(); });+wizFitStage(); /* ---------- edit ring: the dashed box around what this step edits ---------- */ const WIZ_EDIT_TARGETS = {@@ -415,11 +463,7 @@ function wizRenderReview(host) { }); row.append(q, ex); checks.appendChild(row);- const c = state.confirm || {};- if (!(c.title && c.subtitle && c.showcase && c.type) && !Object.values(state.flags || {}).some(Boolean)) {- wizMerge(state, { confirm: { title: true, subtitle: true, showcase: true, type: true } });- send({ confirm: { title: true, subtitle: true, showcase: true, type: true } });- }+ wizApplySeasonedPretick(); } else { const allb = document.createElement('button'); allb.className = 'btn small';@@ -563,7 +607,10 @@ function wizardRender() { if (sig === wizLastSig) return; const focused = document.activeElement && $('wdock').contains(document.activeElement) && document.activeElement.tagName === 'INPUT';- if (focused) { wizLastSig = sig; return; }+ // Skip the rebuild while an input is focused (keeps the caret), but do NOT+ // record the signature as rendered: that made the next render after blur+ // think nothing changed and skip too, so typed changes never caught up.+ if (focused) return; wizLastSig = sig; const s = wizActive(); wizRenderRail();@@ -576,6 +623,7 @@ function wizardRender() { const gbtn = $('wbtn-generate'); gbtn.style.display = s.review ? '' : 'none'; if (s.review) {+ wizApplySeasonedPretick(); // must precede the disabled derivation below gbtn.disabled = !wizAllConfirmed(); gbtn.title = gbtn.disabled ? 'Confirm the four checks first' : ''; }
public/studio.css+27−6@@ -113,7 +113,23 @@ label.chk input { accent-color:var(--accent); width:14px; height:14px; cursor:po /* ---------- stage + hero canvas ---------- */-#stage { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:flex-start; gap:18px; padding:22px; min-width:0; overflow:auto; }+#stage { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:flex-start; gap:18px; padding:22px; min-width:0; min-height:0; overflow:auto; }+/* wizard: the hero ALWAYS fits the stage (#48: a human-facing image must be+ seen whole; a cropped hero hides what the edit just did). Width-limited on+ wide panes, height-limited on short ones: the 16:10 box takes min(100%+ width, available height * 1.6). The stage no longer scrolls in the wizard. */+body.wizard #stage { overflow:hidden; justify-content:center; }+/* Pure flex, no magic numbers: the hero may SHRINK in the column (flex-shrink,+ min-height:0) and keeps 16:10, so on a short pane the height binds and the+ width follows; on a wide pane max-width binds. The stagebar / approved+ banner above it simply take their share first. */+/* Width is the driving axis (cqw typography depends on it). Pure CSS cannot+ make a definite width FOLLOW a height cap without squashing the ratio, so+ wizard.js measures the stage's free height into --stage-free (px) and the+ width is min(pane width, 1000px, free height * 1.6): wide panes are+ width-bound, short panes are height-bound, and 16:10 never breaks. */+body.wizard #hero { flex:0 0 auto; height:auto; aspect-ratio:16/10;+ width:min(100%, 1000px, calc(var(--stage-free, 100000px) * 1.6)); } /* the stagebar (#21/#23): stage chrome lives ABOVE the stage, never on it */ #stagebar { display:none; align-items:center; gap:10px; margin:10px 0 8px; }@@ -266,8 +282,12 @@ body.wizard #panel { display:none; } body.wizard #tourpop { display:none !important; } body.wizard #verifybar { display:none !important; } body.classic #wdock, body.classic #weditring { display:none !important; }+/* The dock is a fixed-height pane with FIXED chrome and exactly ONE scroll+ region. The grip, intro, step rail and step header never scroll away+ (Colby: "the resize tab should always be at the top"); only #wcardsrow+ scrolls, so there is never a scrollbar inside a scrollbar (#46 #47). */ #wdock { flex:0 0 auto; background:var(--surface); border-top:1px solid var(--border); padding:10px 18px 14px;- display:flex; flex-direction:column; gap:10px; height:min(44vh, 330px); overflow-y:auto; scrollbar-width:thin; }+ display:flex; flex-direction:column; gap:10px; height:min(44vh, 330px); overflow:hidden; scrollbar-width:thin; } #wdockintro { font-size:12px; color:var(--text-2); padding:0 2px 2px; } #wdockintro b { color:var(--text); font-weight:600; } #wdockintro .ref { font-family:'JetBrains Mono',monospace; font-size:11px; color:var(--accent); }@@ -302,8 +322,8 @@ body.classic #wdock, body.classic #weditring { display:none !important; } #wdockhead .wdhint { font-size:12px; color:var(--text-2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; min-width:0; } #wbtn-generate { font-size:15px; font-weight:600; padding:10px 26px; border-radius:10px; } #wbtn-generate:not(:disabled) { box-shadow:0 0 18px rgba(0,184,176,.35); }-#wcardsrow { display:flex; gap:10px; align-items:center; min-height:0; flex:1; }-#wcards { display:flex; gap:10px; overflow-x:auto; flex:1; scrollbar-width:none; align-items:center; padding:2px; }+#wcardsrow { display:flex; gap:10px; align-items:center; min-height:0; flex:1 1 0; overflow:hidden; }+#wcards { display:flex; gap:10px; overflow-x:auto; overflow-y:hidden; flex:1; min-width:0; min-height:0; scrollbar-width:none; align-items:center; padding:2px; } #wcards::-webkit-scrollbar { display:none; } #wcards > :first-child { margin-left:auto; } #wcards > :last-child { margin-right:auto; }@@ -411,6 +431,7 @@ body.wizard.show-ctx #contexts { display:flex; } .wiz-lintbox .lint-info { color:#8b949e; } /* two card-row modes: option rows center natural-height cards; custom step content (review, headline, package) fills the space and scrolls itself */-#wcards.custom { align-items:stretch; overflow-y:auto; overflow-x:hidden; }+#wcards.custom { align-items:stretch; overflow-y:auto; overflow-x:hidden; height:100%; align-self:stretch; min-height:0;+ scrollbar-width:thin; scrollbar-color:var(--border) transparent; } #wcardsrow:has(> #wcards.custom) { align-items:stretch; }-#wcards.custom { height:100%; align-self:stretch; }+#wcards.custom::-webkit-scrollbar { display:block; width:7px; }