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
master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981
/* Hero Studio wizard (M4): the bottom-dock, one-decision-at-a-time chrome
Colby picked over the panel. Runs as the DEFAULT surface; the classic
panel stays one toggle away (header "Classic"). This file binds the
mockup's proven interaction to the LIVE state: every choice is a send()
patch, every render follows the same SSE loop the panel uses.
Loads after app.js (classic scripts share top-level bindings). */
/* ---------- snappiness: optimistic writes + rebuild-shy rendering ----------
Through a buffering proxy the SSE echo can arrive up to a poll-tick late;
waiting for it made every click feel like molasses (Colby, dogfood run).
wizSend applies the patch LOCALLY first (the mock's instant feel), then
sends; the echo is idempotent confirmation. */
function wizMerge(dst, src) {
for (const k of Object.keys(src)) {
if (src[k] && typeof src[k] === 'object' && !Array.isArray(src[k]) && dst[k] && typeof dst[k] === 'object') {
wizMerge(dst[k], src[k]);
} else {
dst[k] = src[k];
}
}
}
function wizSend(patch) {
wizMerge(state, patch);
render();
send(patch);
}
/* ---------- step engine ---------- */
let wizStep = 'package';
let wizPickerOpen = false;
let wizExpanded = false; // seasoned checklist expanded back to one-by-one
let wizUploads = []; // this session's uploaded images (client-side gallery)
let wizFineTune = false;
function wp() { return pageByKey(state.page); }
function wSubText() { return state.subtitle || firstSentence(wp().brief, 80); }
function wSubWords() { return wSubText().split(/\s+/).filter(Boolean); }
function wizLayoutThumb(l) {
const win = {
'bleed-bottom': { x:42, y:9, w:52, h:58 },
'bleed-right': { x:52, y:9, w:52, h:40 },
'bleed-top': { x:42, y:-8, w:48, h:38 },
'bleed-corner-br': { x:50, y:20, w:54, h:48 },
'bleed-corner-tr': { x:50, y:-8, w:54, h:40 },
'contained': { x:46, y:9, w:46, h:30 },
'mirror': { x:4, y:9, w:52, h:58 },
'showcase': { x:30, y:4, w:74, h:54 },
}[l];
const mirror = l === 'mirror';
const tx = mirror ? 62 : 8;
const scrim = l === 'showcase'
? '<rect x="0" y="0" width="46" height="62" fill="url(#scr' + l + ')"/>' : '';
return '<svg viewBox="0 0 100 62" preserveAspectRatio="none">'
+ '<defs><linearGradient id="scr' + l + '" x1="0" y1="0" x2="1" y2="0">'
+ '<stop offset="0" stop-color="#05090e" stop-opacity=".95"/><stop offset="1" stop-color="#05090e" stop-opacity="0"/>'
+ '</linearGradient></defs>'
+ '<rect x="0" y="0" width="100" height="62" fill="#0d1117"/>'
+ '<rect x="' + win.x + '" y="' + win.y + '" width="' + win.w + '" height="' + win.h + '" rx="2" fill="#1c2530" stroke="#3a4552" stroke-width="1"/>'
+ scrim
+ '<rect x="' + tx + '" y="12" width="26" height="6" rx="1.5" fill="#e6edf3" opacity=".9"/>'
+ '<rect x="' + tx + '" y="22" width="22" height="3" rx="1.5" fill="#8b949e" opacity=".8"/>'
+ '<rect x="' + tx + '" y="28" width="18" height="3" rx="1.5" fill="#8b949e" opacity=".5"/>'
+ '</svg>';
}
const WIZ_LAYOUTS = [
{ v: 'bleed-bottom', label: 'Off the bottom' },
{ v: 'bleed-right', label: 'Off the right' },
{ v: 'bleed-top', label: 'Off the top' },
{ v: 'bleed-corner-br', label: 'Bottom-right corner' },
{ v: 'bleed-corner-tr', label: 'Top-right corner' },
{ v: 'contained', label: 'Floating inside' },
{ v: 'mirror', label: 'Mirrored (text right)' },
{ v: 'showcase', label: 'Backdrop' },
];
const WIZ_THEMES = [
{ v: 'midnight', label: 'Midnight', sw: 'sw-midnight' },
{ v: 'pcb', label: 'Circuit board', sw: 'sw-pcb' },
{ v: 'onion', label: 'Onion arcs', sw: 'sw-onion' },
{ v: 'pill', label: 'Pill field', sw: 'sw-pill' },
{ v: 'grid', label: 'Square grid', sw: 'sw-grid' },
{ v: 'gradient', label: 'Bold gradient', sw: 'sw-grad' },
{ v: 'aurora', label: 'Aurora', sw: 'sw-aurora' },
{ v: 'mesh', label: 'Color mesh', sw: 'sw-mesh' },
{ v: 'circuitry', label: 'Generated traces', sw: 'sw-circuitry' },
{ v: 'circuit-field', label: 'Routed field', sw: 'sw-circuit-field' },
{ v: 'circuit-tile', label: 'Seamless traces', sw: 'sw-circuit-tile' },
{ v: 'image', label: 'Your image', sw: 'sw-image' },
];
const WIZ_STEPS = [
{
id: 'package', title: 'Package',
hint: 'Which page this hero is for. One studio per page; your other open studios show here.',
summary: () => (wp().title || state.page),
custom: (host) => {
const wrap = document.createElement('div');
wrap.className = 'wiz-finish';
const cur = wizCard({ v: state.page, label: wp().title, sub: state.page + (state.verify ? ' · in review' : ' · this studio') }, true, () => {});
wrap.appendChild(cur);
fetch('instances').then(r => r.json()).then((d) => {
(d.instances || []).filter(i => !i.self && i.task && i.task !== state.page).forEach(i => {
const c = wizCard({ v: 'sib', label: i.task, sub: 'open in "' + i.thread + '"' }, false, () => {
// same host, sibling port: swap the port segment of this URL
location.href = location.href.replace(/\b\d{4,5}\b/, String(i.port));
});
wrap.appendChild(c);
});
}).catch(() => {});
if (!wizPickerOpen) {
const more = wizCard({ v: 'more', label: 'Another page\u2026', sub: state.verify ? 'This studio is mid-review; a second studio opens for other pages' : 'Pick from the wiki' }, false, () => {
if (state.verify) { handleToast({ text: 'This studio is locked to ' + state.page + ' during review. Ask your AI to pose the other page; it gets its own studio.', kind: 'info', seq: Date.now() }); return; }
wizPickerOpen = true;
wizLastSig = '';
wizardRender();
});
wrap.appendChild(more);
} else {
pages.slice(0, 40).forEach(pg => {
const ref = pg.owner + '/' + pg.slug;
if (ref === state.page) return;
wrap.appendChild(wizCard({ v: ref, label: pg.title, sub: ref }, false, () => { wizPickerOpen = false; wizSend({ page: ref }); }));
});
}
host.appendChild(wrap);
},
},
{
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).
when: () => brandStepNeeded(),
hint: () => {
const g = state.guide || {};
if (g.status === 'missing') return 'This owner has no brand guide yet. A hero must not borrow another owner\u2019s look, so make one here.';
if (g.status === 'invalid') return 'This owner\u2019s brand.json does not validate. Fix the fields below, then confirm.';
if (g.status === 'private') return 'This owner\u2019s guide is private and this session cannot read it.';
if (g.status === 'unavailable') return 'The registry did not answer for this owner\u2019s guide; the hero stays unbranded until it does.';
return 'Nobody has approved this owner\u2019s look yet. Check it on the hero above, adjust it, and confirm.';
},
summary: () => {
const g = state.guide || {};
return (g.owner || '?') + ': ' + (g.status || 'unknown');
},
custom: (host) => brandStepBody(host),
},
{
id: 'showcase', title: 'Showcase image',
hint: () => (canonType(wp().type) === 'component'
? 'The picture of your part. Use its real render or photo; it floats bare, no app window.'
: 'The screenshot shown in your hero. Use your real app, not a mock.'),
summary: () => (state.showcase && state.showcase.src ? (state.showcase.full ? 'Full-hero import' : 'Screenshot') : 'Drawn mock'),
options: () => [
{ v: 'page', label: 'From the wiki page', sub: 'The screenshot already on the page' },
{ v: 'upload', label: 'Upload images', sub: wizUploads.length ? 'Add more; keep several and switch' : 'Pick PNGs from this machine' },
...wizUploads.map((u, i) => ({ v: 'up:' + i, label: u.name.length > 18 ? u.name.slice(0, 17) + '…' : u.name, sub: 'Uploaded image ' + (i + 1) })),
{ v: 'mock', label: 'Drawn mock', sub: 'The generic drawn window' },
],
get: () => {
if (!(state.showcase && state.showcase.src)) return 'mock';
const i = wizUploads.findIndex(u => u.data === state.showcase.src);
return i >= 0 ? 'up:' + i : 'page';
},
set: (v) => {
if (v === 'mock') { wizSend({ showcase: { src: '', full: false } }); return; }
if (v === 'upload') { $('showcase-file').click(); return; }
if (v === 'page') { post('showcase-from-page?page=' + encodeURIComponent(state.page)).catch(() => {}); return; }
if (v.startsWith('up:')) { wizSend({ showcase: { src: wizUploads[Number(v.slice(3))].data } }); }
},
},
{
id: 'placement', title: 'Placement',
hint: 'Where the screenshot sits. Arrow keys work too.',
summary: () => (WIZ_LAYOUTS.find(l => l.v === state.layout) || {}).label,
options: () => WIZ_LAYOUTS.map(l => ({ v: l.v, label: l.label, thumb: wizLayoutThumb(l.v) })),
get: () => state.layout,
set: (v) => wizSend({ layout: v }),
},
{
id: 'background', title: 'Background',
hint: 'The scene behind everything.',
summary: () => (WIZ_THEMES.find(t => t.v === state.theme) || {}).label,
options: () => WIZ_THEMES.map(t => ({ v: t.v, label: t.label, sw: t.sw })),
get: () => state.theme,
set: (v) => {
if (v === 'image' && !(state.bgImage && state.bgImage.src)) {
handleToast({ text: 'Pick a background file in Classic controls (bg section), or hero-studio bg --file <image>.', kind: 'info', seq: Date.now() });
}
wizSend({ theme: v });
},
},
{
id: 'headline', title: 'Headline and text',
hint: 'The words in your hero. Fewer words read bigger.',
summary: () => state.headline || wp().title,
custom: (host) => {
const wrap = document.createElement('div');
wrap.className = 'wiz-headline';
const mk = (labText) => {
const l = document.createElement('div');
l.className = 'wiz-lab'; l.textContent = labText;
return l;
};
const hin = document.createElement('input');
hin.type = 'text'; hin.className = 'hin';
hin.placeholder = wp().title + ' (the page title)';
hin.value = state.headline || '';
let ht = null;
hin.addEventListener('input', () => {
clearTimeout(ht);
previewText({ headline: hin.value });
ht = setTimeout(() => { send({ headline: hin.value }); setTimeout(() => runFit(null), 300); }, 350);
});
const fitb = document.createElement('button');
fitb.className = 'btn small'; fitb.textContent = 'Make it as big as fits';
fitb.addEventListener('click', () => runFit(null));
// Title size: the missing "smaller" half (Colby: a long title wraps deep
// and pushes the subtitle under the homepage overlay band; fit only
// finds the LARGEST size, this lets a human bring it DOWN). Same 5-13
// cqw bounds as Classic's slider and the fit solver. The hero updates
// per tick locally; the send is debounced so slider spam coalesces to
// one ledger entry.
const sizeRow = document.createElement('div');
sizeRow.className = 'wiz-sizerow';
const srange = document.createElement('input');
srange.type = 'range'; srange.min = '5'; srange.max = '13'; srange.step = '0.1';
srange.value = String((state.fontSize || {}).title || 8);
const sval = document.createElement('span');
sval.className = 'wiz-sizeval';
sval.textContent = srange.value + 'cqw';
let szt = null;
srange.addEventListener('input', () => {
const v = Number(srange.value);
sval.textContent = v + 'cqw';
wizMerge(state, { fontSize: { title: v } });
hero.style.setProperty('--fs-title', v + 'cqw');
render();
clearTimeout(szt);
szt = setTimeout(() => send({ fontSize: { title: v } }), 250);
});
sizeRow.append(mk('Title size'), srange, sval, fitb);
const sin = document.createElement('input');
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', () => {
previewText({ subtitle: sin.value });
renderPick();
clearTimeout(st);
st = setTimeout(() => {
const patch = { subtitle: sin.value };
if (Array.isArray(state.accentWords)) {
const n = liveWords().length;
patch.accentWords = state.accentWords.filter(i => i < n);
}
wizSend(patch);
}, 350);
});
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 : []);
liveWords().forEach((w, i) => {
const t = document.createElement('span');
t.className = 'wtok' + (on.has(i) ? ' on' : '');
t.textContent = w;
t.addEventListener('click', () => {
const set = new Set(Array.isArray(state.accentWords) ? state.accentWords : []);
set.has(i) ? set.delete(i) : set.add(i);
wizSend({ accentWords: [...set].sort((a, b) => a - b) });
});
pick.appendChild(t);
});
};
renderPick();
wrap.append(mk('Headline'), hin, sizeRow, mk('Text under it'), sin, mk('Tap words to color them'), pick);
host.appendChild(wrap);
},
},
{
id: 'finishing', title: 'Finishing',
hint: 'The small marks in the corners.',
summary: () => ['pill', 'slug', 'logo'].filter(k => state.overlay[k] && (k !== 'logo' || (state.showcase && state.showcase.full))).join(' + ') || 'clean',
custom: (host) => {
const wrap = document.createElement('div');
wrap.className = 'wiz-finish';
// Only marks that actually BAKE into this pose get a card: the author
// line is a homepage overlay (never in the PNG), and the wordmark
// stamp exists only over full-hero imports. Dead buttons teach
// distrust (Colby, dogfood round 3).
const items = [
['pill', 'Type pill (top right)', 'Bakes into the corner'],
['slug', 'Wiki address (bottom right)', 'Bakes into the corner; the homepage also shows a live one'],
];
if (state.showcase && state.showcase.full && wp().owner === 'adom') {
items.push(['logo', 'Adom wordmark stamp', 'Bakes the standard mark over your import']);
}
items.forEach(([k, lab, subOn]) => {
const c = document.createElement('div');
c.className = 'card wide' + (state.overlay[k] ? ' on' : '');
c.innerHTML = '<div class="card-label"></div><div class="card-sub"></div>';
c.querySelector('.card-label').textContent = lab;
c.querySelector('.card-sub').textContent = state.overlay[k] ? subOn : 'Hidden';
c.addEventListener('click', () => wizSend({ overlay: { [k]: !state.overlay[k] } }));
wrap.appendChild(c);
});
const note = document.createElement('div');
note.className = 'wiz-legend';
note.textContent = 'The author line and live stats come from the homepage, not your PNG: flip "Homepage preview" on the Review step to see them.';
wrap.appendChild(note);
host.appendChild(wrap);
},
},
{
id: 'review', title: 'Review and generate',
hint: 'See it everywhere it will show up, then make it real.',
review: true,
summary: () => (wizAllConfirmed() ? 'ready' : 'pending'),
custom: (host) => wizRenderReview(host),
},
];
/* A step with `when` only appears while that predicate holds: the Brand step
drops out of the flow the moment the guide is confirmed. */
const wizSteps = () => WIZ_STEPS.filter(s => typeof s.when !== 'function' || s.when());
const wizStepIndex = (id) => wizSteps().findIndex(s => s.id === id);
const wizActive = () => wizSteps()[wizStepIndex(wizStep)] || wizSteps()[0];
/* The guide is settled only when a human confirmed it. Everything else
(starter, missing, invalid, private, unreachable) puts Brand first. */
function brandStepNeeded() {
const g = state.guide;
if (!g) return false; // not resolved yet: do not flash the step
return g.status !== 'confirmed';
}
/* 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
&& !Object.values(state.flags || {}).some(Boolean)
&& !(state.lint || []).some(l => l.level === 'fail');
}
function wizGoTo(id) {
wizStep = id;
wizExpanded = false;
wizLastSig = '';
wizardRender();
requestAnimationFrame(positionWizRing);
}
/* ---------- rail ---------- */
function wizRenderRail() {
const rail = $('wsteprail');
rail.textContent = '';
wizSteps().forEach((s, i) => {
const chip = document.createElement('div');
// 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>'
: (needs ? '!' : String(i + 1));
const t = document.createElement('span');
t.className = 't'; t.textContent = s.title;
chip.append(n, t);
const sum = s.summary();
if (sum && s.id !== wizStep) {
const v = document.createElement('span');
v.className = 'v'; v.textContent = sum;
chip.appendChild(v);
}
chip.addEventListener('click', () => wizGoTo(s.id));
rail.appendChild(chip);
});
wizFitRail();
const act = rail.querySelector('.schip.active');
if (act) act.scrollIntoView({ inline: 'center', block: 'nearest' });
}
function wizFitRail() {
const rail = $('wsteprail');
const wrap = $('wrailwrap');
rail.classList.remove('compact', 'mini');
if (rail.scrollWidth > rail.clientWidth + 1) rail.classList.add('compact');
if (rail.scrollWidth > rail.clientWidth + 1) rail.classList.add('mini');
const upd = () => {
wrap.classList.toggle('can-left', rail.scrollLeft > 2);
wrap.classList.toggle('can-right', rail.scrollLeft + rail.clientWidth < rail.scrollWidth - 2);
};
upd();
rail.onscroll = upd;
}
/* 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 = {
showcase: () => (state.showcase && state.showcase.full ? $('hero') : hero.querySelector('.window')),
placement: () => hero.querySelector('.window'),
background: () => $('hero'),
headline: () => hero.querySelector('.textzone'),
finishing: () => $('pill-tr'),
};
function positionWizRing() {
const ring = $('weditring');
if (!ring || !document.body.classList.contains('wizard')) return;
const getT = WIZ_EDIT_TARGETS[wizStep];
const t = getT && getT();
if (!t || (!t.offsetParent && t !== $('hero'))) { ring.classList.remove('on'); return; }
const hb = $('hero').getBoundingClientRect();
const tb = t.getBoundingClientRect();
const pad = t === $('hero') ? -8 : 8;
ring.style.left = (tb.left - hb.left - pad) + 'px';
ring.style.top = (tb.top - hb.top - pad) + 'px';
ring.style.width = (tb.width + pad * 2) + 'px';
ring.style.height = (tb.height + pad * 2) + 'px';
ring.classList.add('on');
}
/* ---------- review ---------- */
const WIZ_CHECKS = [
{ key: 'title', q: 'Title reads right' },
{ key: 'subtitle', q: 'The line under the title reads right' },
{ key: 'showcase', q: 'The screenshot shows the right thing' },
{ key: 'type', q: () => 'Page type matches (this page says "' + wireLabel(wp().type) + '")' },
];
function wizRenderReview(host) {
const wrap = document.createElement('div');
wrap.className = 'wiz-review';
const views = document.createElement('div');
views.className = 'rv-col';
const vlab = document.createElement('div');
vlab.className = 'wiz-lab'; vlab.textContent = 'See it as';
const seg = document.createElement('div');
seg.className = 'wseg';
const ctxOn = document.body.classList.contains('show-ctx');
[
{ id: 'png', label: 'Exactly my PNG' },
{ id: 'mq', label: 'Homepage preview' },
{ id: 'ctx', label: 'Everywhere it lives' },
].forEach(o => {
const b = document.createElement('button');
b.textContent = o.label;
const active = o.id === 'ctx' ? ctxOn : (!ctxOn && !!state.marqueePreview === (o.id === 'mq'));
if (active) b.classList.add('on');
b.addEventListener('click', () => {
document.body.classList.toggle('show-ctx', o.id === 'ctx');
if (o.id !== 'ctx') wizSend({ marqueePreview: o.id === 'mq' });
else { wizardRender(); document.getElementById('contexts').scrollIntoView({ block: 'nearest', behavior: 'smooth' }); }
if (o.id !== 'ctx') wizardRender();
});
seg.appendChild(b);
});
const legend = document.createElement('div');
legend.className = 'wiz-legend';
legend.textContent = 'Amber-outlined = the homepage adds it, it is not in your PNG. Press D to flip.';
views.append(vlab, seg, legend);
const checks = document.createElement('div');
checks.className = 'rv-col';
const seasoned = state.seasoned !== undefined ? !!state.seasoned : (state.gatesDone || 0) >= 4;
if (seasoned && !wizExpanded) {
const row = document.createElement('div');
row.className = 'rv-check ok';
const q = document.createElement('span');
q.className = 'q';
q.textContent = 'You’ve done this ' + state.gatesDone + ' times, so the checks are pre-ticked. You saw every step on the way here.';
const ex = document.createElement('button');
ex.className = 'btn small';
ex.textContent = 'Check one by one';
ex.addEventListener('click', () => {
wizExpanded = true;
wizSend({ confirm: { title: false, subtitle: false, showcase: false, type: false } });
});
row.append(q, ex);
checks.appendChild(row);
wizApplySeasonedPretick();
} else {
const allb = document.createElement('button');
allb.className = 'btn small';
allb.textContent = 'All of it looks good';
allb.addEventListener('click', () => wizSend({
confirm: { title: true, subtitle: true, showcase: true, type: true },
flags: { title: false, subtitle: false, showcase: false, type: false },
}));
checks.appendChild(allb);
WIZ_CHECKS.forEach(c => {
const row = document.createElement('div');
row.className = 'rv-check' + (state.confirm[c.key] ? ' ok' : '') + (state.flags[c.key] ? ' flag' : '');
const q = document.createElement('span');
q.className = 'q'; q.textContent = typeof c.q === 'function' ? c.q() : c.q;
const ok = document.createElement('button');
ok.className = 'btn small' + (state.confirm[c.key] ? ' okon' : '');
ok.textContent = (state.confirm[c.key] ? '\u2713 ' : '') + 'Looks good';
ok.addEventListener('click', () => wizSend({ confirm: { [c.key]: true }, flags: { [c.key]: false } }));
const fl = document.createElement('button');
fl.className = 'btn small' + (state.flags[c.key] ? ' flagon' : '');
fl.textContent = (state.flags[c.key] ? '\u2713 ' : '') + 'Needs fixing';
fl.addEventListener('click', () => wizSend({ flags: { [c.key]: true }, confirm: { [c.key]: false } }));
row.append(q, ok, fl);
checks.appendChild(row);
});
}
const act = document.createElement('div');
act.className = 'rv-col';
const lintBox = document.createElement('div');
lintBox.className = 'wiz-lintbox';
const lab = document.createElement('div');
lab.className = 'wiz-lab'; lab.textContent = 'Layout lint';
lintBox.appendChild(lab);
const items = state.lint || [];
if (!items.length) {
const okRow = document.createElement('div');
okRow.className = 'lint-item lint-ok';
okRow.textContent = 'No layout findings.';
lintBox.appendChild(okRow);
} else {
items.forEach((it) => {
const row = document.createElement('div');
row.className = 'lint-item lint-' + (it.level || 'warn');
row.textContent = (it.level === 'fail' ? 'BLOCKS GENERATE: ' : '') + it.msg;
// best effort spotlight: most lint ids end in the element they measured
row.style.cursor = 'pointer';
row.title = 'Click to highlight it on the hero';
row.addEventListener('click', () => {
const parts = String(it.id || '').split('-');
for (let n = 1; n < parts.length; n++) {
const el = document.getElementById(parts.slice(n).join('-'));
if (el) {
el.classList.add('tour-glow');
setTimeout(() => el.classList.remove('tour-glow'), 2200);
return;
}
}
});
lintBox.appendChild(row);
});
}
act.append(lintBox);
wrap.append(views, checks, act);
host.appendChild(wrap);
}
/* ---------- confetti: the finale earns it ---------- */
function wizConfetti() {
// whole-screen rain, ABOVE the preview modal, timed to when it pops
const c = document.createElement('canvas');
c.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:100000';
c.width = innerWidth; c.height = innerHeight;
document.body.appendChild(c);
const g = c.getContext('2d');
const colors = ['#00b8b0', '#7a5cff', '#3fb950', '#e6edf3', '#37d6cd'];
const parts = Array.from({ length: 260 }, (_, i) => ({
x: (i / 260) * innerWidth + ((i * 37) % 40) - 20,
y: -20 - ((i * 53) % 160),
vx: ((i % 11) - 5) * 0.7,
vy: 5 + ((i * 29) % 50) / 10,
s: 4 + (i % 5), r: i * 0.7, vr: ((i % 9) - 4) * 0.14,
col: colors[i % colors.length],
}));
let t = 0;
(function tick() {
t++;
g.clearRect(0, 0, c.width, c.height);
for (const p of parts) {
p.x += p.vx; p.y += p.vy; p.vy += 0.12; p.r += p.vr;
g.save(); g.translate(p.x, p.y); g.rotate(p.r);
g.fillStyle = p.col; g.globalAlpha = Math.max(0, 1.15 - t / 75);
g.fillRect(-p.s / 2, -p.s / 2, p.s, p.s * 0.62);
g.restore();
}
if (t < 85) requestAnimationFrame(tick);
else c.remove();
})();
}
window.wizardOnGenerated = () => wizConfetti();
/* ---------- dock render ---------- */
/* The Brand step's body: the guide as it stands, editable by anyone who can
edit the page, and a Confirm that is a human action like Generate. Errors
from the wiki's v1 validation are shown beside the field they name. */
function brandStepBody(host) {
const g = state.guide || {};
// The step is a column: the sentences about this guide stack, and the
// things the human acts on (swatches, Confirm) sit in one row under them.
// A single flex row for all of it spread the copy across the whole dock.
const wrap = document.createElement('div');
wrap.className = 'wiz-brand';
const notes = document.createElement('div');
notes.className = 'wiz-brand-notes';
const controls = document.createElement('div');
controls.className = 'wiz-finish';
wrap.append(notes, controls);
const line = (text, cls) => {
const d = document.createElement('div');
d.className = 'hint' + (cls ? ' ' + cls : '');
d.textContent = text;
notes.appendChild(d);
return d;
};
// No wiki identity at all: a private guide then reads as private or
// missing, and Generate would lock on a guide that is fine. Say why first.
if (state.identity && state.identity.anonymous) {
line('This studio is reading the wiki anonymously (no ADOM_WIKI_TOKEN, adom-wiki or adom-cli login, container key, or Adom Bridge session was found), so private brand guides look private or missing. Sign in, then refresh this step.', 'bad');
}
// Who may edit, in the server's own words.
if (g.editable_by && g.editable_by.text) line(g.editable_by.text + (g.can_edit ? ' You can.' : ' You cannot, so this step is read-only.'));
if (g.status === 'unavailable') {
line('The registry did not answer: ' + (g.error || 'unknown error') + '. The hero stays unbranded rather than borrowing another owner\u2019s look.');
const retry = wizCard({ v: 'retry', label: 'Try again', sub: 'ask the registry for this owner\u2019s guide' }, false, () => {
fetch('brand?fresh=1').then(() => wizardRender());
});
controls.appendChild(retry);
host.appendChild(wrap);
return;
}
if (g.status === 'missing') {
line('No guide exists for ' + g.owner + ' yet. Creating one here starts it from this owner\u2019s page, never from Adom\u2019s look.');
}
// v1 errors, field by field.
(g.errors || []).forEach((e) => {
line((e.field || '?') + ': ' + (e.message || ''), 'lint-fail');
});
(g.warnings || []).forEach((w) => line(typeof w === 'string' ? w : JSON.stringify(w), 'lint-warn'));
const brand = g.brand || null;
if (brand) {
const colors = brand.colors || {};
const row = document.createElement('div');
row.className = 'wiz-swatches';
[['primary', 'Primary'], ['accent', 'Accent'], ['background', 'Background'], ['text', 'Text']].forEach(([key, label]) => {
const cur = (state.brandEdits && state.brandEdits['colors.' + key]) || colors[key] || '';
const card = document.createElement('label');
card.className = 'card wide';
const sw = document.createElement('span');
sw.className = 'swx';
sw.style.background = cur || 'transparent';
const inp = document.createElement('input');
inp.type = 'color';
inp.value = /^#[0-9a-fA-F]{6}$/.test(cur) ? cur : '#000000';
inp.style.width = '0'; inp.style.height = '0'; inp.style.opacity = '0'; inp.style.position = 'absolute';
inp.disabled = !g.can_edit;
inp.addEventListener('change', () => {
fetch('brand/edit', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ aiThread: 'wizard', edits: { ['colors.' + key]: inp.value } }),
}).then(() => wizardRender());
});
const l = document.createElement('div');
l.className = 'card-label'; l.textContent = label;
const sub = document.createElement('div');
sub.className = 'card-sub'; sub.textContent = cur || 'unset';
card.append(sw, l, sub, inp);
row.appendChild(card);
});
controls.appendChild(row);
const ty = brand.typography || {};
line('Type: ' + [(ty.display || {}).family, (ty.body || {}).family].filter(Boolean).join(' / ') + (g.assets && g.assets.fonts ? '' : ''));
if (g.assets && g.assets.mark) line('Logo: ' + String(g.assets.mark).split('/').pop());
}
// 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.
// 0.21.0: the wiki refuses a hero that does not wear the owner's CONFIRMED
// guide, so the click that makes the hero is the click that confirms it.
// Say so here, so the click is informed; the Confirm card stays as the
// explicit path for someone who wants to approve without generating.
if (g.status !== 'confirmed' && g.can_edit) {
const call = document.createElement('div');
call.className = 'hint wiz-brand-call';
call.textContent = g.status === 'starter'
? 'Nobody has approved this look yet. Check it on the hero above and adjust it here. '
+ 'Generate confirms this guide in the same click, recording you as the human who approved it, '
+ 'and every hero made from it after that carries that approval. Confirm now if you would rather approve without generating.'
: 'Fix the fields named above, then Generate: it confirms the guide in the same click.';
notes.appendChild(call);
}
if (g.status !== 'confirmed' && !g.can_edit) {
const lock = document.createElement('div');
lock.className = 'hint wiz-brand-call';
lock.textContent = 'Generate is locked: the wiki refuses a hero that does not wear ' + (g.owner || 'this owner') + '\u2019s confirmed guide, and only someone who can edit it may confirm it. '
+ ((g.editable_by && g.editable_by.text) || '');
notes.appendChild(lock);
}
if (g.status === 'missing' && g.starter) {
const st = g.starter;
const d = document.createElement('div');
d.className = 'hint wiz-brand-call';
d.textContent = st.result === 'refused'
? 'A starter guide could not be drafted for ' + (g.owner || 'this owner') + ': ' + (st.error || st.code) + (st.hint ? ' ' + st.hint : '')
: st.result === 'exists_unreadable'
? (st.error || (g.owner + '/brand exists but this studio cannot read it')) + '. ' + (st.hint || '')
: st.result === 'unreachable'
? 'The registry could not be reached to draft a starter guide: ' + (st.error || '') + '. Pose again when it answers.'
: 'A starter guide is being drafted for ' + (g.owner || 'this owner') + '; pose again in a moment and it opens populated.';
notes.appendChild(d);
}
const confirmCard = wizCard({
v: 'confirm',
label: g.status === 'confirmed' ? 'Confirmed' : (g.can_edit ? 'Confirm now' : 'Cannot confirm'),
sub: g.status === 'confirmed'
? 'approved by ' + ((g.brand && g.brand.confirmed_by) || 'a human')
: (g.can_edit
? 'approve without generating; Generate does this too'
: ((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', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ aiThread: 'wizard', fromGate: true }),
})
.then(r => r.json())
.then((out) => {
if (!out.ok) {
handleToast({ text: out.error || 'confirm failed', kind: 'warn', seq: Date.now() });
(out.errors || []).forEach(e => handleToast({ text: (e.field || '?') + ': ' + (e.message || ''), kind: 'warn', seq: Date.now() + Math.random() }));
return;
}
handleToast({ text: 'Confirmed by ' + out.confirmed_by, kind: 'success', seq: Date.now() });
wizardRender();
})
.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);
}
function wizCard(o, on, onPick) {
const c = document.createElement('div');
c.className = 'card' + (on ? ' on' : '') + (o.sub && o.sub.length > 24 ? ' wide' : '');
if (o.thumb) {
const t = document.createElement('div');
t.className = 'thumb'; t.innerHTML = o.thumb;
c.appendChild(t);
} else if (o.sw) {
const t = document.createElement('div');
t.className = 'swx ' + o.sw;
c.appendChild(t);
}
const l = document.createElement('div');
l.className = 'card-label'; l.textContent = o.label;
c.appendChild(l);
if (o.sub) {
const sb = document.createElement('div');
sb.className = 'card-sub'; sb.textContent = o.sub;
c.appendChild(sb);
}
c.addEventListener('click', onPick);
return c;
}
let wizLastSig = '';
function wizSignature() {
const l = state.lint || [];
const v = state.showcase || {};
return JSON.stringify([wizStep, wizExpanded, state.page, state.layout, state.theme,
state.headline, state.subtitle, state.accentWords, state.overlay, state.confirm, state.circuit, state.circuitSeed,
state.flags, state.marqueePreview, state.gatesDone, wizUploads.length,
v.full, (v.src || '').length, l.filter(x => x.level === 'fail').length,
l.filter(x => x.level === 'warn').length, document.body.classList.contains('show-ctx')]);
}
function wizardRender() {
if (!document.body.classList.contains('wizard')) return;
if (!state || !pages.length || !$('wdock')) return;
let traces = $('wtrace-controls');
if (!traces) { traces = document.createElement('div'); traces.id = 'wtrace-controls'; $('wdock').appendChild(traces); }
if (wizStep === 'background') renderTraceControls(traces);
else traces.hidden = true;
$('wdock').classList.toggle('has-trace-controls', wizStep === 'background' && isTraceTheme());
const sig = wizSignature();
if (sig === wizLastSig) return;
const focused = document.activeElement && $('wdock').contains(document.activeElement)
&& document.activeElement.tagName === 'INPUT';
// 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();
$('wintro-ref').textContent = state.page;
$('wd-title').textContent = (wizStepIndex(wizStep) + 1) + '. ' + s.title;
$('wd-hint').textContent = typeof s.hint === 'function' ? s.hint() : s.hint;
const next = wizSteps()[wizStepIndex(wizStep) + 1];
$('wbtn-next').textContent = next ? 'Next: ' + next.title : 'Done';
$('wbtn-next').style.display = next && !s.review ? '' : 'none';
const gbtn = $('wbtn-generate');
gbtn.style.display = s.review ? '' : 'none';
if (s.review) {
wizApplySeasonedPretick(); // must precede the disabled derivation below
const dead = typeof rendererDead === 'function' && rendererDead();
gbtn.disabled = !wizAllConfirmed() || dead;
gbtn.title = dead ? 'no renderer: see the renderer line under Generate' : (gbtn.disabled ? 'Confirm the four checks first' : '');
}
const cards = $('wcards');
cards.textContent = '';
cards.classList.toggle('custom', !s.options);
const showArrows = !!s.options;
$('warr-l').style.visibility = showArrows ? 'visible' : 'hidden';
$('warr-r').style.visibility = showArrows ? 'visible' : 'hidden';
if (s.options) {
const opts = s.options();
const cur = s.get();
const idx = opts.findIndex(o => o.v === cur);
$('wd-count').textContent = (Math.max(0, idx) + 1) + ' of ' + opts.length;
opts.forEach(o => cards.appendChild(wizCard(o, o.v === cur, () => { s.set(o.v); })));
const on = cards.querySelector('.card.on');
if (on) on.scrollIntoView({ inline: 'center', block: 'nearest' });
} else {
$('wd-count').textContent = '';
if (s.custom) s.custom(cards);
}
requestAnimationFrame(positionWizRing);
}
/* arrows cycle option steps; left/right keys too */
function wizCycle(dir) {
const s = wizActive();
if (!s.options) return;
const opts = s.options();
const i = opts.findIndex(o => o.v === s.get());
const n = opts[(i + dir + opts.length) % opts.length];
s.set(n.v);
}
$('warr-l').addEventListener('click', () => wizCycle(-1));
$('warr-r').addEventListener('click', () => wizCycle(1));
$('wbtn-next').addEventListener('click', () => {
const next = wizSteps()[wizStepIndex(wizStep) + 1];
if (next) wizGoTo(next.id);
});
$('wbtn-generate').addEventListener('click', () => {
if ($('wbtn-generate').disabled) return;
runGenerate();
});
document.addEventListener('keydown', (e) => {
if (!document.body.classList.contains('wizard')) return;
if (e.target.closest('input,textarea')) return;
if (e.key === 'ArrowLeft') wizCycle(-1);
if (e.key === 'ArrowRight') wizCycle(1);
});
/* multi-upload rides the existing file input; keep a session gallery */
$('showcase-file').setAttribute('multiple', '');
$('showcase-file').addEventListener('change', () => {
if (!document.body.classList.contains('wizard')) return;
const files = [...$('showcase-file').files];
files.forEach((f) => {
const r = new FileReader();
r.onload = () => {
wizUploads.push({ name: f.name, data: r.result });
if (wizUploads.length && f === files[files.length - 1]) {
wizSend({ showcase: { src: r.result } });
}
};
r.readAsDataURL(f);
});
});
/* classic toggle: the old panel is one click away, never gone */
$('wiz-classic').addEventListener('click', () => {
const toClassic = document.body.classList.contains('wizard');
document.body.classList.toggle('wizard', !toClassic);
document.body.classList.toggle('classic', toClassic);
$('wiz-classic').textContent = toClassic ? 'Wizard' : 'Classic';
if (!toClassic) wizardRender();
showTourStep(); // hand the spotlight to whichever mode we just entered
});
/* boot: wizard is the default surface; a pose (verify) starts at Showcase
since Package is locked. Component pages ride the same wizard for now
(Colby, 2026-08-13); their extra image options stay in Classic. */
if (document.body.dataset.render === undefined) document.body.classList.add('wizard');
setTimeout(() => {
if (state && state.verify) wizStep = 'showcase';
wizardRender();
}, 400);
/* draggable dock height (Colby): grab the top edge; remembered per browser */
(() => {
const grip = document.getElementById('wdock-grip');
const dock = document.getElementById('wdock');
if (!grip || !dock) return;
const KEY = 'hs-dock-h';
const saved = localStorage.getItem(KEY);
if (saved) dock.style.setProperty('--dock-h', saved + 'px');
let startY = 0, startH = 0, dragging = false;
grip.addEventListener('pointerdown', (e) => {
dragging = true; startY = e.clientY; startH = dock.getBoundingClientRect().height;
grip.setPointerCapture(e.pointerId);
e.preventDefault();
});
grip.addEventListener('pointermove', (e) => {
if (!dragging) return;
const h = Math.max(150, Math.min(window.innerHeight * 0.7, startH + (startY - e.clientY)));
dock.style.setProperty('--dock-h', h + 'px');
});
const drop = () => {
if (!dragging) return;
dragging = false;
const h = Math.round(dock.getBoundingClientRect().height);
localStorage.setItem(KEY, String(h));
positionWizRing();
};
grip.addEventListener('pointerup', drop);
grip.addEventListener('pointercancel', drop);
grip.addEventListener('dblclick', () => {
localStorage.removeItem(KEY);
dock.style.removeProperty('--dock-h');
positionWizRing();
});
})();