master
Colby Knox Merge PR #182: Preview text edits immediately while debouncing saves 1f1ac18 15d ago
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();
  });
})();