← Commit history

Publish 0.20.1

Colby Knox ·90779e48c8 ·2mo ago ·parent b8ac69a
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; }