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.10
8 files changed
+335−29
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); }); }