master
Colby Knox Merge PR #182: Preview text edits immediately while debouncing saves 1f1ac18 15d ago
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838
/* app.js: the entire studio front-end, moved verbatim from index.html in the
   M3 split (wizard redesign). Decomposition into hero/lint/fit/wizard modules
   lands with M4, when the panel code entangled with render() is deleted. */

if (new URLSearchParams(location.search).has('render')) document.body.dataset.render = '1';
// The render view (?render=1) is what Generate and compose screenshot. It is a READ-ONLY consumer of
// the state it loaded: it must never POST state, and never subscribe to changes mid-screenshot.
// Before this it ran the full surface: its slotSize measurement (headless viewport, 1109x1269)
// disagreed with the human's open studio (1109x1227), each POST was broadcast to the other, and the
// two ping-ponged hundreds of times a second, so the renderer's networkidle2 wait never settled and
// every Generate died at the 30 s navigation timeout (2026-09-22, adom/adom-bluegreen).
const RENDER_VIEW = new URLSearchParams(location.search).has('render');

/* console relay: the AI reads GET /console instead of DevTools */
(function () {
  const post = (level, args) => fetch('console', { method:'POST', headers:{'Content-Type':'application/json'},
    body: JSON.stringify({ level, text: args.map(a => { try { return typeof a === 'string' ? a : JSON.stringify(a); } catch { return String(a); } }).join(' ') }) }).catch(()=>{});
  ['log','warn','error'].forEach(k => { const o = console[k].bind(console); console[k] = (...a) => { o(...a); post(k, a); }; });
  window.onerror = (m, s, l) => post('error', [m + ' @' + s + ':' + l]);
  window.addEventListener('unhandledrejection', e => post('error', ['unhandledrejection: ' + e.reason]));
})();

let pages = [];
/// The page rows are loaded once at boot. The server bumps `pagesRev` whenever
/// a row changes (a live refresh correcting a page's type, say), and the
/// client refetches on a mismatch. Without this an open tab kept the boot-time
/// row and drew the wrong type pill for the rest of the session.
let lastPagesRev = 0;
function syncPagesIfStale(st) {
  const rev = st && st.pagesRev;
  if (!rev || rev === lastPagesRev) return;
  lastPagesRev = rev;
  fetch('api/pages').then(r => r.json()).then(rows => {
    pages = rows;
    const sel = document.getElementById('sel-page');
    if (sel) sel.innerHTML = pages.map(p =>
      '<option value="' + p.owner + '/' + p.slug + '">' + p.title + ' (' + p.type + ')</option>').join('');
    render();
  }).catch(() => {});
}
let comps = [];
let state = null;

const $ = (id) => document.getElementById(id);
// Every state-changing POST from this page is attributed to the server's
// owning ai-thread (#39): the page IS that instance's surface, and the human
// acting through it acts within that thread's session.
const withThread = (o) => Object.assign({ aiThread: (state && state.aiThread) || 'hero-studio ui' }, o || {});
const post = (path, body) => RENDER_VIEW
  ? Promise.resolve(new Response('{}', { headers: { 'Content-Type': 'application/json' } }))
  : fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(withThread(body)) });
const send = (patch) => post('state', patch).catch(() => {});

// Paint typing immediately; keep the existing debounce for persistence. An
// older SSE/poll response must not roll back text whose save is still pending.
const textDrafts = {};
function previewText(patch) {
  Object.assign(textDrafts, patch);
  Object.assign(state, patch);
  render();
}
function receiveState(next) {
  for (const [key, value] of Object.entries(textDrafts)) {
    if (next[key] === value) delete textDrafts[key];
    else next[key] = value;
  }
  state = next;
}

function canonType(t) { return t === 'hardware' ? 'component' : (t || 'app'); }
// the LAUNCH spelling for display + bake: the wiki's public name is hardware
function wireLabel(t) { return canonType(t) === 'component' ? 'hardware' : (t || 'app'); }
/// The posed page's row, or a marked stub built from the KEY.
///
/// This used to fall back to `pages[0]`, so a posed page missing from the list
/// silently rendered a STRANGER's type and title: kcknox/brand (a skill) drew
/// an APP pill because it was another page's row being read. A wrong type pill
/// on a published hero is a lie about what the page is, so a miss is now
/// visible instead of plausible.
function pageByKey(key) {
  const hit = pages.find(p => p.owner + '/' + p.slug === key);
  if (hit) return hit;
  const [owner, slug] = String(key || '').split('/');
  return { owner: owner || '', slug: slug || '', type: null, title: '', brief: '',
           author: '', score: 0, stars: 0, installs30d: 0, tags: [], __missing: true };
}
function compById(id) { return comps.find(c => c.id === id) || comps[0]; }
/* the object the overlay, header, and context cards render: page or component */
function displayEntry() {
  if (state.mode === 'component' && comps.length) {
    const c = compById(state.comp.id);
    return { owner: c.owner, slug: c.slug, type: 'component', title: c.name, brief: c.spec,
      author: c.author, score: c.score, stars: c.stars, installs30d: null,
      tags: [c.mfr.toLowerCase()], updated_at: null, mpn: c.mpn };
  }
  return pageByKey(state.page);
}

/* long mono strings in the overlay: shrink a step past 40 chars; past 54,
   middle-truncate so the owner and the tail stay readable (full string in title=) */
function fitMono(s) {
  if (s.length <= 40) return { text: s, small: false };
  if (s.length <= 54) return { text: s, small: true };
  return { text: s.slice(0, 33) + '...' + s.slice(-18), small: true };
}

function firstSentence(s, max) {
  let t = (s || '').split(/(?<=\.)\s/)[0] || s || '';
  if (t.length > max) t = t.slice(0, max).replace(/\s+\S*$/, '') + '...';
  return t;
}

function accentize(text) {
  const words = text.split(' ');
  if (words.length < 3) return '<span class="accent">' + text + '</span>';
  return '<span class="accent">' + words.slice(0, 2).join(' ') + '</span> ' + words.slice(2).join(' ');
}

/* The owner's brand guide, applied to the live hero: colors as CSS custom
   properties, the display and body families (loaded from the guide's font
   URLs), the logo in the wordmark slot, and the badge text. Adom's palette
   and wordmark stay the fallback ONLY for adom pages; for anyone else an
   unresolved guide leaves the hero unbranded rather than borrowing Adom's.
   (adom/wiki brand guides) */
function guideBrand() {
  const g = state.guide;
  return (g && g.brand) || null;
}

/* The logo URL the hero should wear: the guide's wordmark for a dark ground,
   else its mark. Null means "no owner logo", and the Adom wordmark shows only
   on adom pages. */
function guideLogo(g) {
  const a = (g && g.assets) || null;
  if (!a) return null;
  // served by the studio, not fetched from the registry: a private guide needs
  // the token, and a self-signed registry is refused by the render browser
  const role = a.wordmark_light ? 'wordmark_light' : (a.wordmark_dark ? 'wordmark_dark' : (a.mark ? 'mark' : null));
  return role ? 'brand/asset?role=' + role + '&v=' + encodeURIComponent(String((g && g.sha) || '')) : null;
}

/// `#rgb` or `#rrggbb` plus an alpha, as a canvas-ready rgba() string. Canvas
/// takes no CSS variables, so a brand color has to be resolved to numbers.
function rgbaFrom(hex, alpha) {
  const h = String(hex || '').trim().replace('#', '');
  const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h;
  const n = parseInt(full, 16);
  if (full.length !== 6 || isNaN(n)) return 'rgba(0,184,177,' + alpha.toFixed(3) + ')';
  return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + alpha.toFixed(3) + ')';
}

const loadedFontUrls = new Set();
function loadGuideFonts(g) {
  const fonts = (g && g.assets && g.assets.fonts) || [];
  for (const f of fonts) {
    if (!f || !f.url || loadedFontUrls.has(f.url)) continue;
    loadedFontUrls.add(f.url);
    if (f.source === 'google' || /fonts\.googleapis\.com/.test(f.url)) {
      const link = document.createElement('link');
      link.rel = 'stylesheet';
      link.href = f.url;
      document.head.appendChild(link);
    } else {
      // a font FILE served by the guide's page, proxied by the studio for the
      // same reason the logo is
      const face = new FontFace(f.family, "url(brand/font?role=" + encodeURIComponent(f.role) + ")");
      face.load().then((ff) => document.fonts.add(ff)).catch(() => {
        try {
          fetch('console', { method: 'POST', headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ level: 'warn', text: 'brand font failed to load: ' + f.family }) });
        } catch {}
      });
    }
  }
}

function applyGuide(hero, g, foreign) {
  const brand = (g && g.brand) || null;
  const usable = !!brand && g.status !== 'unavailable';
  loadGuideFonts(g);
  const st = hero.style;
  const set = (k, v) => { if (v) st.setProperty(k, v); else st.removeProperty(k); };
  const c = (brand && brand.colors) || {};
  // A guide always wins, adom included: adom/brand IS Adom's look once it
  // exists, and until then the stylesheet's Adom defaults stand.
  set('--brand-primary', usable ? c.primary : null);
  set('--brand-accent', usable ? (c.accent || c.primary) : null);
  set('--brand-bg', usable ? c.background : null);
  set('--brand-surface', usable ? c.surface : null);
  set('--brand-text', usable ? c.text : null);
  set('--brand-text-2', usable ? c.text_secondary : null);
  set('--brand-border', usable ? c.border : null);
  const grad = usable && Array.isArray(c.gradient) && c.gradient.length >= 2
    ? 'linear-gradient(100deg,' + c.gradient.join(',') + ')' : null;
  set('--brand-grad', grad);
  const ty = (brand && brand.typography) || {};
  set('--brand-display', usable && ty.display && ty.display.family ? "'" + ty.display.family + "'" : null);
  set('--brand-body', usable && ty.body && ty.body.family ? "'" + ty.body.family + "'" : null);
  set('--brand-mono', usable && ty.mono && ty.mono.family ? "'" + ty.mono.family + "'" : null);
  hero.classList.toggle('branded', usable);
  // Mirror the same variables onto the document root and flag <body>, so the
  // STUDIO CHROME can use them too. The Background step's thumbnails live in
  // studio.css and cannot see variables scoped to #hero, which is why they
  // kept showing Adom's palette on a branded page (the one surface the human
  // actually picks from).
  const root = document.documentElement;
  for (const k of ['--brand-primary', '--brand-accent', '--brand-bg', '--brand-surface',
                   '--brand-text', '--brand-text-2', '--brand-border', '--brand-grad',
                   '--brand-display', '--brand-body', '--brand-mono']) {
    const v = st.getPropertyValue(k);
    if (v) root.style.setProperty(k, v); else root.style.removeProperty(k);
  }
  document.body.classList.toggle('branded', usable);
  // the owner's logo in the wordmark slot
  const logo = guideLogo(g);
  const slot = hero.querySelector('.wordmark .wmark');
  if (slot) {
    if (usable && logo) {
      slot.style.setProperty('--owner-logo', 'url("' + logo + '")');
      slot.classList.add('owner-logo');
    } else {
      slot.style.removeProperty('--owner-logo');
      slot.classList.remove('owner-logo');
    }
  }
  // Deliberately NOT applied: a guide's hero.badge_text does not get to
  // change the type pill. The pill says what KIND of page this is (APP,
  // SKILL, COMPONENT), which is a registry convention a reader relies on
  // reading the same across every owner, so it stays the Adom standard.
  return usable;
}

/* Size the app window to the showcase image's aspect inside the layout's
   slot, so `object-fit: contain` never has bars to paint (the black margins
   Colby saw on a wide table screenshot). Cover fits, full-hero imports and
   the CSS mock keep the layout's box. Re-runs on every render, on image
   load and on resize; export renders the same DOM, so it carries over. */
function fitWindowToShowcase() {
  const hero = $('hero');
  const win = hero && hero.querySelector('.window');
  const simg = $('showcase-img');
  if (!win || !simg) return;
  const vit = state.showcase || {};
  const active = !!vit.src && !vit.full && !vit.cover && simg.naturalWidth > 0 && simg.naturalHeight > 0;
  // measure the layout's box with any previous fit cleared
  win.style.width = '';
  win.style.height = '';
  if (!active) return;
  const wb = win.getBoundingClientRect();
  const tb = win.querySelector('.titlebar');
  const tbh = tb && getComputedStyle(tb).display !== 'none' ? tb.getBoundingClientRect().height : 0;
  const maxW = wb.width, maxBodyH = Math.max(0, wb.height - tbh);
  if (maxW < 4 || maxBodyH < 4) return;
  const ar = simg.naturalWidth / simg.naturalHeight;
  let w = maxW, bodyH = maxW / ar;
  if (bodyH > maxBodyH) { bodyH = maxBodyH; w = maxBodyH * ar; }
  win.style.width = w.toFixed(2) + 'px';
  win.style.height = (bodyH + tbh).toFixed(2) + 'px';
}

/// hero-studio#58: what will render the next Generate, live from the server
/// (refreshed before every Generate and once a minute). A dead renderer
/// disables the gate buttons and names the fix; a newer package on disk
/// under this running server says so, since only a restart picks it up.
function rendererDead() {
  const rs = state.renderer;
  return !!(rs && !rs.local.ok && !rs.pup.ok);
}
function renderRendererLine() {
  const rs = state.renderer;
  const dead = rendererDead();
  let txt = '';
  if (rs) {
    if (rs.local.ok) txt = 'renderer: local headless Chrome in this container';
    else if (rs.pup.ok) txt = 'renderer: host browser via pup (local ' + rs.local.why + '; for full fidelity: ' + rs.local.fix + ')';
    else txt = 'RENDERER NOT READY, Generate will fail: ' + rs.local.why + '; pup fallback: ' + rs.pup.why + '. Fix: ' + (rs.local.fix || 'hero-studio doctor');
    if (rs.onDisk) txt += ' | hero-studio ' + rs.onDisk + ' is installed on disk; this studio runs ' + rs.running + ': restart it to pick up the update';
  }
  // the classic panel line always says what will render
  const el = $('renderer-line');
  if (el) { el.textContent = txt; el.classList.toggle('bad', dead); }
  // the wizard is what the human sees: its line appears only when there is
  // something to act on (dead renderer, pup fallback, newer package on disk)
  const wl = $('wrenderer');
  if (wl) {
    const noteworthy = !!rs && (dead || !rs.local.ok || !!rs.onDisk);
    wl.style.display = noteworthy ? '' : 'none';
    wl.textContent = txt;
    wl.classList.toggle('bad', dead);
  }
  ['btn-generate', 'v-generate'].forEach((id) => {
    const b = $(id);
    if (!b) return;
    if (dead) { b.disabled = true; b.dataset.rdead = '1'; }
    else if (b.dataset.rdead) { delete b.dataset.rdead; b.disabled = false; }
  });
}
function render() {
  renderRendererLine();
  if (!state || !pages.length) return;
  if (window.wizardRender) { try { wizardRender(); } catch (e) { console.error('wizard render:', e); } }
  const p = pageByKey(state.page);
  const d = displayEntry();
  const mode = state.mode || 'app';
  const hero = $('hero');
  document.body.dataset.mode = mode;
  hero.dataset.mode = mode;
  hero.dataset.theme = state.theme;
  hero.dataset.layout = state.layout;
  hero.dataset.clayout = state.comp.layout;
  hero.dataset.imgsrc = state.comp.imgSrc;
  hero.dataset.mpn = state.comp.mpn;
  hero.classList.toggle('guides', !!state.guides);
  // imported background art (theme 'image', hero-studio#24 ask 7)
  const bg = state.bgImage || {};
  hero.style.setProperty('--bgimg', bg.src ? 'url(' + bg.src + ')' : 'none');
  hero.classList.toggle('bg-cover', !!bg.cover);
  $('bg-cover').checked = !!bg.cover;
  // ?? not ||: an angle of 0 is a real choice, not an unset value
  hero.style.setProperty('--ga', (state.gradAngle ?? 155) + 'deg');
  $('rng-angle').value = state.gradAngle ?? 155;
  $('angle-val').textContent = state.gradAngle ?? 155;

  // header subject
  $('subj-title').textContent = d.title;
  // zeros are noise: show only stats that exist (a fresh page reads clean)
  const metaParts = [wireLabel(d.type)];
  if (mode === 'component' && d.mpn) metaParts.push('MPN ' + d.mpn);
  if (d.score > 0) metaParts.push('score ' + d.score);
  if (d.stars > 0) metaParts.push(d.stars + ' stars');
  if (mode !== 'component' && d.installs30d > 0) metaParts.push(d.installs30d + ' installs/30d');
  $('subj-meta').textContent = metaParts.join(' · ');
  $('btn-guides').classList.toggle('on', !!state.guides);

  // verification chrome (the pose gate sets state.verify); per-item confirms
  const confirm = state.confirm || {};
  const flags = state.flags || {};
  $('v-title').checked = !!confirm.title;
  $('v-sub').checked = !!confirm.subtitle;
  $('v-showcase').checked = !!confirm.showcase;
  $('v-type').checked = !!confirm.type;
  // "Needs fixing" verdicts (#14): flags hold Generate and are visible to the
  // driving AI via state, so it can adjust and re-present instead of the
  // human laundering a problem through an approve-only checklist.
  for (const [el, key] of [['v-title','title'],['v-sub','subtitle'],['v-showcase','showcase'],['v-type','type']]) {
    const label = $(el).parentElement;
    let mark = label.querySelector('.flagged');
    if (flags[key] && !mark) {
      mark = document.createElement('span');
      mark.className = 'flagged';
      mark.textContent = 'NEEDS FIXING (flagged; the AI sees this in state.flags)';
      label.appendChild(mark);
    } else if (!flags[key] && mark) mark.remove();
  }
  const types = (Array.isArray(d.types) && d.types.length ? d.types : [d.type]).filter(Boolean);
  const modeMatchesType = (canonType(d.type) === 'component') === (mode === 'component');
  $('v-type-label').textContent = 'Page type matches: page is "' + types.map(wireLabel).join(' + ') + '", studio mode is "' + mode + '"' + (modeMatchesType ? '' : ' (MISMATCH: switch mode at the top of the panel)');
  $('v-type-label').classList.toggle('warn', !modeMatchesType);
  const anyFlag = Object.values(flags).some(Boolean);
  const lintFail = (state.lint || []).some(l => l.level === 'fail');
  $('v-generate').disabled = !(confirm.title && confirm.subtitle && confirm.showcase && confirm.type) || anyFlag || lintFail;

  // author-adjustable image-zone type (hero-studio#4); cqw so preview == render
  const fs = state.fontSize || {};
  hero.style.setProperty('--fs-title', (fs.title || 8) + 'cqw');
  hero.style.setProperty('--fs-sub', (fs.sub || 2.5) + 'cqw');
  hero.style.setProperty('--fs-desc', (fs.desc || 1.35) + 'cqw');
  hero.style.setProperty('--fs-leading', String(fs.leading || 1.02));
  hero.style.setProperty('--fs-gap', String(fs.gap == null ? 1 : fs.gap));
  for (const [id, val, dflt] of [['fs-title', fs.title, 8], ['fs-sub', fs.sub, 2.5], ['fs-desc', fs.desc, 1.35], ['fs-leading', fs.leading, 1.02], ['fs-gap', fs.gap, 1]]) {
    $(id).value = val || dflt;
    $(id + '-val').textContent = (val || dflt);
  }

  // the gate visibly CLOSES (hero-studio#5): approval is persistent state
  const ap = state.approved;
  const approvedHere = !!(ap && ap.page === state.page) && window._abDismissed !== (ap && ap.ts);
  $('approvedbar').style.display = approvedHere ? 'block' : 'none';
  if (approvedHere) {
    $('ab-body').textContent = ap.file + ', generated ' + new Date(ap.ts).toLocaleString() +
      '. The gate is satisfied; the PNG carries the provenance stamp. Apply it with: adom-wiki page hero ' + state.page + ' --image <repo path>';
  }
  $('verifybar').style.display = state.verify && !approvedHere ? 'block' : 'none';

  // stagebar (#21/#23): stage chrome above the stage, never on it
  $('stagebar').classList.toggle('on', !!state.verify || !!state.marqueePreview);
  $('sb-png').classList.toggle('on', !state.marqueePreview);
  $('sb-mq').classList.toggle('on', !!state.marqueePreview);

  // walkthrough + procedural background + measured layout lint + page fields
  showTourStep();
  scheduleLint();
  renderPageFields();
  if (state.theme === 'circuitry') requestAnimationFrame(drawCircuitry);
  $('btn-reroll').style.display = state.theme === 'circuitry' || isTraceTheme() ? '' : 'none';
  renderTraceControls($('trace-controls'));

  // marquee overlay preview (ask 5): today's REAL homepage treatment,
  // composited over the hero so redundancy and balance are judgeable
  hero.classList.toggle('show-mq', !!state.marqueePreview && mode === 'app');
  if (state.marqueePreview) {
    $('mqo-badges').innerHTML = '<span class="badge badge-' + canonType(d.type) + '">' + wireLabel(d.type) + '</span>' +
      (d.score > 0 ? '<span class="ts-slide-score">' + TREND13 + ' ' + d.score + '</span>' : '') +
      (d.stars > 0 ? '<span class="ts-slide-stars">' + STAR12 + ' ' + d.stars + '</span>' : '');
    // TODAY-REALITY on purpose (Colby): the homepage still overlays its own
    // title, so show the doubling instead of hiding it; the tag names the
    // planned fix so nobody thinks it is an accident.
    $('mqo-title').textContent = d.title;
    $('mqo-brief').textContent = firstSentence(d.brief || p.brief || '', 120);
    $('mqo-owner').textContent = 'wiki.adom.inc/' + d.owner + '/' + d.slug;
    // sample prompt: DYNAMIC by design (#22): metadata the page renders with a
    // copy button; if it were pixels nobody could copy it
    const sp = (Array.isArray(d.sample_prompts) && d.sample_prompts[0] && d.sample_prompts[0].prompt) || state.samplePrompt || '';
    $('mqo-prompt').style.display = sp ? 'flex' : 'none';
    $('mqo-prompt-text').textContent = sp ? '"' + sp + '"' : '';
  }
  $('chk-mqpreview').checked = !!state.marqueePreview;

  // showcase slot size (ask 1): measure the window body, scale to export px.
  // The window may be hugging the current image (fitWindowToShowcase), which
  // is NOT the advice a fresh capture wants: clear the fit, measure the
  // layout's own slot, then restore it.
  requestAnimationFrame(() => {
    const win = hero.querySelector('.appbody');
    const el = $('slot-size');
    if (!win || !el) return;
    const frame = hero.querySelector('.window');
    const fitW = frame ? frame.style.width : '';
    const fitH = frame ? frame.style.height : '';
    if (frame) { frame.style.width = ''; frame.style.height = ''; }
    const hb = hero.getBoundingClientRect();
    const wb = win.getBoundingClientRect();
    if (frame) { frame.style.width = fitW; frame.style.height = fitH; }
    if (hb.width < 10 || wb.width < 10) { el.textContent = 'showcase slot hidden in this layout'; return; }
    const k = 2000 / hb.width;
    const w = Math.round(wb.width * k);
    const h = Math.round(wb.height * k);
    el.dataset.size = w + 'x' + h;
    el.textContent = 'shoot the showcase image at ' + w + 'x' + h +
      ' (aspect ' + (wb.width / wb.height).toFixed(2) + ') for this placement; click to copy';
    // Report only when THIS surface's own measurement changes. Comparing against the server's value
    // made two surfaces at different widths (two tabs, or a tab and the render view) correct each
    // other forever: each POST is broadcast, the other sees a mismatch and posts back.
    const key = w + 'x' + h;
    if (window._slotSent !== key) { window._slotSent = key; send({ slotSize: { w: w, h: h } }); }
  });

  // showcase content: real screenshot in the window, or full-hero import
  const vit = state.showcase || {};
  hero.classList.toggle('showcase-cover', !!vit.cover);
  $('showcase-cover').checked = !!vit.cover;
  hero.classList.toggle('has-showcase', !!vit.src && !vit.full);
  // fake titlebar: real window screenshots carry their own chrome, so a
  // second bar above it reads as an obvious fake (#11). Auto = off whenever
  // an import is present; the CSS mock keeps it. Explicit choice wins.
  // Hardware pages: an imported render is a physical part, not a program,
  // so auto = bare (no frame, bar, or shadow at all); a fake app window
  // around a 3D board render reads as a screenshot of software.
  const isHardware = canonType(d.type) === 'component';
  const chromeMode = vit.chrome || ((vit.src && !vit.full) ? (isHardware ? 'bare' : 'none') : 'fake');
  hero.classList.toggle('no-chrome', chromeMode === 'none');
  hero.classList.toggle('bare-chrome', chromeMode === 'bare');
  $('showcase-chrome').checked = chromeMode === 'fake';
  hero.classList.toggle('fullimport', !!vit.src && !!vit.full);
  const simOverImport = !!(vit.src && vit.full);
  for (const sid of ['pill-tr', 'slug-br']) {
    const sEl = $(sid);
    if (sEl) sEl.classList.toggle('sim-fenced', simOverImport);
  }
  // Setting an <img> src to '' fires onerror in Chrome, and render() runs on
  // every SSE tick, so the mock (no image) used to toast "failed to load" on
  // every tick. Only bind the handler when there IS a src to load, and warn
  // once per distinct src, never per render.
  const simg = $('showcase-img');
  const want = (!vit.full && vit.src) || '';
  if (simg.getAttribute('src') !== want) {
    simg.onerror = null;
    if (want) {
      simg.onerror = () => {
        if (simg.dataset.warned === want) return;
        simg.dataset.warned = want;
        try { fetch('console', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ level: 'error', text: 'showcase image failed to load: ' + String(vit.src).slice(0, 80) }) }); } catch {}
        handleToast({ text: 'The showcase image failed to load (see console); re-import it or pick another source.', kind: 'warn', seq: Date.now() });
      };
      simg.src = want;
    } else {
      simg.removeAttribute('src');
    }
  }
  // The window hugs the imported image (no letterbox bars): its box is the
  // layout's slot at most, shrunk on one axis to the image's aspect. The
  // slot text above is measured BEFORE this runs (inline sizes cleared), so
  // "shoot at WxH" still names the layout's full slot.
  simg.onload = fitWindowToShowcase;
  fitWindowToShowcase();
  // Never set src='' (Chrome fires onerror on it and every page-health scan
  // counts it as a broken image; this is the exact mechanism behind the
  // wiki#125 "broken_images_6" false alarm). Remove the attribute instead.
  const fimg = $('fullimport-img');
  const fwant = (vit.full && vit.src) || '';
  if (fimg.getAttribute('src') !== fwant) { if (fwant) fimg.src = fwant; else fimg.removeAttribute('src'); }
  $('showcase-full').checked = !!vit.full;

  // The hero wears the PAGE OWNER's brand guide: its colors, its type, its
  // logo. Adom's look appears only on adom pages (adom/wiki brand guides).
  const foreign = d.owner !== 'adom';
  applyGuide(hero, state.guide, foreign);
  document.querySelectorAll('.wordmark').forEach(w => w.classList.toggle('noadom', foreign && !guideLogo(state.guide)));
  // #37: the standard-wordmark stamp for imports; adom-owned pages only (the
  // no-Adom-mark-for-foreign-owners policy applies to the stamp too)
  hero.classList.toggle('stamp-logo', !!(state.overlay || {}).logo && !foreign);
  $('stamp-logo-row').style.display = (simOverImport && !foreign) ? '' : 'none';
  $('chk-stamp-logo').checked = !!(state.overlay || {}).logo;
  $('fi-logo').classList.toggle('sim-fenced', simOverImport && !!(state.overlay || {}).logo);

  // component image zone
  if (mode === 'component' && comps.length) {
    const c = compById(state.comp.id);
    $('c-family').textContent = c.family;
    $('c-mpn-chip').textContent = c.mpn;
    $('c-name').textContent = c.name;
    $('c-mpn-under').textContent = c.mpn;
    $('c-spec').textContent = c.spec;
  }

  // image zone
  const showTitleInImage = state.show.title && state.titleMode !== 'overlay-only';
  // The logo slot shows the OWNER's logo when their guide has one; Adom's
  // wordmark still only on adom pages (adom/wiki brand guides).
  $('el-logo').style.display = (state.show.logo && (!foreign || !!guideLogo(state.guide))) ? 'flex' : 'none';
  $('el-title').style.display = showTitleInImage ? 'block' : 'none';
  $('el-title').textContent = state.headline || p.title;
  const hlIn = $('headline-input');
  if (hlIn && document.activeElement !== hlIn) hlIn.value = state.headline || '';
  $('el-sub').style.display = state.show.subtitle ? 'block' : 'none';
  const subText = state.subtitle || firstSentence(p.brief, 80);
  if (Array.isArray(state.accentWords)) {
    $('el-sub').innerHTML = '';
    const on = new Set(state.accentWords);
    let run = null;
    subText.split(/\s+/).filter(Boolean).forEach((w, i) => {
      const sel = on.has(i);
      if (run && run.dataset.acc === (sel ? '1' : '0')) { run.textContent += ' ' + w; return; }
      if (run) $('el-sub').appendChild(document.createTextNode(' '));
      run = document.createElement('span');
      run.dataset.acc = sel ? '1' : '0';
      if (sel) run.className = 'accent';
      run.textContent = w;
      $('el-sub').appendChild(run);
    });
  } else {
    $('el-sub').innerHTML = accentize(subText);
  }
  $('el-desc').style.display = state.show.description ? 'block' : 'none';
  $('el-desc').textContent = firstSentence(p.brief.slice(80), 110);
  $('win-label').textContent = p.title + ' · Adom webview';

  // lower band: gone for app pages (the page shows the image bare); kept in
  // component mode for the MPN-in-overlay placement option
  hero.querySelector('.lower').style.display = mode === 'app' ? 'none' : '';
  const showTitleInOverlay = mode === 'component' || state.titleMode !== 'image-only';
  // Pill GROUP (#19): a page carrying several types (app + service) renders
  // them as one clustered badge box; the linter treats the cluster as one box.
  const ptr = $('pill-tr');
  ptr.style.display = state.overlay.pill ? '' : 'none';
  ptr.innerHTML = types.map(t => '<span class="pill ' + canonType(t) + '">' + wireLabel(t).toUpperCase() + '</span>').join('');
  $('l-trend').style.display = state.overlay.scores ? '' : 'none';
  $('l-trend').querySelector('b').textContent = d.score.toLocaleString('en-US');
  $('l-stars').style.display = state.overlay.scores ? '' : 'none';
  $('l-stars').querySelector('b').textContent = d.stars;
  $('l-installs').style.display = (state.overlay.scores && d.installs30d != null) ? '' : 'none';
  $('l-installs').querySelector('b').textContent = d.installs30d;
  const url = 'https://wiki.adom.inc/' + d.owner + '/' + d.slug;
  $('l-title').style.display = showTitleInOverlay ? 'block' : 'none';
  $('l-title').textContent = d.title;
  $('l-title').href = url;
  $('l-desc').textContent = firstSentence(d.brief || p.brief || '', 140);
  const subtitleInImage = mode === 'app' && state.show.subtitle;
  $('l-desc').style.display = (!subtitleInImage && (d.brief || p.brief)) ? 'block' : 'none';
  $('l-author').style.display = state.overlay.author ? '' : 'none';
  $('l-author').innerHTML = 'by <a href="' + url + '" target="_blank">' + d.author + '</a>';
  const mpnFit = fitMono(d.mpn || '');
  $('l-mpn').textContent = mpnFit.text;
  $('l-mpn').title = d.mpn || '';
  $('l-mpn').classList.toggle('fit', mpnFit.small);
  const ref = 'wiki.adom.inc/' + d.owner + '/' + d.slug;
  const refFit = fitMono(ref);
  $('l-slug').style.display = state.overlay.slug ? '' : 'none';
  $('l-slug').textContent = refFit.text;
  $('l-slug').title = ref;
  $('l-slug').classList.toggle('fit', refFit.small);
  $('l-slug').href = url;

  // controls reflect state
  document.querySelectorAll('#grp-mode .opt').forEach(b => b.classList.toggle('on', b.dataset.v === mode));
  if (comps.length) $('sel-comp').value = compById(state.comp.id).id;
  document.querySelectorAll('#grp-imgsrc .opt').forEach(b => b.classList.toggle('on', b.dataset.v === state.comp.imgSrc));
  document.querySelectorAll('#grp-clayout .opt').forEach(b => b.classList.toggle('on', b.dataset.v === state.comp.layout));
  document.querySelectorAll('#grp-mpn .opt').forEach(b => b.classList.toggle('on', b.dataset.v === state.comp.mpn));
  $('sel-page').value = state.page;
  document.querySelectorAll('#grp-layout .opt').forEach(b => b.classList.toggle('on', b.dataset.v === state.layout));
  document.querySelectorAll('#grp-theme .swatch').forEach(b => b.classList.toggle('on', b.dataset.v === state.theme));
  document.querySelectorAll('#grp-titlemode .opt').forEach(b => b.classList.toggle('on', b.dataset.v === state.titleMode));
  $('chk-logo').checked = state.show.logo;
  $('chk-title').checked = state.show.title;
  $('chk-subtitle').checked = state.show.subtitle;
  $('chk-description').checked = state.show.description;
  $('chk-pill').checked = state.overlay.pill;
  $('chk-scores').checked = state.overlay.scores;
  $('chk-author').checked = state.overlay.author;
  $('chk-slug').checked = state.overlay.slug;

  scheduleTraces();
  syncPreviews();
}

/* in-context previews: markup mirrors git-wiki lib/templates.js (popularMarquee + categoryListPage) */
const STAR12 = '<svg width="12" height="12" viewBox="0 0 24 24" fill="#d29922" stroke="#d29922" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>';
const TREND13 = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>';
const IMGPH = '<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#484f58" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>';

function relTime(iso) {
  if (!iso) return '';
  const s = (Date.now() - new Date(iso).getTime()) / 1000;
  if (s < 3600) return Math.max(1, Math.floor(s / 60)) + 'm ago';
  if (s < 86400) return Math.floor(s / 3600) + 'h ago';
  return Math.floor(s / 86400) + 'd ago';
}

function marqueeTile(p, isLive) {
  const heroPart = isLive ? '<div class="clone-slot"></div>'
    : '<div class="ts-hero-fallback"><span>' + p.title + '</span></div>';
  // DECIDED (Colby, 2026-07-26, adom/wiki#80): hero-bearing tiles get badges
  // and owner only; the baked title is the single title. Heroless fallback
  // tiles keep the text overlay, which is their only title.
  const textPart = isLive
    ? ''
    : '<h3 class="ts-slide-title">' + p.title + '</h3>' +
      (p.brief ? '<p class="ts-slide-brief">' + p.brief + '</p>' : '');
  return '<a class="ts-slide mq-slide">' + heroPart +
    '<div class="ts-slide-shade"></div><div class="ts-slide-info">' +
    '<div class="ts-slide-badges"><span class="badge badge-' + p.type + '">' + p.type + '</span>' +
    (p.score > 0 ? '<span class="ts-slide-score">' + TREND13 + ' ' + p.score + '</span>' : '') +
    (p.stars > 0 ? '<span class="ts-slide-stars">' + STAR12 + ' ' + p.stars + '</span>' : '') +
    '</div>' + textPart +
    '<span class="ts-slide-owner">' + p.owner + '/' + p.slug + '</span></div></a>';
}

function indexCard(p, isLive) {
  const hero = isLive ? '<div class="clone-slot"></div>'
    : '<div class="page-card-hero-placeholder">' + IMGPH + '</div>';
  return '<a class="page-card">' +
    '<div class="page-card-hero">' + hero + '</div>' +
    '<div class="page-card-body">' +
    '<div class="page-card-top"><span class="badge badge-' + p.type + '">' + p.type + '</span></div>' +
    '<h3 class="page-card-title">' + p.title + '</h3>' +
    (p.brief ? '<p class="page-card-brief">' + p.brief + '</p>' : '') +
    '<div class="page-card-meta"><span>' + p.owner + '/' + p.slug + '</span>' +
    (p.updated_at ? '<span class="dot-sep">&middot;</span><span>' + relTime(p.updated_at) + '</span>' : '') +
    (p.stars > 0 ? '<span class="dot-sep">&middot;</span><span class="page-card-stars">' + STAR12 + ' ' + p.stars + '</span>' : '') +
    '</div>' +
    (p.tags && p.tags.length ? '<div class="page-card-tags">' + p.tags.map(t => '<span class="chip-sm">' + t + '</span>').join('') + '</div>' : '') +
    '<div class="page-card-install"><code>adom-wiki pkg install ' + p.owner + '/' + p.slug + '</code></div>' +
    '</div></a>';
}

function cloneHero(imageOnly) {
  const c = $('hero').cloneNode(true);
  c.removeAttribute('id');
  c.querySelectorAll('[id]').forEach(n => n.removeAttribute('id'));
  c.classList.add('preview');
  c.classList.remove('guides');
  if (imageOnly) { const l = c.querySelector('.lower'); if (l) l.remove(); }
  // The live hero keeps its (hidden) showcase/fullimport <img> slots because
  // the CSS toggles them by class. A CLONE is a display copy rebuilt every
  // render, so an img with no src is pure dead weight there and reads as a
  // "broken image" to any DOM-health scan (pup's page check counted these,
  // wiki#125). Drop them from clones.
  c.querySelectorAll('img').forEach(im => { if (!im.getAttribute('src')) im.remove(); });
  return c;
}

function syncPreviews() {
  const p = displayEntry();
  const nb = pages.filter(x => x.owner + '/' + x.slug !== p.owner + '/' + p.slug).slice(0, 3);
  // marquee: live tile among real neighbors, content doubled for the -50% loop
  const seq = marqueeTile(p, true) + nb.map(x => marqueeTile(x)).join('');
  $('mq-track').innerHTML = seq + seq;
  // index grid: live card first, then real neighbor cards (no-hero placeholder icon)
  $('wikigrid').innerHTML = indexCard(p, true) + nb.map(x => indexCard(x)).join('');
  // wiki surfaces get the IMAGE PART only (the wiki overlays its own text);
  // the desk bar gets the full v2 hero with its baked-in text overlay
  document.querySelectorAll('#contexts .clone-slot').forEach(s => s.appendChild(cloneHero(true)));
  document.querySelectorAll('#contexts .heroslot').forEach(s => { s.innerHTML = ''; s.appendChild(cloneHero(false)); });
}

let seenToastSeq = 0;
function handleToast(t) {
  if (t && t.seq > seenToastSeq) { seenToastSeq = t.seq; toast(t.text, t.kind); }
}

function toast(text, kind) {
  const el = document.createElement('div');
  el.className = 'toast ' + (kind || 'info');
  el.textContent = text;
  $('toasts').appendChild(el);
  setTimeout(() => el.remove(), 4000);
}

/* wire controls: server owns the truth, SSE echoes back */
document.querySelectorAll('#grp-mode .opt').forEach(b => b.addEventListener('click', () => {
  // #53: entering the component design lab with no part re-seeds its built-in
  // sample EXPLICITLY (the lab's photos are of this part); a posed page never
  // carries it implicitly (pose nulls comp.id).
  const payload = { mode: b.dataset.v };
  if (b.dataset.v === 'component' && !(state.comp && state.comp.id)) payload.comp = { id: 'sm02b-srss-tb' };
  send(payload);
}));
$('sel-comp').addEventListener('change', e => send({ comp: { id: e.target.value } }));
document.querySelectorAll('#grp-imgsrc .opt').forEach(b => b.addEventListener('click', () => send({ comp: { imgSrc: b.dataset.v } })));
document.querySelectorAll('#grp-clayout .opt').forEach(b => b.addEventListener('click', () => send({ comp: { layout: b.dataset.v } })));
document.querySelectorAll('#grp-mpn .opt').forEach(b => b.addEventListener('click', () => send({ comp: { mpn: b.dataset.v } })));
$('sel-page').addEventListener('change', e => {
  const v = e.target.value;
  send({ page: v });
  // #30: dropdown rows are pose-time snapshots; pull the live page so a
  // just-renamed title or brief cannot be ratified stale.
  post('refresh-page?page=' + encodeURIComponent(v))
    .then(r => r.json())
    .then(j => {
      if (!j || !j.ok) return;
      return fetch('api/pages').then(r => r.json()).then(rows => {
        pages = rows;
        const sel = $('sel-page');
        sel.innerHTML = pages.map(p => '<option value="' + p.owner + '/' + p.slug + '">' + p.title + ' (' + p.type + ')</option>').join('');
        sel.value = v;
        render();
      });
    }).catch(() => {});
});
document.querySelectorAll('#grp-layout .opt').forEach(b => b.addEventListener('click', () => send({ layout: b.dataset.v })));
document.querySelectorAll('#grp-theme .swatch').forEach(b => b.addEventListener('click', () => send({ theme: b.dataset.v })));
document.querySelectorAll('#grp-titlemode .opt').forEach(b => b.addEventListener('click', () => send({ titleMode: b.dataset.v })));
$('chk-logo').addEventListener('change', e => send({ show: { logo: e.target.checked } }));
$('chk-title').addEventListener('change', e => send({ show: { title: e.target.checked } }));
$('chk-subtitle').addEventListener('change', e => send({ show: { subtitle: e.target.checked } }));
$('chk-description').addEventListener('change', e => send({ show: { description: e.target.checked } }));
$('chk-pill').addEventListener('change', e => send({ overlay: { pill: e.target.checked } }));
$('chk-scores').addEventListener('change', e => send({ overlay: { scores: e.target.checked } }));
$('chk-author').addEventListener('change', e => send({ overlay: { author: e.target.checked } }));
$('chk-slug').addEventListener('change', e => send({ overlay: { slug: e.target.checked } }));
$('btn-guides').addEventListener('click', () => send({ guides: !state.guides }));
let angleTimer = null;
$('rng-angle').addEventListener('input', e => {
  $('angle-val').textContent = e.target.value;
  $('hero').style.setProperty('--ga', e.target.value + 'deg'); // instant local feedback
  clearTimeout(angleTimer);
  angleTimer = setTimeout(() => send({ gradAngle: +e.target.value }), 120);
});
$('btn-rand').addEventListener('click', () => {
  const themes = ['midnight','pcb','onion','pill','grid','gradient'];
  const angles = [65, 100, 155, 210, 245, 290, 335];
  const theme = themes[Math.floor(Math.random()*themes.length)];
  const gradAngle = angles[Math.floor(Math.random()*angles.length)];
  if (state.mode === 'component') {
    const clayouts = ['photo-right','photo-left','photo-full'];
    const srcs = ['photo','raytrace','phong'];
    const mpns = ['eyebrow','under-title','overlay'];
    const c = comps[Math.floor(Math.random() * comps.length)];
    send({ theme, gradAngle, comp: { id: c.id, layout: clayouts[Math.floor(Math.random()*clayouts.length)],
      imgSrc: srcs[Math.floor(Math.random()*srcs.length)], mpn: mpns[Math.floor(Math.random()*mpns.length)] } });
    toast('Randomized: ' + c.name, 'info');
  } else {
    const layouts = ['bleed-bottom','bleed-right','bleed-top','bleed-corner-br','bleed-corner-tr','contained','mirror'];
    const p = pages[Math.floor(Math.random() * pages.length)];
    send({ page: p.owner + '/' + p.slug, layout: layouts[Math.floor(Math.random()*layouts.length)], theme, gradAngle });
    toast('Randomized: ' + p.title, 'info');
  }
});
let seenGenSeq = 0;
function showGenerated(g) {
  $('gen-img').src = g.url + '?t=' + g.ts;
  $('gen-path').textContent = g.file;
  $('gen-open').href = g.url;
  $('genmodal').classList.add('show');
}
/* fires for local clicks AND for API-triggered generates (SSE or poll), deduped by seq */
function handleGen(g) {
  if (g && g.ok && g.seq > seenGenSeq) {
    seenGenSeq = g.seq;
    if (window.wizardOnGenerated) { try { window.wizardOnGenerated(); } catch {} }
    showGenerated(g);
  }
}
$('gen-close').addEventListener('click', e => { e.preventDefault(); $('genmodal').classList.remove('show'); });
$('genmodal').addEventListener('click', e => { if (e.target === $('genmodal')) $('genmodal').classList.remove('show'); });
function runGenerate() {
  const b = $('btn-generate');
  b.disabled = true; b.textContent = 'Generating...';
  toast('Rendering the hero on the server', 'info');
  post('generate', { fromGate: true }).then(r => r.json()).then(g => {
    b.disabled = false; b.textContent = 'Generate hero PNG';
    if (g.ok) handleGen(g); // success toast arrives via the live channel
    else toast('Generate failed: ' + (g.error || 'unknown') + (g.hint ? '. ' + g.hint : ''), 'error');
  }).catch(() => { b.disabled = false; b.textContent = 'Generate hero PNG'; toast('Generate failed: network error', 'error'); });
}
$('btn-generate').addEventListener('click', runGenerate);
$('v-generate').addEventListener('click', runGenerate);

$('btn-reset').addEventListener('click', () => {
  post('preset', { name: 'studio-default' });
  toast('Back to the studio defaults (server truth, not a client copy)', 'success');
});

/* tooltips: fixed div, 600ms delay, clamped to viewport on both axes */
(function () {
  const tip = $('tip');
  let timer = null;
  document.addEventListener('mouseover', e => {
    const t = e.target.closest('[data-tip]');
    if (!t) return;
    clearTimeout(timer);
    timer = setTimeout(() => {
      tip.textContent = t.getAttribute('data-tip');
      tip.classList.add('show');
      const r = t.getBoundingClientRect();
      let x = r.left + 14, y = r.bottom + 10;
      const w = tip.offsetWidth, h = tip.offsetHeight, M = 8;
      if (y + h > innerHeight - M) y = r.top - h - 10;
      x = Math.max(M, Math.min(x, innerWidth - w - M));
      y = Math.max(M, Math.min(y, innerHeight - h - M));
      tip.style.left = x + 'px'; tip.style.top = y + 'px';
    }, 600);
  });
  document.addEventListener('mouseout', e => {
    if (e.target.closest && e.target.closest('[data-tip]')) { clearTimeout(timer); tip.classList.remove('show'); }
  });
})();

/* verification checklist + showcase controls */
[['v-title','title'],['v-sub','subtitle'],['v-showcase','showcase'],['v-type','type']].forEach(([el, key]) => {
  // ticking a box also clears any needs-fixing flag on that item (#14)
  $(el).addEventListener('change', e => send({ confirm: { [key]: e.target.checked }, flags: { [key]: false } }));
});
$('verifybar').addEventListener('click', e => {
  if (e.target.id !== 'v-generate' && !$('v-generate').disabled) return;
  if ($('v-generate').disabled && (e.target.id === 'v-generate' || e.target.closest('#v-generate'))) {
    const flags = Object.entries(state.flags || {}).filter(([, v]) => v).map(([k]) => k);
    const fails = (state.lint || []).filter(l => l.level === 'fail');
    let why = 'Confirm all four checks first; Generate unlocks when every item is ratified';
    if (flags.length) why = 'Generate is locked: ' + flags.join(', ') + ' flagged as needs-fixing. Fix and re-tick, or the AI adjusts and re-presents';
    else if (fails.length) why = 'Generate is locked by the layout linter: ' + fails.map(f => f.msg).join(' | ');
    handleToast({ text: why, kind: 'warn', seq: Date.now() });
  }
});
$('bg-file').addEventListener('change', e => {
  const f = e.target.files && e.target.files[0];
  if (!f) return;
  const r = new FileReader();
  r.onload = () => send({ bgImage: { src: r.result }, theme: 'image' });
  r.readAsDataURL(f);
});
$('bg-cover').addEventListener('change', e => send({ bgImage: { cover: e.target.checked } }));
$('showcase-file').addEventListener('change', e => {
  const f = e.target.files && e.target.files[0];
  if (!f) return;
  const r = new FileReader();
  r.onload = () => send({ showcase: { src: r.result } });
  r.readAsDataURL(f);
});
$('showcase-full').addEventListener('change', e => send({ showcase: { full: e.target.checked } }));
$('chk-stamp-logo').addEventListener('change', e => send({ overlay: { logo: e.target.checked } }));
$('showcase-cover').addEventListener('change', e => send({ showcase: { cover: e.target.checked } }));
$('showcase-chrome').addEventListener('change', e => send({ showcase: { chrome: e.target.checked ? 'fake' : 'none' } }));
$('chk-mqpreview').addEventListener('change', e => send({ marqueePreview: e.target.checked }));
$('slot-size').addEventListener('click', () => {
  const sz = $('slot-size').dataset.size;
  if (sz && navigator.clipboard) navigator.clipboard.writeText(sz)
    .then(() => handleToast({ text: 'Copied ' + sz, kind: 'success', seq: Date.now() })).catch(() => {});
});
$('showcase-clear').addEventListener('click', () => send({ showcase: { src: '', full: false } }));
$('showcase-pull').addEventListener('click', () => {
  post('showcase-from-page?page=' + encodeURIComponent(state.page))
    .then(r => r.json())
    .then(j => handleToast({ text: j.ok ? 'Showcase loaded: ' + j.picked : 'No page screenshot: ' + j.error, kind: j.ok ? 'success' : 'warn', seq: Date.now() }))
    .catch(() => {});
});

/* verification walkthrough: spotlight each checklist item in place */
const TOUR_STEPS = [
  { key: 'title', el: 'el-title', title: 'This is your title',
    body: 'A hero gets across exactly ONE message, and this is it: the title, ideally approaching one word. Six messages equal zero messages. It bakes at this exact size; make sure it reads at dock size.' },
  { key: 'subtitle', el: 'el-sub', title: 'This is your subtitle',
    body: 'Deliberately small BECAUSE it is assumed nobody reads it. If the design only works when someone reads this line, the hero has failed. Check the wording anyway.' },
  { key: 'showcase', el: 'showcase-img', fallbackEl: 'circuitcanvas', winSel: '.window', title: 'This is your showcase image',
    body: 'Supporting evidence, NOT the message; the title is the message. It should show YOUR app, not a generic chart, and it should leave white space: negative space is what makes the one message land.' },
  { key: 'type', el: 'pill-tr', title: 'This is your type pill',
    body: 'app / skill / hardware / bootstrap (hardware is the launch name; component is its legacy spelling and survives as a kind). AIs pick this wrong often; if it says the wrong thing, yell at yours now, before it bakes.' },
];
let tourIdx = -1;
function tourTarget(step) {
  if (step.winSel) { const w = hero.querySelector(step.winSel); if (w && w.offsetParent) return w; }
  const el = $(step.el);
  if (el && el.offsetParent) return el;
  return step.fallbackEl ? $(step.fallbackEl) : null;
}
function clearTourGlow() {
  document.querySelectorAll('.tour-glow').forEach(n => n.classList.remove('tour-glow'));
}
function showTourStep() {
  clearTourGlow();
  // the render view is what Generate and compose screenshot: a spotlight
  // there bakes a ring around the title into the exported PNG
  if (RENDER_VIEW) return;
  // the walkthrough is classic-only: in wizard mode the review step owns
  // spotlighting, and two rings on the stage read as one ring too many
  if (document.body.classList.contains('wizard')) { $('tourpop').style.display = 'none'; return; }
  const confirm = state.confirm || {};
  const flags = state.flags || {};
  // a flagged step is reviewed (verdict: needs fixing), so the tour moves on;
  // the flag itself keeps Generate locked until it is cleared
  const remaining = TOUR_STEPS.filter(st => !confirm[st.key] && !flags[st.key]);
  if (!state.tour || !state.verify || remaining.length === 0) {
    $('tourpop').style.display = 'none';
    if (state.tour && remaining.length === 0) send({ tour: false });
    return;
  }
  const step = remaining[0];
  tourIdx = TOUR_STEPS.indexOf(step);
  const target = tourTarget(step);
  if (!target) { $('tourpop').style.display = 'none'; return; }
  target.classList.add('tour-glow');
  const r = target.getBoundingClientRect();
  const pop = $('tourpop');
  $('tp-title').textContent = step.title;
  $('tp-body').textContent = step.body;
  pop.style.display = 'block';
  placeTourPop(pop, target, r);
}
/* The card must NEVER cover the element it asks the human to verify: a
   coach-mark over its own subject collects blind confirmations (#10).
   Try below / above / right / left; take the first spot that fits the
   viewport and does not intersect the target. If no spot exists (target
   nearly fills the window), lift the target above the card instead. */
function placeTourPop(pop, target, r) {
  const W = window.innerWidth, H = window.innerHeight, M = 12, GAP = 14;
  const pw = pop.offsetWidth, ph = pop.offsetHeight;
  const candidates = [
    { top: r.bottom + GAP, left: r.left },
    { top: r.top - ph - GAP, left: r.left },
    { top: r.top, left: r.right + GAP },
    { top: r.top, left: r.left - pw - GAP },
  ];
  const fits = c => c.top >= M && c.left >= M && c.top + ph <= H - M && c.left + pw <= W - M;
  const hides = c => !(c.left + pw <= r.left || c.left >= r.right || c.top + ph <= r.top || c.top >= r.bottom);
  let spot = candidates.find(c => fits(c) && !hides(c));
  if (!spot) {
    // clamp into the viewport on the roomier vertical side of the target
    const left = Math.max(M, Math.min(W - pw - M, r.left));
    spot = (H - r.bottom >= r.top)
      ? { top: Math.max(M, Math.min(H - ph - M, r.bottom + GAP)), left }
      : { top: Math.max(M, r.top - ph - GAP), left };
  }
  document.querySelectorAll('.tour-above').forEach(n => n.classList.remove('tour-above'));
  if (hides(spot)) target.classList.add('tour-above');
  // apply the human's drag displacement (#13): anchored spawn gives context,
  // the remembered offset keeps later steps out of the way they chose
  pop._anchor = { top: spot.top, left: spot.left };
  const top = Math.max(4, Math.min(window.innerHeight - 60, spot.top + tpDrag.dy));
  const left = Math.max(4, Math.min(window.innerWidth - 80, spot.left + tpDrag.dx));
  pop.style.top = top + 'px';
  pop.style.left = left + 'px';
}
window.addEventListener('resize', () => { fitWindowToShowcase(); if (state.tour && state.verify) showTourStep(); });

/* ---- layout linter (#18): the studio notices, the human decides ----
   Most engineers are not graphic designers; the studio is the only party that
   reliably knows the geometry, so it encodes the judgment. Runs after every
   render (debounced), posts results to state.lint (visible to the driving AI),
   and hard failures (clipped text) lock Generate. Warnings advise. */
let lintTimer = null;
let lastLintKey = '';
function scheduleLint() { clearTimeout(lintTimer); lintTimer = setTimeout(runLint, 450); }
function runLint() {
  if (!state) return;
  const items = computeLint();
  if (!items) return;
  return items;
}
window.__computeLint = () => (computeLint() || []).map(({ id, level, msg }) => ({ id, level, msg }));
/* The pure measurement, shared with the server's headless compose render
   (hero-studio#51): the server evaluates window.__computeLint in the same
   page code it screenshots, so measured lint reflects the EXACT composed
   pose with no dependency on an open, responsive surface. */
function computeLint() {
  if (!state) return null;
  const hero = $('hero');
  const H = hero.getBoundingClientRect();
  if (H.width < 60) return null;
  const items = [];
  if (isTraceTheme() && !(state.showcase && state.showcase.full) && window.__traceError) {
    items.push({ id: 'circuit-render', level: 'fail', el: null, msg: 'Circuit traces could not render: ' + window.__traceError });
  }
  const rel = (r) => ({ left: r.left - H.left, top: r.top - H.top, right: r.right - H.left, bottom: r.bottom - H.top, width: r.width, height: r.height });
  const vis = (el) => el && el.offsetParent && el.getBoundingClientRect().width > 2;
  const boxOf = (el) => rel(el.getBoundingClientRect());
  // hero-studio#51: an element box is the LAYOUT column; glyphs overflow it
  // (a long word at a big size escapes right) and box-vs-box overlap misses
  // exactly the collision a human sees. Range client rects measure the
  // rendered text lines themselves, overflow included.
  const textBoxOf = (el) => {
    try {
      const range = document.createRange();
      range.selectNodeContents(el);
      const rects = Array.from(range.getClientRects()).filter((r) => r.width > 1 && r.height > 1);
      if (!rects.length) return boxOf(el);
      const u = {
        left: Math.min.apply(null, rects.map((r) => r.left)),
        top: Math.min.apply(null, rects.map((r) => r.top)),
        right: Math.max.apply(null, rects.map((r) => r.right)),
        bottom: Math.max.apply(null, rects.map((r) => r.bottom)),
      };
      return rel({ left: u.left, top: u.top, right: u.right, bottom: u.bottom, width: u.right - u.left, height: u.bottom - u.top });
    } catch { return boxOf(el); }
  };
  const overlapPx = (a, b) => Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left)) * Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top));

  const ptrEl = $('pill-tr');
  const pill = vis(ptrEl) ? boxOf(ptrEl) : null;
  const winEl = hero.querySelector('.window');
  const showcase = (winEl && winEl.offsetParent && state.mode !== 'component') ? boxOf(winEl) : null;
  const texts = [['title', 'el-title'], ['subtitle', 'el-sub'], ['detail line', 'el-desc']]
    .filter(([, id]) => vis($(id)))
    .map(([name, id]) => ({ name, id, box: textBoxOf($(id)) }));

  // 1. pill vs showcase / pill vs baked text (#17). Pill placement is
  // contract-fixed top right, so the in-contract fix is a backdrop scrim
  // (reads as a deliberate badge) plus this warning.
  ptrEl.classList.toggle('scrim', !!(pill && showcase && overlapPx(pill, showcase) > 9));
  if (pill && showcase && overlapPx(pill, showcase) > 9) {
    items.push({ id: 'pill-showcase', level: 'warn', el: 'pill-tr', msg: 'type pill sits on the showcase image (scrim applied so it reads as a badge). If the app has its own corner UI there, pick a layout that leaves the top-right clear' });
  }
  for (const t of texts) {
    if (pill && overlapPx(pill, t.box) > 9) items.push({ id: 'pill-' + t.id, level: 'warn', el: 'pill-tr', msg: 'type pill overlaps the ' + t.name });
  }

  // 2. image-zone text intruding into the dynamic marquee band (#14): the
  // homepage overlays its own info over the lower band; keep it calm.
  const bandTop = H.height * 0.76;
  for (const t of texts) {
    if (t.box.bottom > bandTop + 2) {
      items.push({ id: 'band-' + t.id, level: 'warn', el: t.id, msg: t.name + ' extends ' + Math.round(t.box.bottom - bandTop) + 'px into the dynamic marquee band; shrink the title font or shorten the copy so the lower band stays calm' });
    }
  }

  // 3. clipped text = hard fail (the registry would ship a broken hero);
  // near-edge text = warning. The showcase is exempt: bleeding off the edge is
  // a deliberate layout feature.
  const inset = H.width * 0.015;
  for (const t of texts) {
    if (t.box.left < -1 || t.box.top < -1 || t.box.right > H.width + 1 || t.box.bottom > H.height + 1) {
      items.push({ id: 'clip-' + t.id, level: 'fail', el: t.id, msg: t.name + ' is CLIPPED by the hero edge; Generate stays locked until it fits' });
    } else if (t.box.left < inset || t.box.top < inset || H.width - t.box.right < inset) {
      items.push({ id: 'inset-' + t.id, level: 'warn', el: t.id, msg: t.name + ' is closer than 1.5% to the hero edge' });
    }
  }

  // 4b. full-hero imports (#22): the composed checks cannot see imported
  // pixels; say so instead of reporting all clear, and run the band scan.
  const vit2 = state.showcase || {};
  if (vit2.full && vit2.src) {
    scanImportBand(vit2.src);
    items.push({ id: 'import-unlinted', level: 'warn', el: 'fullimport-img', msg: 'full-hero import: composed-layout checks cannot inspect imported pixels; verify by eye before ratifying. Doctrine still applies (#32/#37): title set in Familjen Grotesk 700, body copy in Satoshi, optical left alignment on the ink edge, calm lower band; wordmark at the ONE standard size (156x35px at export scale, 90px inset top-left): if the import lacks it, use Stamp the standard Adom wordmark instead of drawing your own' });
    if (bandScan.status === 'done' && bandScan.texty) {
      items.push({ id: 'import-band-text', level: 'warn', el: 'fullimport-img', msg: 'the imported image appears to carry text or graphics in the lower dynamic band (heuristic edge scan): the homepage overlays its own info there, keep the band calm' });
    }
  }

  // 4c. prompt-looking text baked into pixels (#22): sample prompts belong in
  // page metadata (sample_prompts), rendered dynamically with a copy button.
  for (const t of texts) {
    const txt = ($(t.id).textContent || '');
    if (/["\u201c\u201d'][^"\u201c\u201d']{12,}["\u201c\u201d']/.test(txt)) {
      items.push({ id: 'prompt-' + t.id, level: 'warn', el: t.id, msg: t.name + ' contains quoted prompt-looking text; a baked prompt cannot be copied. Put it in sample_prompts (page metadata) and the page renders it dynamically with a copy button' });
    }
  }

  // 4d. the built-in sample part composing a POSED page (#53): a real,
  // unrelated MPN in the slot where the page's own part belongs reads as a
  // bug to the person whose board it is, and shipped unlinted once.
  if (state.mode === 'component' && state.comp && state.comp.id === 'sm02b-srss-tb' && state.page) {
    items.push({ id: 'comp-demo-part', level: 'warn', el: null, msg: 'the composition shows the built-in SAMPLE part (SM02B-SRSS-TB, a JST connector), not ' + state.page + "'s part: set the real part (set {\"comp\":{\"id\":\"<slug>\"}}) or pose the app composition with a showcase image" });
  }

  // 4. title wrap count: a 4-line title is almost never intended.
  const tEl = $('el-title');
  if (vis(tEl)) {
    const lh = parseFloat(getComputedStyle(tEl).lineHeight);
    const lines = lh ? Math.round(tEl.getBoundingClientRect().height / lh) : 1;
    if (lines >= 4) items.push({ id: 'title-wrap', level: 'warn', el: 'el-title', msg: 'title wraps to ' + lines + ' lines; shorten it or lower the title size (left panel)' });
  }

  // 5. text vs showcase (#25): the title owns its column. The fix is ALWAYS to
  // move or resize the SHOWCASE (layout, size), never to shrink the title:
  // small type is invisible on the dock, and #25's six-hour session shrank the
  // title four times before anyone questioned the showcase.
  for (const t of texts) {
    if (showcase && overlapPx(t.box, showcase) > 9) {
      items.push({ id: 'showcase-' + t.id, level: 'warn', el: t.id, msg: 'the ' + t.name + ' collides with the showcase image: resize or move the showcase image (layout picker, size), do not shrink the ' + t.name + ' to fit' });
    }
  }
  const fsNow = (state.fontSize || {});
  if (vis(tEl) && (fsNow.title || 8) < 6.5) {
    items.push({ id: 'title-timid', level: 'warn', el: 'el-title', msg: 'title is at ' + fsNow.title + 'cqw, under the 6.5cqw family floor; dock thumbnails render this illegibly. If it was shrunk to clear the showcase image, resize the showcase image instead' });
  }

  // 6. optical alignment (retrospective #35 fix 4): glyph side bearing shifts
  // the INK edge off the box edge; boxes can align while the pixels do not.
  if (vis(tEl) && texts.length >= 2) {
    try {
      const cv2 = (runLint._cv || (runLint._cv = document.createElement('canvas').getContext('2d')));
      const inkLeft = (el) => {
        const ch = (el.textContent || '').trim().charAt(0);
        if (!ch) return null;
        const cs = getComputedStyle(el);
        cv2.font = cs.fontWeight + ' ' + cs.fontSize + ' ' + cs.fontFamily;
        const mm = cv2.measureText(ch);
        return el.getBoundingClientRect().left - (mm.actualBoundingBoxLeft || 0);
      };
      const a = inkLeft(tEl);
      const b = inkLeft($(texts[1].id));
      if (a != null && b != null) {
        const exportPx = Math.round(Math.abs(a - b) * 2000 / H.width);
        if (exportPx > 5) {
          items.push({ id: 'ink-align', level: 'warn', el: 'el-title', msg: 'optical alignment: the title\'s ink edge sits ' + exportPx + 'px (export scale) ' + (a > b ? 'right' : 'left') + ' of the ' + texts[1].name + '\'s because of glyph side bearing; reword the first word or accept it deliberately' });
        }
      }
    } catch {}
  }

  // 6b. showcase coverage (#43): the title is the message, the showcase is
  // supporting evidence. When the showcase swallows the hero, the hierarchy
  // inverts and white space (what makes the one message land) is gone.
  if (showcase) {
    const iw = Math.max(0, Math.min(showcase.right, H.width) - Math.max(showcase.left, 0));
    const ih = Math.max(0, Math.min(showcase.bottom, H.height) - Math.max(showcase.top, 0));
    const cov = (iw * ih) / (H.width * H.height);
    if (cov > 0.72) {
      items.push({ id: 'showcase-coverage', level: 'warn', el: null, msg: 'the showcase image covers ' + Math.round(cov * 100) + '% of the hero. The title is the ONE message this image sends; the showcase image is supporting evidence. Pick a layout that gives the title its white space' });
    }
  }

  // 6c. cover-crop accounting (#41): a cover fit silently discards source
  // pixels; say how much, and check the crop lines for beheaded content.
  const vimg = $('showcase-img');
  if (vit2.src && !vit2.full && vit2.cover && vimg && vimg.naturalWidth > 0) {
    const win2 = hero.querySelector('.appbody');
    if (win2 && win2.offsetParent) {
      const wb = win2.getBoundingClientRect();
      const arImg = vimg.naturalWidth / vimg.naturalHeight;
      const arSlot = wb.width / wb.height;
      const discard = 1 - Math.min(arImg / arSlot, arSlot / arImg);
      if (discard > 0.25) {
        const axis = arImg > arSlot ? 'left and right edges' : 'top and bottom edges';
        items.push({ id: 'cover-discard', level: 'warn', el: 'showcase-img', msg: 'cover fit discards ' + Math.round(discard * 100) + '% of the source screenshot (' + axis + '). Check nothing that matters was cropped out; a screenshot captured AT the slot aspect (left panel size) loses nothing' });
      }
      scanCropEdges(vimg, arSlot);
      if (cropScan.status === 'done' && cropScan.cut) {
        items.push({ id: 'crop-edge-cut', level: 'warn', el: 'showcase-img', msg: 'content appears cut mid-element at the ' + cropScan.where + ' crop line (edge-density heuristic): logos or headings beheaded by a crop read as broken. Recapture at the slot aspect or move the crop' });
      }
    }
  }

  // 6d. multi-message import (#43): one hero, one message.
  if (vit2.full && vit2.src && bandScan.status === 'done' && bandScan.busyCells >= 6) {
    items.push({ id: 'import-multi-message', level: 'warn', el: 'fullimport-img', msg: 'the imported image reads as ' + bandScan.busyCells + ' of 12 zones text/graphic-dense: that is several competing messages, and a hero sends ONE. The title should be the message; white space is what makes it land' });
  }

  // 6e. brand provenance: from 0.21.0 the wiki REFUSES a studio hero that is
  // not stamped with the page owner's confirmed guide, so Generate is locked
  // (level fail) whenever this click could not end that way. A starter guide
  // the human may edit is fine: Generate confirms it in the same click. The
  // drawn-pixel checks below read COMPUTED style, so a font that fell back or
  // a theme painting over the brand is caught here, not after publish.
  {
    const g = state.guide || null;
    const owner = (g && g.owner) || String(state.page || '').split('/')[0] || '';
    const brand = (g && g.brand) || null;
    const applied = hero.classList.contains('branded');
    const who = (g && g.editable_by && g.editable_by.text) || ('only ' + owner + ' can edit ' + owner + '/brand');
    if (state.mode === 'app') {
      if (!g || g.status === 'unavailable') {
        items.push({ id: 'brand-unresolved', level: 'fail', el: null, msg: 'the brand guide for "' + owner + '" could not be resolved' + (g && g.error ? ' (' + g.error + ')' : '') + '. The wiki refuses a hero that does not wear the owner\'s confirmed guide, so Generate is locked: hero-studio doctor --owner ' + owner });
      } else if (g.status === 'missing') {
        const st = g.starter || {};
        items.push({ id: 'brand-missing', level: 'fail', el: null, msg: st.result === 'exists_unreadable'
          ? (st.error || owner + '/brand exists but this studio cannot read it') + '. ' + (st.hint || '')
          : owner + ' has no brand guide' + (st.result === 'refused' ? ', and a starter could not be drafted for you: ' + (st.error || st.code) : st.result === 'drafting' || st.result === 'timeout' ? '; a starter is being drafted, re-pose in a moment' : '') + '. ' + who });
      } else if (g.status === 'invalid') {
        const fields = (g.errors || []).map((e) => (e && e.field) || '?').join(', ');
        items.push({ id: 'brand-invalid', level: 'fail', el: null, msg: owner + '/brand fails brand.json v1 validation' + (fields ? ' (' + fields + ')' : '') + '. The wiki refuses a hero on an unconfirmed guide, so Generate is locked until those fields are fixed at the Brand step. ' + who });
      } else if (g.status !== 'confirmed' && !g.can_edit) {
        items.push({ id: 'brand-locked', level: 'fail', el: null, msg: owner + '/brand is ' + g.status + ' and this identity cannot confirm it, so Generate is locked. ' + who });
      } else if (g.status === 'starter' && g.can_edit) {
        items.push({ id: 'brand-starter', level: 'info', el: null, msg: 'Generate also confirms ' + owner + '/brand in this click, recording you as the human who approved these colors and fonts. Check them on the hero first' });
      }
    }
    if (brand && !applied && g && g.status !== 'invalid') {
      items.push({ id: 'brand-not-applied', level: 'warn', el: null, msg: owner + '/brand resolved (' + g.status + ') but the hero is not rendering branded, so these colors and fonts are not the ones this page will publish with' });
    }
    if (brand && applied) {
      // the drawn pixels vs the guide: computed font family and title ink
      const titleEl = $('el-title');
      if (titleEl && vis(titleEl)) {
        const cs = getComputedStyle(titleEl);
        const want = ((brand.typography || {}).display || {}).family || '';
        const got = (cs.fontFamily || '').replace(/["']/g, '');
        if (want && got && got.toLowerCase().indexOf(want.toLowerCase()) !== 0) {
          items.push({ id: 'brand-font-drift', level: 'warn', el: 'el-title', msg: 'the title is drawn in "' + got.split(',')[0] + '" but ' + owner + '/brand names "' + want + '" as its display face. A font file that did not load falls back silently, so the published hero would not be in the owner\'s type' });
        }
        // an @font-face that never loaded leaves the family unavailable even
        // though the computed value still reports it
        const ff = ((brand.typography || {}).display || {}).family;
        try {
          if (ff && document.fonts && document.fonts.check && !document.fonts.check('700 40px "' + ff + '"')) {
            items.push({ id: 'brand-font-missing', level: 'warn', el: 'el-title', msg: '"' + ff + '" is not available to this renderer, so the title falls back to a system face. Give ' + owner + '/brand a font file under fonts/ (woff2) or a Google Fonts family name' });
          }
        } catch {}
      }
      const groundWant = (brand.colors || {}).background || '';
      const bgEl = hero.querySelector('.bg');
      if (groundWant && bgEl) {
        const lit = getComputedStyle(bgEl).backgroundColor;
        const m = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(lit || '');
        const hexWant = groundWant.length === 4
          ? [1, 2, 3].map((i) => parseInt(groundWant[i] + groundWant[i], 16))
          : [1, 3, 5].map((i) => parseInt(groundWant.slice(i, i + 2), 16));
        if (m && hexWant.every((v) => !isNaN(v))) {
          const d = Math.max(Math.abs(+m[1] - hexWant[0]), Math.abs(+m[2] - hexWant[1]), Math.abs(+m[3] - hexWant[2]));
          // a wash or gradient over the ground is by design; a wholly
          // different ground is the Adom-theme-layer bug
          if (d > 60) {
            items.push({ id: 'brand-ground-drift', level: 'warn', el: null, msg: 'the hero ground renders rgb(' + m[1] + ',' + m[2] + ',' + m[3] + ') but ' + owner + '/brand names ' + groundWant + '. A theme layer painting over the brand ground is the usual cause' });
          }
        }
      }
    }
  }

  // 6f. the page's own identity. A hero states what KIND of page it is, and
  // that pill is a registry convention a reader trusts, so an unknown or
  // borrowed type is worse than no pill at all.
  {
    const d2 = displayEntry();
    if (d2 && d2.__missing) {
      items.push({ id: 'page-missing', level: 'fail', el: null, msg: 'the posed page "' + String(state.page || '') + '" is not in the studio\'s page list, so its title, brief and TYPE are unknown. The hero would state the wrong kind of page. Re-pose it (hero-studio pose ' + String(state.page || '') + ') so the live row is loaded' });
    } else if (state.mode === 'app' && d2 && !d2.type) {
      items.push({ id: 'type-unknown', level: 'warn', el: 'pill-tr', msg: 'this page has no type, so no type pill is drawn. A published hero normally states app, skill or component; set the page type rather than shipping a hero that does not say what this is' });
    }
  }

  // 7. published headroom (#28): how much calm space is left under the lowest
  // text before the marquee band, in EXPORT pixels; informational.
  if (texts.length && state.mode === 'app') {
    const lowest = Math.max.apply(null, texts.map(t => t.box.bottom));
    const px = Math.round((bandTop - lowest) * 2000 / H.width);
    items.push({ id: 'headroom', level: 'info', msg: 'published headroom: ' + px + 'px between the lowest image-zone text and the marquee band at export scale (2000x1250)' });
  }

  return items;
}
window.__computeLint = () => (computeLint() || []).map(({ id, level, msg }) => ({ id, level, msg }));
function renderLintBox(items) {
  const box = $('lintbox');
  if (!state.verify) { box.innerHTML = ''; return; }
  if (!items.length) { box.innerHTML = '<div class="lint-ok">layout linter: no findings (pill, margins, band, clipping, wrap all clear)</div>'; return; }
  box.innerHTML = items.map((i, n) =>
    '<div class="lint-item lint-' + i.level + '" data-el="' + (i.el || '') + '" data-n="' + n + '">' +
    (i.level === 'fail' ? 'BLOCKS GENERATE: ' : '') + i.msg + '</div>').join('');
}
$('lintbox').addEventListener('click', (e) => {
  const item = e.target.closest('.lint-item');
  if (!item || !item.dataset.el) return;
  const el = $(item.dataset.el);
  if (!el) return;
  clearTourGlow();
  el.classList.add('tour-glow');
  setTimeout(() => el.classList.remove('tour-glow'), 2200);
});

/* draggable tour dialogs (#13): pointer-capture drag on the header, clamped;
   the offset persists across steps for this tour session */
let tpDrag = { dx: 0, dy: 0 };
(() => {
  const pop = $('tourpop');
  const head = pop.querySelector('.tp-head');
  let grab = null;
  head.addEventListener('pointerdown', (e) => {
    grab = { x: e.clientX - pop.offsetLeft, y: e.clientY - pop.offsetTop };
    pop.classList.add('dragging');
    head.setPointerCapture(e.pointerId);
  });
  head.addEventListener('pointermove', (e) => {
    if (!grab) return;
    const left = Math.max(4, Math.min(window.innerWidth - 80, e.clientX - grab.x));
    const top = Math.max(4, Math.min(window.innerHeight - 60, e.clientY - grab.y));
    pop.style.left = left + 'px';
    pop.style.top = top + 'px';
    if (pop._anchor) tpDrag = { dx: left - pop._anchor.left, dy: top - pop._anchor.top };
  });
  const drop = () => { grab = null; pop.classList.remove('dragging'); };
  head.addEventListener('pointerup', drop);
  head.addEventListener('pointercancel', drop);
})();
$('tp-ok').addEventListener('click', () => {
  const step = TOUR_STEPS[tourIdx];
  if (step) send({ confirm: { [step.key]: true }, flags: { [step.key]: false } });
});
$('tp-flag').addEventListener('click', () => {
  const step = TOUR_STEPS[tourIdx];
  if (!step) return;
  send({ flags: { [step.key]: true }, confirm: { [step.key]: false } });
  handleToast({ text: step.key + ' flagged as needs-fixing: Generate stays locked and the driving AI can read the flag from state and adjust', kind: 'warn', seq: Date.now() });
});
$('tp-skip').addEventListener('click', () => { clearTourGlow(); $('tourpop').style.display = 'none'; send({ tour: false }); });

$('mqo-prompt-copy').addEventListener('click', async (e) => {
  e.stopPropagation();
  const txt = $('mqo-prompt-text').textContent.replace(/^"|"$/g, '');
  try { await navigator.clipboard.writeText(txt); } catch {}
  handleToast({ text: 'sample prompt copied', kind: 'success', seq: Date.now() });
});

/* full-hero import band scan (#22.1): composed-layout lint cannot see imported
   pixels, so the class of hero most likely to violate the band skipped every
   check. A cheap edge-density scan of the bottom 24% flags text-like structure
   in the band; it is a heuristic and says so. */
let bandScan = { key: '', status: 'idle', texty: false, busyCells: 0, cells: 12 };
function edgeDensity(g, sx, sy, sw, sh, w, h) {
  // draw a region to a small canvas and return horizontal edge density
  g.canvas.width = w; g.canvas.height = h;
  g.drawImage(g._img, sx, sy, sw, sh, 0, 0, w, h);
  const d = g.getImageData(0, 0, w, h).data;
  let edges = 0, n = 0;
  for (let y = 0; y < h; y++) {
    for (let x = 1; x < w; x++) {
      const i = (y * w + x) * 4, j = i - 4;
      const a = d[i] * .3 + d[i + 1] * .59 + d[i + 2] * .11;
      const b = d[j] * .3 + d[j + 1] * .59 + d[j + 2] * .11;
      if (Math.abs(a - b) > 60) edges++;
      n++;
    }
  }
  return n ? edges / n : 0;
}
function scanImportBand(src) {
  const key = src.length + ':' + src.slice(40, 72);
  if (bandScan.key === key) return;
  bandScan = { key, status: 'pending', texty: false, busyCells: 0, cells: 12 };
  const img = new Image();
  img.onload = () => {
    try {
      const g = document.createElement('canvas').getContext('2d');
      g._img = img;
      // 1. the calm-band check (bottom 24%), unchanged
      const w = Math.min(800, img.width);
      const bandH = Math.max(10, Math.round(img.height * 0.24 * (w / img.width)));
      bandScan.texty = edgeDensity(g, 0, img.height * 0.76, img.width, img.height * 0.24, w, bandH) > 0.0015;
      // 2. message-block grid (#43): a hero sends ONE message; count how many
      // zones of the upper 76% read as text/graphic-dense. 4x3 grid, cheap.
      let busy = 0;
      for (let gy = 0; gy < 3; gy++) {
        for (let gx = 0; gx < 4; gx++) {
          const dens = edgeDensity(g,
            img.width * gx / 4, img.height * 0.76 * gy / 3,
            img.width / 4, img.height * 0.76 / 3, 200, 100);
          if (dens > 0.003) busy++;
        }
      }
      bandScan.busyCells = busy;
      bandScan.status = 'done';
    } catch { bandScan.status = 'failed'; }
    scheduleLint();
  };
  img.onerror = () => { bandScan.status = 'failed'; scheduleLint(); };
  img.src = src;
}

/* crop-line scan (#41): sample thin strips just inside the visible window of
   a cover-cropped showcase; text straddling the crop line = beheaded content */
let cropScan = { key: '', status: 'idle', cut: false, where: '' };
function scanCropEdges(vimg, arSlot) {
  const key = vimg.src.length + ':' + arSlot.toFixed(3);
  if (cropScan.key === key) return;
  cropScan = { key, status: 'pending', cut: false, where: '' };
  try {
    const g = document.createElement('canvas').getContext('2d');
    g._img = vimg;
    const iw = vimg.naturalWidth, ih = vimg.naturalHeight;
    const arImg = iw / ih;
    let cut = false, where = '';
    if (arImg > arSlot) {
      const visW = ih * arSlot;
      const cutL = (iw - visW) / 2;
      const strip = Math.max(8, iw * 0.02);
      if (edgeDensity(g, cutL, 0, strip, ih, 24, 300) > 0.004) { cut = true; where = 'left'; }
      if (edgeDensity(g, cutL + visW - strip, 0, strip, ih, 24, 300) > 0.004) { cut = true; where = where ? 'left and right' : 'right'; }
    } else if (arImg < arSlot) {
      const visH = iw / arSlot;
      const cutT = (ih - visH) / 2;
      const strip = Math.max(8, ih * 0.02);
      if (edgeDensity(g, 0, cutT, iw, strip, 300, 24) > 0.004) { cut = true; where = 'top'; }
      if (edgeDensity(g, 0, cutT + visH - strip, iw, strip, 300, 24) > 0.004) { cut = true; where = where ? 'top and bottom' : 'bottom'; }
    }
    cropScan.cut = cut; cropScan.where = where; cropScan.status = 'done';
  } catch { cropScan.status = 'failed'; }
}

/* auto-fit (#40): the largest title size that keeps <=3 lines, positive
   headroom, and no clipping, measured with the REAL layout: sweep the CSS
   var coarse-to-fine, read the boxes, restore. Runs on the fit SSE event
   (drive verb + preset re-fit) and on headline edits; never in render mode. */
function solveTitleFit(maxCqw) {
  const tEl = $('el-title');
  if (!tEl || !tEl.offsetParent || document.body.dataset.render !== undefined) return null;
  const H = hero.getBoundingClientRect();
  if (H.width < 60) return null;
  const bandTop = H.top + H.height * 0.76;
  const measure = (v) => {
    hero.style.setProperty('--fs-title', v + 'cqw');
    void tEl.offsetHeight;
    const b = tEl.getBoundingClientRect();
    const lh = parseFloat(getComputedStyle(tEl).lineHeight) || 1;
    return { b, lines: Math.max(1, Math.round(b.height / lh)) };
  };
  const inHero = (b) => b.bottom < bandTop && b.right <= H.right + 1 && b.left >= H.left - 1 && b.top >= H.top - 1;
  const fits = (v) => { const m = measure(v); return m.lines <= 3 && inHero(m.b); };
  const fitsLoose = (v) => inHero(measure(v).b);
  const hi = Math.min(maxCqw || 13, 13);
  const sweep = (pred) => {
    let best = null;
    for (let v = hi; v >= 5 && best == null; v = Math.round((v - 0.5) * 10) / 10) {
      if (pred(v)) best = v;
    }
    if (best != null && best < hi) {
      for (let v = Math.min(hi, best + 0.4); v > best; v = Math.round((v - 0.1) * 10) / 10) {
        if (pred(v)) { best = v; break; }
      }
    }
    return best;
  };
  let best = sweep(fits);
  let loose = false;
  if (best == null) {
    // Nothing satisfies <=3 lines at any legible size: the headline itself is
    // the problem. Relax to the HARD constraints only (inside the hero, above
    // the band) so the pose is at least never broken, and say so.
    loose = true;
    best = sweep(fitsLoose);
  }
  const cur = (state.fontSize || {}).title || 8;
  hero.style.setProperty('--fs-title', cur + 'cqw');
  if (best == null) return null;
  hero.style.setProperty('--fs-title', best + 'cqw');
  void tEl.offsetHeight;
  const lines = Math.max(1, Math.round(tEl.getBoundingClientRect().height / (parseFloat(getComputedStyle(tEl).lineHeight) || 1)));
  hero.style.setProperty('--fs-title', cur + 'cqw');
  return { size: best, loose, lines };
}
function runFit(maxCqw) {
  const r = solveTitleFit(maxCqw);
  if (r == null) {
    handleToast({ text: 'fit: no title size in 5-13cqw keeps this headline inside the hero for this layout; shorten the headline or change layout', kind: 'warn', seq: Date.now() });
    return;
  }
  const cur = (state.fontSize || {}).title || 8;
  if (Math.abs(r.size - cur) >= 0.05) send({ fontSize: { title: r.size } });
  if (r.loose) {
    handleToast({ text: 'fit: this headline cannot make 3 lines at any legible size; set ' + r.size + 'cqw (' + r.lines + ' lines, still clear of the band). SHORTER COPY READS BIGGER: the title is the one message, aim for a few words', kind: 'warn', seq: Date.now() });
  } else if (Math.abs(r.size - cur) >= 0.05) {
    handleToast({ text: 'fit: title ' + r.size + 'cqw (largest size with <=3 lines and positive headroom for this headline + layout)', kind: 'info', seq: Date.now() });
  }
}

/* the stagebar (#21/#23): explicit dynamic on/off, keyboard D, legend off-stage */
$('sb-png').addEventListener('click', () => send({ marqueePreview: false }));
$('sb-mq').addEventListener('click', () => send({ marqueePreview: true }));
document.addEventListener('keydown', (e) => {
  if (e.key.toLowerCase() === 'd' && !e.target.closest('input,textarea')) {
    send({ marqueePreview: !state.marqueePreview });
  }
});

/* hold-to-hide the dynamic overlay (#16): press the band to see the pure
   export, release to bring the overlay back */
(() => {
  const mq = $('mqoverlay');
  const hero = $('hero');
  mq.addEventListener('pointerdown', (e) => { e.preventDefault(); hero.classList.add('mq-hold'); });
  document.addEventListener('pointerup', () => hero.classList.remove('mq-hold'));
  document.addEventListener('pointercancel', () => hero.classList.remove('mq-hold'));
})();

/* ---- page fields + Apply + Copy Changelog Prompt (#20) ----
   The human edits page-level fields at the gate; the change ledger on the
   server records everything (page fields AND pose deltas), Apply writes to
   the wiki or stashes for the first publish, and the prompt hands the whole
   session back to the AI that owns the package working copy. */
let pfEditing = false;
function renderPageFields() {
  if (pfEditing || !state) return;
  const d = pageByKey(state.page);
  if (!d) return;
  $('pf-title').value = d.title || '';
  $('pf-brief').value = d.brief || '';
  $('pf-tags').value = (d.tags || []).join(', ');
}
async function refreshChangesBadge() {
  try {
    const c = await (await fetch('changes')).json();
    const n = Object.keys(c.page || {}).length;
    $('pf-apply').textContent = 'Apply changes to wiki' + (n && !c.applied ? ' (' + n + ' unapplied)' : '');
    $('pf-apply').disabled = !n || !!c.applied;
    if (c.applied) $('pf-status').textContent = c.applied === 'wiki'
      ? 'Applied to the wiki. Still run Copy Changelog Prompt: the local package checkout needs syncing too.'
      : 'Stashed for the next publish (the page or edit route is not on the wiki yet); the changelog prompt carries the edits.';
  } catch {}
}
function pushPageField() {
  const [owner, slug] = (state.page || '/').split('/');
  const body = { owner, slug, title: $('pf-title').value.trim(), brief: $('pf-brief').value.trim(),
    tags: $('pf-tags').value.split(',').map(t => t.trim()).filter(Boolean) };
  post('api/pages', body)
    .then(() => fetch('api/pages')).then(r => r.json()).then(rows => { pages = rows; render(); refreshChangesBadge(); })
    .catch(() => {});
}
for (const id of ['pf-title', 'pf-brief', 'pf-tags']) {
  $(id).addEventListener('focus', () => { pfEditing = true; });
  $(id).addEventListener('blur', () => { pfEditing = false; pushPageField(); });
}
$('pf-apply').addEventListener('click', async () => {
  const r = await (await post('apply-page')).json().catch(() => ({}));
  if (!r.ok) { handleToast({ text: r.error || 'nothing to apply', kind: 'warn', seq: Date.now() }); return; }
  handleToast({
    text: r.applied === 'wiki' ? 'Page edits applied to the wiki (' + r.fields.join(', ') + ')'
      : 'Edits stashed for the next publish' + (r.http ? ' (wiki said HTTP ' + r.http + ')' : ''),
    kind: r.applied === 'wiki' ? 'success' : 'info', seq: Date.now(),
  });
  refreshChangesBadge();
});
$('pf-prompt').addEventListener('click', async () => {
  const c = await (await fetch('changes')).json().catch(() => null);
  if (!c) return;
  const lines = ['While reviewing the hero in Hero Studio I made these changes to ' + (c.ref || state.page) + ':'];
  const fmt = (v) => typeof v === 'string' ? '"' + v + '"' : JSON.stringify(v);
  for (const [f, ch] of Object.entries(c.page || {})) lines.push('- ' + f + ': ' + fmt(ch.from) + ' -> ' + fmt(ch.to));
  for (const [f, ch] of Object.entries(c.pose || {})) lines.push('- pose ' + f + ': ' + fmt(ch.from) + ' -> ' + fmt(ch.to));
  if (lines.length === 1) lines.push('- (no changes recorded this session)');
  lines.push('- Applied to wiki: ' + (c.applied === 'wiki' ? 'yes' : c.applied === 'stashed' ? 'no, stashed: please include in the next publish' : 'no: please include in the next publish'));
  lines.push('Please update the local package (page.json / package.json / README) to match.');
  const text = lines.join('\n');
  try {
    await navigator.clipboard.writeText(text);
    handleToast({ text: 'Changelog prompt copied: paste it into the AI session that owns the package', kind: 'success', seq: Date.now() });
  } catch {
    const ta = document.createElement('textarea');
    ta.value = text; document.body.appendChild(ta); ta.select();
    document.execCommand('copy'); ta.remove();
    handleToast({ text: 'Changelog prompt copied (fallback path)', kind: 'success', seq: Date.now() });
  }
});
setInterval(refreshChangesBadge, 3000);

/* zen focus (#12): the hero gets the whole surface */
$('btn-zen').addEventListener('click', () => {
  const on = document.body.classList.toggle('zen');
  $('btn-zen').classList.toggle('on', on);
  try { on ? document.documentElement.requestFullscreen() : document.exitFullscreen(); } catch {}
});
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape' && document.body.classList.contains('zen')) {
    document.body.classList.remove('zen');
    $('btn-zen').classList.remove('on');
  }
});

/* Context-aware trace generators. Keep the existing circuitry theme intact. */
function isTraceTheme() { return state && ['circuit-field', 'circuit-tile'].includes(state.theme); }
const TRACE_DEFAULTS = { density: 0.8, scale: 1.4, strength: 0.22, clearance: 8 };
let traceModule, traceKey = '', traceRevision = 0;
window.__traceReady = Promise.resolve();
window.__tracePending = false;
window.__traceError = '';
function measureTraceKeepouts(mod, clearance) {
  const hero = $('hero'), win = hero.querySelector('.window');
  const heroStyle = hero.style.cssText, winStyle = win.style.cssText;
  // Measure the live composition at the export size. Pixel-sized borders and
  // font rounding otherwise shift a grid cell between the wizard and export.
  // Restore synchronously, before paint; no duplicate DOM or changed UI state.
  try {
    for (const [key, value] of Object.entries({ width:'1600px', height:'1000px', 'max-width':'none', 'aspect-ratio':'auto', border:'none', flex:'none' })) hero.style.setProperty(key,value,'important');
    fitWindowToShowcase();
    return mod.keepouts(hero, clearance);
  } finally { hero.style.cssText = heroStyle; win.style.cssText = winStyle; }
}
function scheduleTraces() {
  const revision = ++traceRevision;
  const img = $('trace-art');
  if (!isTraceTheme() || (state.showcase && state.showcase.full)) {
    img.removeAttribute('src'); traceKey = ''; window.__tracePending = false; window.__traceError = '';
    window.__traceReady = Promise.resolve();
    return;
  }
  window.__tracePending = true;
  window.__traceReady = (async () => {
    // Font loading and showcase fitting must finish before measuring keepouts.
    traceModule ||= import('./circuit-traces.mjs');
    const [mod] = await Promise.all([traceModule, document.fonts.ready]);
    if (!RENDER_VIEW && !document.hidden) await new Promise(resolve => requestAnimationFrame(resolve));
    if (revision !== traceRevision) return;
    const hero = $('hero');
    if (hero.getBoundingClientRect().width < 10) return;
    const settings = { ...TRACE_DEFAULTS, ...state.circuit };
    const colors = (guideBrand() || {}).colors || {};
    const spec = { theme: state.theme, seed: state.circuitSeed, settings,
      boxes: measureTraceKeepouts(mod, settings.clearance),
      color: colors.primary || '#8b949e', background: colors.background || '#0d1117' };
    const key = JSON.stringify(spec);
    if (key !== traceKey) {
      const art = mod.composeTraces(spec);
      img.src = art.src;
      await img.decode();
      if (revision !== traceRevision) return;
      traceKey = key;
      img.dataset.routes = art.count;
    }
    window.__traceError = '';
    window.__tracePending = false;
    syncPreviews();
  })().catch(e => {
    if (revision !== traceRevision) return;
    traceKey = ''; img.removeAttribute('src');
    window.__tracePending = false; window.__traceError = String(e.message || e);
    console.error('Circuit traces:', e); scheduleLint();
  });
}
new ResizeObserver(() => { if (state) scheduleTraces(); }).observe($('hero'));
document.fonts.addEventListener('loadingdone', () => { if (state) scheduleTraces(); });
$('showcase-img').addEventListener('load', () => { if (state) scheduleTraces(); });

// One control builder for Classic and the wizard. Local feedback during a drag;
// persist on change so a slider never creates a queue of expensive reroutes.
function renderTraceControls(host) {
  if (!host) return;
  const active = isTraceTheme();
  host.hidden = !active;
  if (!active) { host.textContent = ''; return; }
  if (host.contains(document.activeElement)) return;
  host.textContent = ''; host.className = 'trace-controls';
  const full = !!(state.showcase && state.showcase.full);
  const note = document.createElement('div'); note.className = 'hint';
  note.textContent = full ? 'Full hero import is active. Switch it off to compose traces around your content.'
    : 'Follows the shapes of your letters and showcase. Clear space adjusts the breathing room around them.';
  host.appendChild(note);
  const config = { ...TRACE_DEFAULTS, ...state.circuit };
  for (const [key, label, min, max, step] of [['density','Density',0.25,1.25,0.05], ['scale','Trace size',0.8,2.5,0.1], ['strength','Strength',0.05,0.5,0.01], ['clearance','Clear space',8,40,2]]) {
    const lab = document.createElement('label'); lab.textContent = label;
    const input = document.createElement('input'); input.type = 'range'; input.min = min; input.max = max; input.step = step;
    input.value = config[key]; input.disabled = full; input.setAttribute('aria-label', label);
    const output = document.createElement('output'); output.textContent = input.value;
    input.addEventListener('input', () => { output.textContent = input.value; });
    input.addEventListener('change', () => {
      state.circuit = { ...TRACE_DEFAULTS, ...state.circuit, [key]: Number(input.value) };
      render(); send({ circuit: { [key]: Number(input.value) } });
    });
    lab.append(input, output); host.appendChild(lab);
  }
  const label = document.createElement('label'); label.textContent = 'Seed';
  const seed = document.createElement('input'); seed.type = 'number'; seed.min = '0'; seed.max = '2147483646'; seed.step = '1';
  seed.value = state.circuitSeed; seed.disabled = full; seed.setAttribute('aria-label', 'Trace seed');
  seed.addEventListener('change', () => {
    if (!seed.value || !seed.checkValidity()) { seed.value = state.circuitSeed; return; }
    state.circuitSeed = Number(seed.value); render(); send({ circuitSeed: state.circuitSeed });
  });
  label.appendChild(seed); host.appendChild(label);
  const reroll = document.createElement('button'); reroll.className = 'btn small'; reroll.textContent = 'New pattern'; reroll.disabled = full;
  reroll.addEventListener('click', () => post('reroll').catch(() => {})); host.appendChild(reroll);
}

/* procedural circuitry background (seeded, gently different every roll) */
function mulberry32(a) {
  return function () {
    a |= 0; a = (a + 0x6D2B79F5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
let lastCircuitKey = '';
function drawCircuitry() {
  const cv = $('circuitcanvas');
  const rect = hero.getBoundingClientRect();
  if (rect.width < 10) return;
  // the guide's sha rides the cache key: a brand edit must repaint the traces,
  // and without it the canvas kept the previous owner's colors
  const key = state.circuitSeed + 'x' + Math.round(rect.width)
    + 'x' + ((state.guide && state.guide.sha) || 'none')
    + 'x' + JSON.stringify((state.brandEdits || {}));
  if (key === lastCircuitKey) return;
  lastCircuitKey = key;
  const W = (cv.width = Math.round(rect.width * 2));
  const H = (cv.height = Math.round(rect.height * 2));
  const g = cv.getContext('2d');
  const rnd = mulberry32(state.circuitSeed || 1);
  g.clearRect(0, 0, W, H);
  const grid = Math.round(W / 46);
  const gb = guideBrand();
  const gc = (gb && gb.colors) || null;
  const traceInk = gc
    ? [gc.primary, gc.accent || gc.primary, (gc.gradient && gc.gradient[gc.gradient.length - 1]) || gc.accent || gc.primary]
    : ['#00b8b1', '#58a6ff', '#8c6bf7'];
  const traces = 34;
  for (let i = 0; i < traces; i++) {
    const hue = rnd();
    // The circuitry theme paints on a canvas, so CSS cannot recolor it the way
    // the other themes are recolored: the trace colors come from the resolved
    // guide here, and fall back to Adom's three brand hues when there is none.
    g.strokeStyle = hue < 0.72 ? rgbaFrom(traceInk[0], 0.05 + rnd() * 0.10)
      : hue < 0.9 ? rgbaFrom(traceInk[1], 0.05 + rnd() * 0.08)
      : rgbaFrom(traceInk[2], 0.05 + rnd() * 0.08);
    g.lineWidth = rnd() < 0.85 ? 2 : 4;
    g.lineCap = 'round';
    let x = Math.round(rnd() * (W / grid)) * grid;
    let y = Math.round(rnd() * (H / grid)) * grid;
    g.beginPath();
    g.arc(x, y, g.lineWidth * 1.6, 0, Math.PI * 2);
    g.stroke();
    g.beginPath();
    g.moveTo(x, y);
    let dir = Math.floor(rnd() * 4); // 0 r, 1 d, 2 l, 3 u
    const segs = 3 + Math.floor(rnd() * 5);
    for (let sVar = 0; sVar < segs; sVar++) {
      const len = grid * (1 + Math.floor(rnd() * 5));
      // 45-degree bend half the time, PCB style
      if (rnd() < 0.5) dir = (dir + (rnd() < 0.5 ? 1 : 3)) % 4;
      const diag = rnd() < 0.4;
      const dx = dir === 0 ? len : dir === 2 ? -len : diag ? len * 0.7 : 0;
      const dy = dir === 1 ? len : dir === 3 ? -len : diag ? len * 0.7 : 0;
      x += dx; y += dy;
      g.lineTo(x, y);
    }
    g.stroke();
    g.beginPath();
    g.arc(x, y, g.lineWidth * 1.6, 0, Math.PI * 2);
    g.stroke();
  }
}
$('btn-reroll').addEventListener('click', () => { post('reroll').catch(() => {}); });

/* image-zone type sizes: instant local feedback, debounced send */
let fsTimer = null;
let hlTimer = null;
$('headline-input').addEventListener('input', e => {
  clearTimeout(hlTimer);
  const v = e.target.value;
  previewText({ headline: v });
  hlTimer = setTimeout(() => { send({ headline: v }); setTimeout(() => runFit(null), 250); }, 350);
});
for (const [id, key] of [['fs-title', 'title'], ['fs-sub', 'sub'], ['fs-desc', 'desc'], ['fs-leading', 'leading'], ['fs-gap', 'gap']]) {
  $(id).addEventListener('input', e => {
    const v = Number(e.target.value);
    $(id + '-val').textContent = v;
    hero.style.setProperty('--' + id.replace('fs-', 'fs-'), v + 'cqw');
    hero.style.setProperty('--fs-' + key.replace('sub', 'sub').replace('desc', 'desc'), v + 'cqw');
    clearTimeout(fsTimer);
    fsTimer = setTimeout(() => send({ fontSize: { [key]: v } }), 150);
  });
}
$('ab-again').addEventListener('click', runGenerate);

/* owner brand controls */
$('brand-accent').addEventListener('input', e => send({ brand: { accent: e.target.value } }));
$('brand-bg').addEventListener('input', e => send({ brand: { bg: e.target.value } }));
$('brand-clear').addEventListener('click', () => send({ brand: { accent: '', bg: '' } }));

/* boot: load real pages, subscribe to the live channel */
Promise.all([
  fetch('api/pages').then(r => r.json()),
  fetch('api/components').then(r => r.json()),
  fetch('state?src=ref').then(r => r.json()),
]).then(([pg, cp, st]) => {
  pages = pg;
  comps = cp;
  state = st;
  lastPagesRev = st.pagesRev || 0;
  seenToastSeq = (st._toast && st._toast.seq) || 0; // don't replay a stale toast on load
  seenGenSeq = (st._gen && st._gen.seq) || 0;       // nor a stale generated modal
  const sel = $('sel-page');
  sel.innerHTML = pages.map(p =>
    '<option value="' + p.owner + '/' + p.slug + '">' + p.title + ' (' + p.type + ')</option>').join('');
  $('sel-comp').innerHTML = comps.map(c =>
    '<option value="' + c.id + '">' + c.name + (c.sample ? ' (sample)' : '') + '</option>').join('');
  render();
  console.log('hero-studio ready: ' + pages.length + ' live pages');
  if (location.hash) {
    const el = document.querySelector(location.hash);
    if (el) setTimeout(() => el.scrollIntoView({ block: 'start' }), 300);
  }
  if (RENDER_VIEW) return;   // render view: the state it loaded is the state it renders
  const es = new EventSource('events');
  es.addEventListener('state', e => { receiveState(JSON.parse(e.data)); syncPagesIfStale(state); render(); });
  es.addEventListener('fit', e => { let m = null; try { m = (JSON.parse(e.data) || {}).max; } catch {} setTimeout(() => runFit(m), 80); });
  es.addEventListener('toast', e => { handleToast(JSON.parse(e.data)); });
  es.addEventListener('generated', e => { handleGen(JSON.parse(e.data)); });
  // poll fallback in case the proxy buffers the event stream; the ETag
  // makes the idle second cost a 304 with no body instead of the full state
  let stateEtag = '';
  setInterval(() => {
    fetch('state?src=ref', { headers: stateEtag ? { 'If-None-Match': stateEtag } : {} }).then(r => {
      if (r.status === 304) return null;
      stateEtag = r.headers.get('ETag') || '';
      return r.json();
    }).then(st => {
      if (!st) return;
      handleToast(st._toast);
      handleGen(st._gen);
      if (JSON.stringify(st) !== JSON.stringify(state)) { receiveState(st); render(); }
    }).catch(()=>{});
  }, 1000);
});

/* the approved banner is dismissible per approval (Colby): the record lives
   in state + the PNG, the banner does not need to squat on the stage */
$('ab-close').addEventListener('click', () => {
  window._abDismissed = (state.approved || {}).ts;
  $('approvedbar').style.display = 'none';
});