← Commit history
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "hero-studio",   "title": "Hero Studio",   "type": "app",-  "version": "0.20.9",+  "version": "0.20.10",   "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.9",+  "version": "0.20.10",   "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+5
@@ -247,6 +247,11 @@ await jf('state', {     // #33: the baked headline is pose state, distinct from the page title;     // --headline sets it, otherwise reset so the previous page's cannot leak     headline: flags.headline || '',+    // ...and the same for the subtitle, which was NOT reset: posing a new page+    // after editing one carried the previous page's subtitle across, so a+    // fresh page rendered someone else's sentence under its title. A+    // same-page re-pose keeps it, like the rest of the staged work.+    ...(samePageRepose ? {} : { subtitle: '' }),     posedAt: Date.now(),     // Clear the previous package's showcase so a failed auto-pull can never     // leave sibling A's screenshot in sibling B's hero -- but PRESERVE staged
public/hero.css+116−7
@@ -85,7 +85,7 @@ body {  .hero[data-theme=pcb] .bg::after {   content:''; position:absolute; inset:0; opacity:.5;-  background-image:url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160"><g stroke="%2300e6dc" stroke-opacity=".09" fill="none" stroke-width="1.4"><path d="M10 20 h56 l18 18 v50"/><path d="M84 150 v-36 l22 -22 h44"/><path d="M20 140 v-48 l14 -14"/><circle cx="10" cy="20" r="3.2"/><circle cx="84" cy="88" r="3.2"/><circle cx="150" cy="92" r="3.2"/><circle cx="20" cy="140" r="3.2"/><circle cx="34" cy="78" r="3.2"/></g></svg>');+  --motif:url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160"><g stroke="%2300e6dc" stroke-opacity=".09" fill="none" stroke-width="1.4"><path d="M10 20 h56 l18 18 v50"/><path d="M84 150 v-36 l22 -22 h44"/><path d="M20 140 v-48 l14 -14"/><circle cx="10" cy="20" r="3.2"/><circle cx="84" cy="88" r="3.2"/><circle cx="150" cy="92" r="3.2"/><circle cx="20" cy="140" r="3.2"/><circle cx="34" cy="78" r="3.2"/></g></svg>'); background-image:var(--motif);   background-size:10cqw 10cqw; } @@ -98,7 +98,7 @@ body { /* authentic Kickstand corner dome (brand deck onion motif), teal-to-purple stroke */ .hero[data-theme=onion] .bg::after {   content:''; position:absolute; inset:0;-  background-image:url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400"><defs><linearGradient id="g" x1="0" y1="1" x2="1" y2="0"><stop offset="0" stop-color="%238c6bf7"/><stop offset=".55" stop-color="%2339b8ff"/><stop offset="1" stop-color="%2300e6dc"/></linearGradient></defs><g fill="none" stroke="url(%23g)" stroke-width="4" opacity=".5"><path d="M356 400 V234 Q356 60 200 60 Q44 60 44 234 V400"/><path d="M312 400 V250 Q312 110 200 110 Q88 110 88 250 V400"/><path d="M268 400 V266 Q268 160 200 160 Q132 160 132 266 V400"/><path d="M224 400 V282 Q224 210 200 210 Q176 210 176 282 V400"/></g></svg>');+  --motif:url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400"><defs><linearGradient id="g" x1="0" y1="1" x2="1" y2="0"><stop offset="0" stop-color="%238c6bf7"/><stop offset=".55" stop-color="%2339b8ff"/><stop offset="1" stop-color="%2300e6dc"/></linearGradient></defs><g fill="none" stroke="url(%23g)" stroke-width="4" opacity=".5"><path d="M356 400 V234 Q356 60 200 60 Q44 60 44 234 V400"/><path d="M312 400 V250 Q312 110 200 110 Q88 110 88 250 V400"/><path d="M268 400 V266 Q268 160 200 160 Q132 160 132 266 V400"/><path d="M224 400 V282 Q224 210 200 210 Q176 210 176 282 V400"/></g></svg>'); background-image:var(--motif);   background-repeat:no-repeat;   background-position:right -7cqw bottom -9cqw;   background-size:50cqw auto;@@ -113,7 +113,7 @@ body { /* authentic pattern-pill: scattered capsule + circle outlines, mixed brand strokes */ .hero[data-theme=pill] .bg::after {   content:''; position:absolute; inset:0;-  background-image:url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="260" height="200"><g fill="none" stroke-width="2"><rect x="16" y="22" width="26" height="62" rx="13" stroke="%2364ABFF" stroke-opacity=".3"/><circle cx="112" cy="38" r="12" stroke="%2300e6dc" stroke-opacity=".28"/><rect x="196" y="14" width="28" height="72" rx="14" stroke="%2339b8ff" stroke-opacity=".3"/><rect x="84" y="128" width="62" height="26" rx="13" stroke="%2300b8b1" stroke-opacity=".28"/><circle cx="226" cy="150" r="10" stroke="%238c6bf7" stroke-opacity=".26"/><rect x="20" y="150" width="26" height="40" rx="13" stroke="%2300e6dc" stroke-opacity=".2"/></g></svg>');+  --motif:url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="260" height="200"><g fill="none" stroke-width="2"><rect x="16" y="22" width="26" height="62" rx="13" stroke="%2364ABFF" stroke-opacity=".3"/><circle cx="112" cy="38" r="12" stroke="%2300e6dc" stroke-opacity=".28"/><rect x="196" y="14" width="28" height="72" rx="14" stroke="%2339b8ff" stroke-opacity=".3"/><rect x="84" y="128" width="62" height="26" rx="13" stroke="%2300b8b1" stroke-opacity=".28"/><circle cx="226" cy="150" r="10" stroke="%238c6bf7" stroke-opacity=".26"/><rect x="20" y="150" width="26" height="40" rx="13" stroke="%2300e6dc" stroke-opacity=".2"/></g></svg>'); background-image:var(--motif);   background-size:16cqw auto; } @@ -126,7 +126,7 @@ body { /* authentic pattern-grid: fine outlined-square field */ .hero[data-theme=grid] .bg::after {   content:''; position:absolute; inset:0; opacity:.55;-  background-image:url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28"><rect x="4" y="4" width="18" height="18" fill="none" stroke="%23ffffff" stroke-opacity=".13" stroke-width="1.3"/></svg>');+  --motif:url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28"><rect x="4" y="4" width="18" height="18" fill="none" stroke="%23ffffff" stroke-opacity=".13" stroke-width="1.3"/></svg>'); background-image:var(--motif);   background-size:2.4cqw 2.4cqw; } @@ -616,9 +616,118 @@ body[data-mode=component] .mode-app { display:none; }   -webkit-mask: none; mask: none;   width: 16cqw; height: 1.72cqw; max-width: 16cqw; }-#hero.branded .pill { color: var(--brand-primary, #00e6dc);-  border-color: color-mix(in srgb, var(--brand-primary, #00e6dc) 45%, transparent);-  background: color-mix(in srgb, var(--brand-primary, #00b8b0) 10%, transparent); }+/* The type pill (APP / SKILL / COMPONENT) is a REGISTRY convention, not the+   page owner's decoration: it tells a reader what kind of page this is, and+   it must read the same on every hero regardless of owner. So it keeps the+   Adom standard even on a branded hero, deliberately. */++/* ---- every background theme in the OWNER's colors ----------------------+   The themes above are Adom's: their grounds, washes and motif strokes are+   Adom hexes, including inside the inline SVGs. On a page whose owner has a+   brand guide that is the same leak the guide exists to stop, just one layer+   further down (it is what the human sees in the Background step's+   thumbnails). So when a guide resolves, every theme is re-derived from it.++   Each theme keeps its own IDENTITY: its ground VALUE (how dark), its wash+   geometry and its motif shapes are untouched. Only the hues change, mixed+   out of the guide's own colors. The motifs are repainted by masking the same+   --motif SVG and filling with a brand color, so nothing has to be generated+   per owner and the geometry can never drift from the unbranded version. */++#hero.branded[data-theme=midnight] .bg {+  background:+    radial-gradient(1200px 800px at 88% -10%, color-mix(in srgb, var(--brand-primary) 22%, transparent), transparent 60%),+    radial-gradient(900px 700px at -5% 110%, color-mix(in srgb, var(--brand-accent) 18%, transparent), transparent 55%),+    linear-gradient(var(--ga),+      color-mix(in srgb, var(--brand-bg) 88%, black) 0%,+      var(--brand-bg) 45%,+      color-mix(in srgb, var(--brand-bg) 82%, var(--brand-primary)) 100%);+}++#hero.branded[data-theme=pcb] .bg {+  background:+    radial-gradient(1100px 760px at 85% -8%, color-mix(in srgb, var(--brand-primary) 16%, transparent), transparent 60%),+    linear-gradient(var(--ga),+      color-mix(in srgb, var(--brand-bg) 86%, var(--brand-primary)) 0%,+      color-mix(in srgb, var(--brand-bg) 70%, var(--brand-primary)) 55%,+      color-mix(in srgb, var(--brand-bg) 88%, var(--brand-primary)) 100%);+}++#hero.branded[data-theme=onion] .bg {+  background:+    radial-gradient(900px 700px at -5% 112%, color-mix(in srgb, var(--brand-accent) 16%, transparent), transparent 55%),+    linear-gradient(var(--ga),+      color-mix(in srgb, var(--brand-bg) 88%, var(--brand-accent)) 0%,+      color-mix(in srgb, var(--brand-bg) 74%, var(--brand-accent)) 55%,+      color-mix(in srgb, var(--brand-bg) 90%, var(--brand-accent)) 100%);+}++#hero.branded[data-theme=pill] .bg {+  background:+    radial-gradient(1000px 720px at 90% -8%, color-mix(in srgb, var(--brand-primary) 14%, transparent), transparent 60%),+    linear-gradient(var(--ga),+      color-mix(in srgb, var(--brand-bg) 86%, var(--brand-primary)) 0%,+      color-mix(in srgb, var(--brand-bg) 72%, var(--brand-accent)) 55%,+      color-mix(in srgb, var(--brand-bg) 88%, var(--brand-primary)) 100%);+}++#hero.branded[data-theme=grid] .bg {+  background:+    radial-gradient(1000px 700px at 85% -10%, color-mix(in srgb, var(--brand-primary) 12%, transparent), transparent 60%),+    linear-gradient(var(--ga),+      color-mix(in srgb, var(--brand-bg) 84%, var(--brand-primary)) 0%,+      color-mix(in srgb, var(--brand-bg) 72%, var(--brand-primary)) 55%,+      color-mix(in srgb, var(--brand-bg) 88%, var(--brand-primary)) 100%);+}++/* the bold gradient IS the guide's gradient when it names one */+#hero.branded[data-theme=gradient] .bg {+  background:+    linear-gradient(155deg,+      color-mix(in srgb, var(--brand-bg) 58%, transparent),+      color-mix(in srgb, var(--brand-bg) 18%, transparent) 55%,+      color-mix(in srgb, var(--brand-bg) 64%, transparent)),+    var(--brand-grad, linear-gradient(var(--ga), var(--brand-primary) 0%, var(--brand-accent) 100%));+}++#hero.branded[data-theme=aurora] {+  background:+    radial-gradient(90cqw 60cqw at 12% -10%, color-mix(in srgb, var(--brand-primary) 28%, transparent), transparent 60%),+    radial-gradient(70cqw 55cqw at 95% 20%, color-mix(in srgb, var(--brand-accent) 22%, transparent), transparent 65%),+    radial-gradient(80cqw 70cqw at 55% 115%, color-mix(in srgb, var(--brand-primary) 20%, transparent), transparent 60%),+    var(--brand-bg);+}++#hero.branded[data-theme=mesh] {+  background:+    conic-gradient(from var(--ga) at 28% 30%, color-mix(in srgb, var(--brand-primary) 16%, transparent), transparent 32%),+    conic-gradient(from calc(var(--ga) + 120deg) at 75% 65%, color-mix(in srgb, var(--brand-accent) 13%, transparent), transparent 36%),+    conic-gradient(from calc(var(--ga) + 240deg) at 45% 90%, color-mix(in srgb, var(--brand-primary) 12%, transparent), transparent 30%),+    var(--brand-bg);+}++#hero.branded[data-theme=circuitry] { background: var(--brand-bg); }++/* Motifs: same geometry, brand ink. The SVG becomes a mask and a brand color+   is painted through it, so an owner never needs a generated copy. */+#hero.branded[data-theme=pcb] .bg::after,+#hero.branded[data-theme=onion] .bg::after,+#hero.branded[data-theme=pill] .bg::after {+  background-image: none;+  -webkit-mask-image: var(--motif);+          mask-image: var(--motif);+  -webkit-mask-repeat: inherit; mask-repeat: inherit;+  -webkit-mask-size: inherit;   mask-size: inherit;+  -webkit-mask-position: inherit; mask-position: inherit;+}+#hero.branded[data-theme=pcb] .bg::after  { background-color: var(--brand-primary); opacity:.22; }+#hero.branded[data-theme=pill] .bg::after { background-color: var(--brand-accent);  opacity:.30; }+/* the onion arcs carry a gradient stroke in Adom's version, so the guide's+   own gradient is the honest counterpart */+#hero.branded[data-theme=onion] .bg::after {+  background: var(--brand-grad, linear-gradient(var(--ga), var(--brand-primary), var(--brand-accent)));+  opacity:.55;+}  /* new background families so heroes stop looking identical */ .hero[data-theme=aurora] {
public/js/app.js+89−10
@@ -14,6 +14,23 @@ if (new URLSearchParams(location.search).has('render')) document.body.dataset.re })();  let pages = [];+/// The page rows are loaded once at boot. The server bumps `pagesRev` whenever+/// a row changes (a live refresh correcting a page's type, say), and the+/// client refetches on a mismatch. Without this an open tab kept the boot-time+/// row and drew the wrong type pill for the rest of the session.+let lastPagesRev = 0;+function syncPagesIfStale(st) {+  const rev = st && st.pagesRev;+  if (!rev || rev === lastPagesRev) return;+  lastPagesRev = rev;+  fetch('api/pages').then(r => r.json()).then(rows => {+    pages = rows;+    const sel = document.getElementById('sel-page');+    if (sel) sel.innerHTML = pages.map(p =>+      '<option value="' + p.owner + '/' + p.slug + '">' + p.title + ' (' + p.type + ')</option>').join('');+    render();+  }).catch(() => {});+} let comps = []; let state = null; @@ -28,7 +45,20 @@ const send = (patch) => post('state', patch).catch(() => {}); function canonType(t) { return t === 'hardware' ? 'component' : (t || 'app'); } // the LAUNCH spelling for display + bake: the wiki's public name is hardware function wireLabel(t) { return canonType(t) === 'component' ? 'hardware' : (t || 'app'); }-function pageByKey(key) { return pages.find(p => p.owner + '/' + p.slug === key) || pages[0]; }+/// The posed page's row, or a marked stub built from the KEY.+///+/// This used to fall back to `pages[0]`, so a posed page missing from the list+/// silently rendered a STRANGER's type and title: kcknox/brand (a skill) drew+/// an APP pill because it was another page's row being read. A wrong type pill+/// on a published hero is a lie about what the page is, so a miss is now+/// visible instead of plausible.+function pageByKey(key) {+  const hit = pages.find(p => p.owner + '/' + p.slug === key);+  if (hit) return hit;+  const [owner, slug] = String(key || '').split('/');+  return { owner: owner || '', slug: slug || '', type: null, title: '', brief: '',+           author: '', score: 0, stars: 0, installs30d: 0, tags: [], __missing: true };+} function compById(id) { return comps.find(c => c.id === id) || comps[0]; } /* the object the overlay, header, and context cards render: page or component */ function displayEntry() {@@ -84,6 +114,16 @@ function guideLogo(g) {   return role ? 'brand/asset?role=' + role + '&v=' + encodeURIComponent(String((g && g.sha) || '')) : null; } +/// `#rgb` or `#rrggbb` plus an alpha, as a canvas-ready rgba() string. Canvas+/// takes no CSS variables, so a brand color has to be resolved to numbers.+function rgbaFrom(hex, alpha) {+  const h = String(hex || '').trim().replace('#', '');+  const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h;+  const n = parseInt(full, 16);+  if (full.length !== 6 || isNaN(n)) return 'rgba(0,184,177,' + alpha.toFixed(3) + ')';+  return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + alpha.toFixed(3) + ')';+}+ const loadedFontUrls = new Set(); function loadGuideFonts(g) {   const fonts = (g && g.assets && g.assets.fonts) || [];@@ -133,6 +173,19 @@ function applyGuide(hero, g, foreign) {   set('--brand-body', usable && ty.body && ty.body.family ? "'" + ty.body.family + "'" : null);   set('--brand-mono', usable && ty.mono && ty.mono.family ? "'" + ty.mono.family + "'" : null);   hero.classList.toggle('branded', usable);+  // Mirror the same variables onto the document root and flag <body>, so the+  // STUDIO CHROME can use them too. The Background step's thumbnails live in+  // studio.css and cannot see variables scoped to #hero, which is why they+  // kept showing Adom's palette on a branded page (the one surface the human+  // actually picks from).+  const root = document.documentElement;+  for (const k of ['--brand-primary', '--brand-accent', '--brand-bg', '--brand-surface',+                   '--brand-text', '--brand-text-2', '--brand-border', '--brand-grad',+                   '--brand-display', '--brand-body', '--brand-mono']) {+    const v = st.getPropertyValue(k);+    if (v) root.style.setProperty(k, v); else root.style.removeProperty(k);+  }+  document.body.classList.toggle('branded', usable);   // the owner's logo in the wordmark slot   const logo = guideLogo(g);   const slot = hero.querySelector('.wordmark .wmark');@@ -145,9 +198,10 @@ function applyGuide(hero, g, foreign) {       slot.classList.remove('owner-logo');     }   }-  const badge = brand && brand.hero && brand.hero.badge_text;-  const pill = document.getElementById('l-pill');-  if (pill && badge && foreign) pill.dataset.badge = badge;+  // Deliberately NOT applied: a guide's hero.badge_text does not get to+  // change the type pill. The pill says what KIND of page this is (APP,+  // SKILL, COMPONENT), which is a registry convention a reader relies on+  // reading the same across every owner, so it stays the Adom standard.   return usable; } @@ -235,7 +289,7 @@ function render() {       label.appendChild(mark);     } else if (!flags[key] && mark) mark.remove();   }-  const types = (Array.isArray(d.types) && d.types.length ? d.types : [d.type]);+  const types = (Array.isArray(d.types) && d.types.length ? d.types : [d.type]).filter(Boolean);   const modeMatchesType = (canonType(d.type) === 'component') === (mode === 'component');   $('v-type-label').textContent = 'Page type matches: page is "' + types.map(wireLabel).join(' + ') + '", studio mode is "' + mode + '"' + (modeMatchesType ? '' : ' (MISMATCH: switch mode at the top of the panel)');   $('v-type-label').classList.toggle('warn', !modeMatchesType);@@ -1112,6 +1166,18 @@ function computeLint() {     }   } +  // 6f. the page's own identity. A hero states what KIND of page it is, and+  // that pill is a registry convention a reader trusts, so an unknown or+  // borrowed type is worse than no pill at all.+  {+    const d2 = displayEntry();+    if (d2 && d2.__missing) {+      items.push({ id: 'page-missing', level: 'fail', el: null, msg: 'the posed page "' + String(state.page || '') + '" is not in the studio\'s page list, so its title, brief and TYPE are unknown. The hero would state the wrong kind of page. Re-pose it (hero-studio pose ' + String(state.page || '') + ') so the live row is loaded' });+    } else if (state.mode === 'app' && d2 && !d2.type) {+      items.push({ id: 'type-unknown', level: 'warn', el: 'pill-tr', msg: 'this page has no type, so no type pill is drawn. A published hero normally states app, skill or component; set the page type rather than shipping a hero that does not say what this is' });+    }+  }+   // 7. published headroom (#28): how much calm space is left under the lowest   // text before the marquee band, in EXPORT pixels; informational.   if (texts.length && state.mode === 'app') {@@ -1451,7 +1517,11 @@ function drawCircuitry() {   const cv = $('circuitcanvas');   const rect = hero.getBoundingClientRect();   if (rect.width < 10) return;-  const key = state.circuitSeed + 'x' + Math.round(rect.width);+  // the guide's sha rides the cache key: a brand edit must repaint the traces,+  // and without it the canvas kept the previous owner's colors+  const key = state.circuitSeed + 'x' + Math.round(rect.width)+    + 'x' + ((state.guide && state.guide.sha) || 'none')+    + 'x' + JSON.stringify((state.brandEdits || {}));   if (key === lastCircuitKey) return;   lastCircuitKey = key;   const W = (cv.width = Math.round(rect.width * 2));@@ -1460,12 +1530,20 @@ function drawCircuitry() {   const rnd = mulberry32(state.circuitSeed || 1);   g.clearRect(0, 0, W, H);   const grid = Math.round(W / 46);+  const gb = guideBrand();+  const gc = (gb && gb.colors) || null;+  const traceInk = gc+    ? [gc.primary, gc.accent || gc.primary, (gc.gradient && gc.gradient[gc.gradient.length - 1]) || gc.accent || gc.primary]+    : ['#00b8b1', '#58a6ff', '#8c6bf7'];   const traces = 34;   for (let i = 0; i < traces; i++) {     const hue = rnd();-    g.strokeStyle = hue < 0.72 ? 'rgba(0,184,177,' + (0.05 + rnd() * 0.10).toFixed(3) + ')'-      : hue < 0.9 ? 'rgba(88,166,255,' + (0.05 + rnd() * 0.08).toFixed(3) + ')'-      : 'rgba(140,107,247,' + (0.05 + rnd() * 0.08).toFixed(3) + ')';+    // The circuitry theme paints on a canvas, so CSS cannot recolor it the way+    // the other themes are recolored: the trace colors come from the resolved+    // guide here, and fall back to Adom's three brand hues when there is none.+    g.strokeStyle = hue < 0.72 ? rgbaFrom(traceInk[0], 0.05 + rnd() * 0.10)+      : hue < 0.9 ? rgbaFrom(traceInk[1], 0.05 + rnd() * 0.08)+      : rgbaFrom(traceInk[2], 0.05 + rnd() * 0.08);     g.lineWidth = rnd() < 0.85 ? 2 : 4;     g.lineCap = 'round';     let x = Math.round(rnd() * (W / grid)) * grid;@@ -1529,6 +1607,7 @@ Promise.all([   pages = pg;   comps = cp;   state = st;+  lastPagesRev = st.pagesRev || 0;   seenToastSeq = (st._toast && st._toast.seq) || 0; // don't replay a stale toast on load   seenGenSeq = (st._gen && st._gen.seq) || 0;       // nor a stale generated modal   const sel = $('sel-page');@@ -1543,7 +1622,7 @@ Promise.all([     if (el) setTimeout(() => el.scrollIntoView({ block: 'start' }), 300);   }   const es = new EventSource('events');-  es.addEventListener('state', e => { state = JSON.parse(e.data); render(); });+  es.addEventListener('state', e => { state = JSON.parse(e.data); syncPagesIfStale(state); render(); });   es.addEventListener('fit', e => { let m = null; try { m = (JSON.parse(e.data) || {}).max; } catch {} setTimeout(() => runFit(m), 80); });   es.addEventListener('toast', e => { handleToast(JSON.parse(e.data)); });   es.addEventListener('generated', e => { handleGen(JSON.parse(e.data)); });
public/js/wizard.js+31−8
@@ -126,6 +126,8 @@ const WIZ_STEPS = [   },   {     id: 'brand', title: 'Brand',+    // Only a human confirming the guide finishes this step.+    done: () => ((state.guide || {}).status === 'confirmed'),     // First step whenever the owner's guide is not settled: a hero must wear     // the PAGE OWNER's look, so the guide gets approved before anything is     // designed on top of it (adom/wiki brand guides).@@ -377,11 +379,19 @@ function wizRenderRail() {   rail.textContent = '';   wizSteps().forEach((s, i) => {     const chip = document.createElement('div');-    const done = s.review ? wizAllConfirmed() : i < wizStepIndex(wizStep);-    chip.className = 'schip' + (s.id === wizStep ? ' active' : '') + (done ? ' done' : '');+    // Walking PAST a step is not the same as finishing it. The Brand step in+    // particular showed a green tick over an unconfirmed guide, which reads as+    // "nothing to do here" for the one step that needs a human decision, so a+    // step can say for itself when it is actually done.+    const passed = i < wizStepIndex(wizStep);+    const done = s.review ? wizAllConfirmed() : (typeof s.done === 'function' ? s.done() : passed);+    const needs = !done && passed && typeof s.done === 'function';+    chip.className = 'schip' + (s.id === wizStep ? ' active' : '') + (done ? ' done' : '') + (needs ? ' needs' : '');     const n = document.createElement('span');     n.className = 'n';-    n.innerHTML = done && s.id !== wizStep ? '<svg viewBox="0 0 24 24"><polyline points="5 13 10 18 19 7"/></svg>' : String(i + 1);+    n.innerHTML = done && s.id !== wizStep+      ? '<svg viewBox="0 0 24 24"><polyline points="5 13 10 18 19 7"/></svg>'+      : (needs ? '!' : String(i + 1));     const t = document.createElement('span');     t.className = 't'; t.textContent = s.title;     chip.append(n, t);@@ -707,13 +717,25 @@ function brandStepBody(host) {     if (g.assets && g.assets.mark) line('Logo: ' + String(g.assets.mark).split('/').pop());   } -  // Confirm: the human action. Disabled for anyone who cannot edit the page.+  // Confirm: the human action, and the ONLY way this step completes. It was a+  // muted card at the end of a row and read as decoration, so it says plainly+  // what it does and what happens if nobody clicks it.+  if (g.status !== 'confirmed' && g.can_edit) {+    const call = document.createElement('div');+    call.className = 'hint wiz-brand-call';+    call.textContent = 'Nobody has approved this look yet. Check the hero above, then click Confirm: '+      + 'that records you as the human who approved it and stamps it into every hero made from it. '+      + 'Until then each hero carries a warning that these colors were never approved.';+    notes.appendChild(call);+  }   const confirmCard = wizCard({     v: 'confirm',-    label: g.can_edit ? 'Confirm this brand' : 'Cannot confirm',-    sub: g.can_edit-      ? 'records you as the human who approved ' + (g.owner || 'this owner') + '\u2019s look'-      : ((g.editable_by && g.editable_by.who) ? 'only ' + g.editable_by.who + ' can' : 'you do not have edit rights'),+    label: g.status === 'confirmed' ? 'Confirmed' : (g.can_edit ? 'Confirm this brand' : 'Cannot confirm'),+    sub: g.status === 'confirmed'+      ? 'approved by ' + ((g.brand && g.brand.confirmed_by) || 'a human')+      : (g.can_edit+        ? 'you are ' + (g.owner || 'this owner') + '; click to approve'+        : ((g.editable_by && g.editable_by.who) ? 'only ' + g.editable_by.who + ' can' : 'you do not have edit rights')),   }, false, () => {     if (!g.can_edit) return;     fetch('brand/confirm', {@@ -733,6 +755,7 @@ function brandStepBody(host) {       .catch(e => handleToast({ text: String(e), kind: 'warn', seq: Date.now() }));   });   if (!g.can_edit) confirmCard.style.opacity = '.55';+  else if (g.status !== 'confirmed') confirmCard.classList.add('wiz-confirm-cta');   controls.appendChild(confirmCard);   host.appendChild(wrap); }
public/studio.css+62
@@ -454,3 +454,65 @@ body.wizard.show-ctx #contexts { display:flex; } #wcards .wiz-swatches .card, #wcards .wiz-swatches .card:has(.swx) { width:112px; min-height:64px; justify-content:center; padding:8px 6px; } #wcards .wiz-swatches .card .swx { width:100%; height:24px; aspect-ratio:auto; border-radius:6px; } #wcards .wiz-brand .wiz-finish > .card { min-height:64px; }++/* ---- the Background step's thumbnails in the OWNER's colors -------------+   These previews are how the human picks a background, so an Adom-teal grid+   of swatches on someone else's page is the leak they actually SEE. Each+   swatch keeps its own pattern and value and only changes hue, derived from+   the same variables the hero uses (mirrored onto :root by applyGuide).+   Both swatch sizes are restyled: studio.css defines them twice, small for+   Classic and large for the wizard cards. */+body.branded .sw-midnight {+  background: linear-gradient(155deg,+    color-mix(in srgb, var(--brand-bg) 88%, black),+    var(--brand-bg) 45%,+    color-mix(in srgb, var(--brand-bg) 82%, var(--brand-primary)));+}+body.branded .sw-pcb {+  background: linear-gradient(155deg,+    color-mix(in srgb, var(--brand-bg) 86%, var(--brand-primary)),+    color-mix(in srgb, var(--brand-bg) 70%, var(--brand-primary)));+}+body.branded .sw-onion {+  background: repeating-radial-gradient(circle at 90% 10%,+    color-mix(in srgb, var(--brand-bg) 88%, var(--brand-accent)) 0 4px,+    color-mix(in srgb, var(--brand-bg) 74%, var(--brand-accent)) 4px 6px);+}+body.branded .sw-pill {+  background: color-mix(in srgb, var(--brand-bg) 72%, var(--brand-accent));+}+body.branded .sw-grid {+  background-color: color-mix(in srgb, var(--brand-bg) 72%, var(--brand-primary));+}+body.branded .sw-grad {+  background: var(--brand-grad, linear-gradient(100deg, var(--brand-primary), var(--brand-accent)));+}+body.branded .sw-aurora {+  background:+    radial-gradient(28px 20px at 30% 20%, color-mix(in srgb, var(--brand-primary) 80%, transparent), transparent),+    radial-gradient(34px 26px at 70% 80%, color-mix(in srgb, var(--brand-accent) 70%, transparent), transparent),+    var(--brand-bg);+}+body.branded .sw-mesh {+  background:+    conic-gradient(from 90deg at 30% 30%, color-mix(in srgb, var(--brand-primary) 30%, transparent), transparent 33%),+    conic-gradient(from 210deg at 72% 68%, color-mix(in srgb, var(--brand-accent) 26%, transparent), transparent 36%),+    var(--brand-bg);+}+body.branded .sw-circuitry { background: var(--brand-bg); }++/* A step walked past but not actually finished: the Brand step over an+   unconfirmed guide. A tick there says "nothing to do" about the one decision+   only the human can make. */+.schip.needs { border-color: color-mix(in srgb, #f0a33c 55%, transparent); }+.schip.needs .n { background: #f0a33c; color: #1a1205; font-weight: 700; }+.schip.needs .t { color: #f0a33c; }++/* The Confirm action is the only thing that finishes the Brand step, so it+   reads as the primary action rather than another swatch card. */+.wiz-brand-call { color: #f0a33c !important; max-width: 62ch; }+#wcards .card.wiz-confirm-cta {+  border-color: #f0a33c;+  background: color-mix(in srgb, #f0a33c 12%, transparent);+}+#wcards .card.wiz-confirm-cta .card-label { color: #f0a33c; font-weight: 700; }
server.js+30−2
@@ -150,6 +150,20 @@ let genSeq = Date.now(); let lastGen = null; let generating = false; +// The page ROWS (title, brief, type) change while a surface is open: a live+// refresh or an api/pages write can correct a page's type mid-session. The+// client loads them once at boot, so without a revision to compare it kept+// rendering the boot-time row: kcknox/brand (a skill) drew an APP pill for a+// whole session because its type arrived after the tab loaded.+let PAGES_REV = 1;+function bumpPages() {+  PAGES_REV++;+  // and TELL the open surfaces: bumping a counter nothing pushes leaves the+  // tab exactly as stale as before. POST /api/pages does not otherwise+  // broadcast, which is how a corrected page type never reached the browser.+  try { broadcast('state'); } catch {}+}+ // Transport rev: every observable change bumps it. GET /state carries it as // an ETag so pollers 304 instead of re-downloading multi-MB bodies; the // serialized payload is cached per rev and variant.@@ -179,6 +193,9 @@ function srcHash(src) { } function leanState() {   const out = Object.assign({}, state);+  // rides every SSE state push, so an open surface can tell its page rows+  // went stale without polling+  out.pagesRev = PAGES_REV;   if (state.showcase && state.showcase.src && state.showcase.src.startsWith('data:')) {     out.showcase = Object.assign({}, state.showcase, { src: 'showcase.png?v=' + srcHash(state.showcase.src) });   }@@ -381,6 +398,7 @@ async function refreshPageLive(ref) {   const live = { owner, slug, title: pg.title, brief: pg.brief, type: pg.type === 'hardware' ? 'component' : pg.type, kind: pg.kind, live: true };   const entry = Object.assign({ author: '', score: 0, stars: 0, installs30d: 0, tags: [] }, i >= 0 ? PAGES[i] : {}, Object.fromEntries(Object.entries(live).filter(([, v]) => v != null)));   if (i >= 0) PAGES[i] = entry; else PAGES.unshift(entry);+  bumpPages();   logLine('info', 'live refresh: ' + ref + ' title="' + pg.title + '"');   return pg; }@@ -703,7 +721,7 @@ function route(req, res, url, p) {     let 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(),+      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(), pagesRev: PAGES_REV,         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' },@@ -1223,6 +1241,7 @@ function route(req, res, url, p) {         i >= 0 ? PAGES[i] : {}, clean,       );       if (i >= 0) PAGES[i] = entry; else PAGES.unshift(entry);+      bumpPages();       try { fs.writeFileSync(path.join(__dirname, 'data', 'pages.json'), JSON.stringify(PAGES, null, 1)); } catch {}       logLine('info', 'page upsert ' + body.owner + '/' + body.slug);       res.writeHead(200, { 'Content-Type': 'application/json' });@@ -1509,7 +1528,16 @@ function route(req, res, url, p) {   if (!full.startsWith(ROOT)) { res.writeHead(404); return res.end('not found'); }   fs.readFile(full, (err, buf) => {     if (err) { res.writeHead(404); return res.end('not found'); }-    res.writeHead(200, { 'Content-Type': MIME[path.extname(full)] || 'application/octet-stream' });+    // The studio is a LIVE design surface: its CSS and JS change while a tab+    // is open, and an open webview happily served a cached studio.css for a+    // whole session, so a fix landed on disk and the human kept seeing the+    // old rendering with no way to tell. No heuristic caching here.+    res.writeHead(200, {+      'Content-Type': MIME[path.extname(full)] || 'application/octet-stream',+      'Cache-Control': 'no-store, must-revalidate',+      'Pragma': 'no-cache',+      'Expires': '0',+    });     res.end(buf);   }); }