← back to Microsite Engine

public/index.html

369 lines

<!doctype html><html lang="en"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Microsite Engine — DW fleet site generator</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<style>
  :root{ --bg:#12100d; --panel:#1b1813; --ink:#f1ece2; --dim:#9c9484; --accent:#c98a3a; --rule:#332e26; --good:#5fae6b; --warn:#d98a3a; --bad:#d15b5b;
    --serif:'Cormorant Garamond',Georgia,serif; --sans:-apple-system,'Inter',system-ui,sans-serif; --cols:5; }
  *{box-sizing:border-box}
  body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 var(--sans)}
  header{padding:18px 24px;border-bottom:1px solid var(--rule);display:flex;align-items:center;gap:16px;position:sticky;top:0;background:var(--bg);z-index:5}
  header h1{font:600 22px var(--serif);margin:0;letter-spacing:.02em}
  header .sub{color:var(--dim);font-size:13px}
  .badge{margin-left:auto;font-size:12px;color:var(--dim);border:1px solid var(--rule);border-radius:20px;padding:4px 12px}
  .wrap{display:grid;grid-template-columns:380px 1fr;gap:0;min-height:calc(100vh - 61px)}
  .form{padding:20px 24px;border-right:1px solid var(--rule);overflow-y:auto;max-height:calc(100vh - 61px)}
  .main{padding:20px 24px;overflow-y:auto;max-height:calc(100vh - 61px)}
  label{display:block;font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--dim);margin:14px 0 5px}
  input,select,textarea{width:100%;background:var(--panel);border:1px solid var(--rule);color:var(--ink);border-radius:6px;padding:8px 10px;font:14px var(--sans)}
  textarea{resize:vertical;min-height:44px}
  .row{display:grid;grid-template-columns:1fr 1fr;gap:10px}
  .chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}
  .chip{font-size:12px;padding:4px 9px;border:1px solid var(--rule);border-radius:16px;background:var(--panel);color:var(--dim);cursor:pointer;user-select:none}
  .chip:hover{border-color:var(--accent);color:var(--ink)}
  .chip.on{background:var(--accent);color:#191510;border-color:var(--accent);font-weight:600}
  .chip .n{opacity:.6;margin-left:4px}
  button{cursor:pointer;font:600 14px var(--sans);border-radius:6px;border:1px solid var(--accent);background:var(--accent);color:#191510;padding:10px 16px}
  button.ghost{background:transparent;color:var(--ink);border-color:var(--rule)}
  button.ghost:hover{border-color:var(--accent)}
  .count{font:600 40px var(--serif);color:var(--accent);line-height:1}
  .count small{font:400 14px var(--sans);color:var(--dim);display:block;text-transform:uppercase;letter-spacing:.1em;margin-top:2px}
  .toolbar{display:flex;align-items:center;gap:14px;margin:14px 0;flex-wrap:wrap}
  .toolbar .sp{margin-left:auto}
  .hero{position:relative;height:300px;border-radius:10px;overflow:hidden;border:1px solid var(--rule);background:#0c0a08;margin-bottom:6px}
  .hero-layers{position:absolute;inset:0}
  .hero-layers .lyr{position:absolute;inset:0;background-size:cover;background-position:center;opacity:0;transition:opacity 1.6s ease-in-out}
  .hero-layers .lyr.on{opacity:1}
  .hero-vignette{position:absolute;inset:0;background:radial-gradient(120% 100% at 50% 40%,transparent 40%,#000a 100%);pointer-events:none}
  .hero-overlay{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;padding-bottom:34px;text-align:center;text-shadow:0 2px 18px #000b}
  .hero-wm{font:600 40px var(--serif);letter-spacing:.06em;color:#fff}
  .hero-tag{font:italic 400 16px var(--serif);color:#f3ece0;margin-top:4px}
  .hero-cta{margin-top:16px;font:600 12px var(--sans);letter-spacing:.16em;text-transform:uppercase;color:#191510;background:#fff;border-radius:22px;padding:9px 26px;opacity:.94}
  .hero-empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--dim);font-size:13px}
  .hero-note{color:var(--dim);font-size:12px;margin:0 0 8px}
  .grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:12px}
  .card{background:var(--panel);border:1px solid var(--rule);border-radius:8px;overflow:hidden}
  .card .img{aspect-ratio:1/1;background:#0c0a08 center/cover no-repeat}
  .card .meta{padding:7px 9px;font-size:12px}
  .card .t{color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  .card .r{color:var(--dim);display:flex;align-items:center;gap:6px;margin-top:3px}
  .dot{width:10px;height:10px;border-radius:50%;border:1px solid #0006;display:inline-block}
  .section-title{font:600 15px var(--serif);letter-spacing:.03em;margin:26px 0 4px;border-bottom:1px solid var(--rule);padding-bottom:6px}
  .drafts{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;margin-top:12px}
  .draft{background:var(--panel);border:1px solid var(--rule);border-radius:8px;padding:12px 14px}
  .draft h4{margin:0 0 2px;font:600 16px var(--serif)}
  .draft .dom{color:var(--accent);font-size:13px}
  .draft .when{color:var(--dim);font-size:11.5px;margin:6px 0}
  .draft .k{color:var(--dim);font-size:12px;margin:2px 0}
  .draft .st{display:inline-block;font-size:10.5px;text-transform:uppercase;letter-spacing:.08em;padding:2px 8px;border-radius:12px;margin-top:6px}
  .st.draft{background:#2a3a2a;color:var(--good)} .st.conflict{background:#3a2a2a;color:var(--bad)}
  .draft .acts{display:flex;gap:8px;margin-top:10px}
  .draft .acts button{font-size:12px;padding:6px 10px}
  pre{background:#0c0a08;border:1px solid var(--rule);border-radius:6px;padding:12px;overflow-x:auto;font-size:12px;color:#cfc7b6}
  .cmd{background:#0c0a08;border:1px dashed var(--accent);border-radius:6px;padding:9px 11px;font-family:ui-monospace,monospace;font-size:12px;color:var(--accent);word-break:break-all;margin-top:8px}
  .note{color:var(--dim);font-size:12px;margin-top:6px}
  .banner{background:#3a2a2a;border:1px solid var(--bad);color:#f0c9c9;padding:10px 14px;border-radius:6px;margin-bottom:14px;font-size:13px}
  dialog{background:var(--panel);color:var(--ink);border:1px solid var(--accent);border-radius:10px;max-width:640px;width:92%}
  dialog::backdrop{background:#000a}
</style></head>
<body>
<header>
  <h1>Microsite Engine</h1>
  <span class="sub">generate a DW sister-microsite config over the live catalog · drives <code>dw-domain-fleet</code></span>
  <span class="badge" id="fleetBadge">…</span>
</header>

<div class="wrap">
  <!-- ============ FORM ============ -->
  <div class="form">
    <div id="fleetErr"></div>
    <label>Start from a site idea <span class="note">(owned unused domain × catalog niche)</span></label>
    <select id="ideaAdd"></select>
    <label>Slug (subdomain, a–z0–9-)</label>
    <input id="slug" placeholder="corkwallcovering" autocomplete="off" list="slugList">
    <datalist id="slugList"></datalist>
    <label>Domain <span class="note">(dropdown = owned domains not yet in the fleet)</span></label>
    <input id="domain" placeholder="corkwallcovering.com" autocomplete="off" list="domainList">
    <datalist id="domainList"></datalist>
    <label>Site name</label>
    <input id="siteName" placeholder="Cork Wallcovering" autocomplete="off">
    <label>Tagline</label>
    <input id="tagline" placeholder="Hand-crafted cork wallcoverings." autocomplete="off">
    <div class="row">
      <div><label>Hero headline</label><input id="heroHeadline" placeholder="Cork Wallcovering"></div>
      <div><label>Hero sub</label><input id="heroSub" placeholder="Explore the collection"></div>
    </div>

    <div class="section-title">Niche — which products this site carries</div>
    <label>Include keywords <span class="note">(any-match on title+tags+type)</span></label>
    <input id="pos" placeholder="type to search the catalog… cork, natural" autocomplete="off" list="posList">
    <datalist id="posList"></datalist>
    <div class="chips" id="tagChips"></div>
    <label>Exclude keywords</label>
    <input id="neg" placeholder="type to search… commercial" autocomplete="off" list="negList">
    <datalist id="negList"></datalist>
    <label>Product types</label>
    <select id="typeAdd"></select>
    <div class="chips" id="typeChips"></div>

    <div class="section-title">Look</div>
    <div class="row">
      <div><label>Palette</label><select id="palette"></select></div>
      <div><label>Font</label><select id="font"></select></div>
    </div>

    <div style="margin-top:20px;display:flex;gap:10px">
      <button id="genBtn">Generate config →</button>
      <button class="ghost" id="resetBtn">Reset</button>
    </div>
    <div class="note">Writes a deploy-ready <code>sites/&lt;slug&gt;.json</code> to this project's <code>output/</code> — never the live fleet, never deploys.</div>
  </div>

  <!-- ============ MAIN ============ -->
  <div class="main">
    <div class="hero" id="hero">
      <div class="hero-layers" id="heroLayers"></div>
      <div class="hero-vignette"></div>
      <div class="hero-overlay">
        <div class="hero-wm" id="heroWm">Cork Wallcovering</div>
        <div class="hero-tag" id="heroTag">Hand-crafted cork wallcoverings.</div>
        <div class="hero-cta">Enter</div>
      </div>
      <div class="hero-empty" id="heroEmpty">hero preview — define a niche with matching products</div>
    </div>
    <div class="hero-note" id="heroNote"></div>

    <div class="toolbar">
      <div><div class="count"><span id="cnt">0</span><small>products match this niche</small></div></div>
      <div class="sp"></div>
      <label style="margin:0">Sort</label>
      <select id="sort" style="width:auto">
        <option value="newest">Newest</option>
        <option value="color">Color</option>
        <option value="light-dark">Light → Dark</option>
        <option value="dark-light">Dark → Light</option>
        <option value="style">Style</option>
        <option value="title">Title A→Z</option>
        <option value="sku">SKU A→Z</option>
        <option value="price-asc">Price ↑</option>
        <option value="price-desc">Price ↓</option>
      </select>
      <label style="margin:0">Density</label>
      <input id="density" type="range" min="3" max="8" value="5" style="width:120px">
    </div>
    <div class="grid" id="grid"></div>
    <p class="note" id="gridNote"></p>

    <div class="section-title">Saved microsite drafts</div>
    <div class="drafts" id="drafts"></div>
  </div>
</div>

<dialog id="genDlg">
  <div style="padding:20px 22px">
    <h3 style="margin:0 0 4px;font:600 20px var(--serif)" id="dlgTitle">Config generated</h3>
    <div id="dlgClash"></div>
    <p class="note">Emitted to <code id="dlgPath"></code> — matches <b id="dlgCount">0</b> products, port <b id="dlgPort"></b>.</p>
    <p class="note" style="margin-top:12px">To stage into the live fleet, run (gated — you run it):</p>
    <div class="cmd" id="dlgCmd"></div>
    <details style="margin-top:12px"><summary class="note" style="cursor:pointer">view sites/&lt;slug&gt;.json</summary><pre id="dlgJson"></pre></details>
    <div style="text-align:right;margin-top:16px"><button class="ghost" onclick="genDlg.close()">Close</button></div>
  </div>
</dialog>

<script>
const $ = id => document.getElementById(id);
let FACETS = { typeList:[], tagList:[], palettes:[], fonts:[] };
const selTypes = new Set();

async function boot(){
  FACETS = await (await fetch('/api/facets')).json();
  $('fleetBadge').textContent = FACETS.fleetOk ? (FACETS.total.toLocaleString()+' products · fleet linked') : 'FLEET NOT LINKED';
  if(!FACETS.fleetOk){
    $('fleetErr').innerHTML = '<div class="banner">Could not load dw-domain-fleet shared modules: '+esc(FACETS.fleetErr||'')+'<br>Preview will be empty until the fleet is reachable.</div>';
  }
  $('palette').innerHTML = FACETS.palettes.map(p=>`<option>${p}</option>`).join('');
  $('font').innerHTML = FACETS.fonts.map(f=>`<option>${f}</option>`).join('');
  // type chips
  $('typeChips').innerHTML = FACETS.typeList.map(t=>`<span class="chip" data-type="${t.name}">${t.name}<span class="n">${t.n}</span></span>`).join('');
  document.querySelectorAll('#typeChips .chip').forEach(c=>c.onclick=()=>{
    const t=c.dataset.type; c.classList.toggle('on'); if(selTypes.has(t))selTypes.delete(t);else selTypes.add(t); doPreview();
  });
  // top tag chips (click to append to include)
  $('tagChips').innerHTML = FACETS.tagList.slice(0,28).map(t=>`<span class="chip" data-tag="${t.name}">${t.name}<span class="n">${t.n}</span></span>`).join('');
  document.querySelectorAll('#tagChips .chip').forEach(c=>c.onclick=()=>{
    const cur=$('pos').value.split(',').map(s=>s.trim()).filter(Boolean);
    const t=c.dataset.tag.toLowerCase();
    if(cur.map(s=>s.toLowerCase()).includes(t)){cur.splice(cur.findIndex(s=>s.toLowerCase()===t),1);c.classList.remove('on');}
    else{cur.push(c.dataset.tag);c.classList.add('on');}
    $('pos').value=cur.join(', '); doPreview();
  });
  // prefilled dropdown of every product type (complements the chips above)
  $('typeAdd').innerHTML = '<option value="">+ add a product type…</option>' +
    FACETS.typeList.map(t=>`<option value="${esc(t.name)}">${esc(t.name)} · ${t.n.toLocaleString()}</option>`).join('');
  $('typeAdd').onchange = ()=>{
    const t=$('typeAdd').value; $('typeAdd').value='';
    if(!t || selTypes.has(t)) return;
    selTypes.add(t);
    const chip=[...document.querySelectorAll('#typeChips .chip')].find(c=>c.dataset.type===t);
    if(chip) chip.classList.add('on');
    doPreview();
  };
  // slug + domain dropdowns, prefilled from the owned-domain portfolio
  // (CNCP, minus domains the live fleet already uses)
  const doms = FACETS.domains || [];
  $('domainList').innerHTML = doms.map(d=>`<option value="${esc(d)}"></option>`).join('');
  $('slugList').innerHTML = doms.map(d=>`<option value="${esc(d.split('.')[0].replace(/[^a-z0-9-]/g,''))}"></option>`).join('');
  // picking an owned domain auto-derives the slug (if still empty)
  $('domain').addEventListener('change', ()=>{
    const d=$('domain').value.trim().toLowerCase();
    if(doms.includes(d) && !$('slug').value.trim()) $('slug').value=d.split('.')[0].replace(/[^a-z0-9-]/g,'');
  });
  // "start from a site idea" — EVERY owned domain, in three tiers: niche-matched
  // ideas (with real product counts), all other owned domains, then fleet-taken
  const IDEAS = FACETS.ideas||[];
  const opt = (it,i)=>`<option value="${i}">${esc(it.domain)}${it.keywords.length?` — ${esc(it.keywords.join(', '))} · ${it.count.toLocaleString()} products`:''}</option>`;
  const grp = (label,f)=>{ const o=IDEAS.map((it,i)=>f(it)?opt(it,i):'').join(''); return o?`<optgroup label="${label}">${o}</optgroup>`:''; };
  $('ideaAdd').innerHTML = '<option value="">💡 pick a domain / idea to prefill the form…</option>'
    + grp('Niche ideas — domain × catalog match', it=>it.keywords.length&&!it.inFleet)
    + grp('All owned domains', it=>!it.keywords.length&&!it.inFleet)
    + grp('Already in the fleet', it=>it.inFleet);
  $('ideaAdd').onchange = ()=>{
    const it=IDEAS[+$('ideaAdd').value];
    if(!it) return;
    $('slug').value=it.slug; $('domain').value=it.domain; $('siteName').value=it.siteName;
    if(it.keywords.length){
      $('tagline').value=`Curated ${it.keywords[0].toLowerCase()} wallcoverings for designers and discerning homes.`;
      $('pos').value=it.keywords.join(', ');
    }
    document.querySelectorAll('#tagChips .chip').forEach(c=>c.classList.toggle('on',it.keywords.some(k=>k.toLowerCase()===c.dataset.tag.toLowerCase())));
    syncHeroText(); doPreview();
  };
  // typed-suggestion autocomplete for the include/exclude keyword fields,
  // prefilled from the REAL catalog vocabulary (all tags ∪ all product types)
  const VOCAB=[...new Set([...FACETS.tagList.map(t=>t.name), ...FACETS.typeList.map(t=>t.name)])];
  wireTokenDatalist($('pos'), $('posList'), VOCAB);
  wireTokenDatalist($('neg'), $('negList'), VOCAB);
  loadDrafts(); doPreview();
}

// Token-aware datalist: the keyword fields are comma-separated, but a native
// <datalist> matches against the WHOLE input value. So on each keystroke we
// rebuild the options as "<already-typed tokens>, <suggestion>" and prefix-match
// the trailing token — giving real autocomplete on a multi-value field, and
// hiding tokens you've already picked. Prefix-match is the safe common
// denominator across Chrome/Safari datalist filtering.
function wireTokenDatalist(input, list, vocab){
  function refresh(){
    const val = input.value;
    const cut = val.lastIndexOf(',');
    const prefix = cut >= 0 ? val.slice(0, cut+1) : '';
    const typed  = val.slice(cut+1).trim().toLowerCase();
    const chosen = new Set(prefix.slice(0,-1).split(',').map(s=>s.trim().toLowerCase()).filter(Boolean));
    const spacer = cut >= 0 ? ' ' : '';
    list.innerHTML = vocab
      .filter(v => !chosen.has(v.toLowerCase()))
      .filter(v => !typed || v.toLowerCase().startsWith(typed))
      .slice(0, 50)
      .map(v => `<option value="${esc(prefix+spacer+v)}"></option>`).join('');
  }
  input.addEventListener('input', refresh);
  input.addEventListener('focus', refresh);
  refresh();
}

let pvTimer;
function doPreviewDebounced(){ clearTimeout(pvTimer); pvTimer=setTimeout(doPreview,250); }
async function doPreview(){
  const body={ pos:$('pos').value, neg:$('neg').value, types:[...selTypes], sort:$('sort').value };
  const r = await (await fetch('/api/preview',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})).json();
  $('cnt').textContent = (r.count||0).toLocaleString();
  $('grid').innerHTML = (r.cards||[]).map(c=>`
    <div class="card">
      <div class="img" style="background-image:url('${(c.image_url||'').replace(/'/g,'')}')"></div>
      <div class="meta"><div class="t" title="${esc(c.title)}">${esc(c.title)}</div>
      <div class="r"><span class="dot" style="background:${c.hex}"></span>${esc(c.product_type||'')}</div></div>
    </div>`).join('');
  $('gridNote').textContent = r.count>r.shown ? `showing first ${r.shown} of ${r.count.toLocaleString()}` : (r.count?'':'no products match — loosen the niche');
  setHero(r.heroes || (r.cards||[]).map(c=>c.image_url).filter(Boolean));
}

// ---- hero preview: rotating crossfade over the niche's hero images ----
let heroTimer, heroIdx = 0, heroUrls = [];
function setHero(urls){
  heroUrls = (urls||[]).slice(0,8);
  const L = $('heroLayers');
  L.innerHTML = heroUrls.map((u,i)=>`<div class="lyr${i===0?' on':''}" style="background-image:url('${(u||'').replace(/'/g,'')}')"></div>`).join('');
  $('heroEmpty').style.display = heroUrls.length ? 'none' : 'flex';
  $('heroNote').textContent = heroUrls.length ? `${heroUrls.length} hero images (newest matched products) — emitted as a companion hero-allocation on Generate` : '';
  heroIdx = 0;
  clearTimeout(heroTimer);
  if (heroUrls.length > 1) rotateHero();
}
function rotateHero(){
  const lyrs = document.querySelectorAll('#heroLayers .lyr');
  if (lyrs.length < 2) return;
  heroTimer = setTimeout(()=>{
    lyrs[heroIdx].classList.remove('on');
    heroIdx = (heroIdx + 1) % lyrs.length;
    lyrs[heroIdx].classList.add('on');
    rotateHero();
  }, 3200);
}
function syncHeroText(){
  $('heroWm').textContent = $('siteName').value || $('heroHeadline').value || 'Your Microsite';
  $('heroTag').textContent = $('tagline').value || 'Explore the collection';
}
function esc(s){return String(s||'').replace(/[&<>"]/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[m]));}

async function loadDrafts(){
  const {drafts} = await (await fetch('/api/drafts')).json();
  $('drafts').innerHTML = drafts.length ? drafts.slice().reverse().map(d=>{
    const dt = new Date(d.created_at).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
    const niche = [d.niche.pos.length?('+'+d.niche.pos.join('/')):'', d.niche.neg.length?('−'+d.niche.neg.join('/')):'', d.niche.types.join('/')].filter(Boolean).join(' · ')||'all products';
    return `<div class="draft">
      <h4>${esc(d.siteName)}</h4><div class="dom">${esc(d.domain)}</div>
      <div class="when" title="${d.created_at}">🕓 ${dt}</div>
      <div class="k">niche: ${esc(niche)}</div>
      <div class="k">${(d.matchCount||0).toLocaleString()} products · port ${d.port}</div>
      <span class="st ${d.status}">${d.status}${d.clashes&&d.clashes.length?' — '+esc(d.clashes.join(', ')):''}</span>
      <div class="acts"><button class="ghost" data-load="${d.slug}">Load</button><button class="ghost" data-del="${d.slug}">Delete</button></div>
    </div>`;
  }).join('') : '<p class="note">No drafts yet — generate one to see it here.</p>';
  document.querySelectorAll('[data-del]').forEach(b=>b.onclick=async()=>{ await fetch('/api/delete-draft',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({slug:b.dataset.del})}); loadDrafts(); });
  document.querySelectorAll('[data-load]').forEach(b=>b.onclick=async()=>{
    const {drafts}=await (await fetch('/api/drafts')).json(); const d=drafts.find(x=>x.slug===b.dataset.load); if(!d)return;
    $('slug').value=d.slug;$('domain').value=d.domain;$('siteName').value=d.siteName;
    $('pos').value=d.niche.pos.join(', ');$('neg').value=d.niche.neg.join(', ');
    selTypes.clear(); d.niche.types.forEach(t=>selTypes.add(t));
    document.querySelectorAll('#typeChips .chip').forEach(c=>c.classList.toggle('on',selTypes.has(c.dataset.type)));
    doPreview(); window.scrollTo(0,0);
  });
}

async function generate(){
  const slug=$('slug').value.trim();
  if(!slug){alert('slug required');return;}
  const body={ slug, domain:$('domain').value, siteName:$('siteName').value, tagline:$('tagline').value,
    heroHeadline:$('heroHeadline').value, heroSub:$('heroSub').value,
    pos:$('pos').value, neg:$('neg').value, types:[...selTypes],
    palette:$('palette').value, font:$('font').value };
  const r = await (await fetch('/api/generate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})).json();
  if(r.error){alert(r.error);return;}
  $('dlgPath').textContent=r.outPath; $('dlgCount').textContent=(r.matchCount||0).toLocaleString(); $('dlgPort').textContent=r.port;
  $('dlgCmd').textContent=r.copyCmd + '\n' + (r.heroCmd||''); $('dlgJson').textContent=JSON.stringify(r.config,null,2);
  $('dlgClash').innerHTML = (r.clashes&&r.clashes.length)?('<div class="banner">Conflict: '+esc(r.clashes.join(', '))+'. Pick a different slug/domain before staging.</div>'):'';
  $('genDlg').showModal(); loadDrafts();
}

['pos','neg'].forEach(id=>$(id).addEventListener('input',doPreviewDebounced));
['siteName','heroHeadline','tagline'].forEach(id=>$(id).addEventListener('input',syncHeroText));
$('sort').addEventListener('change',doPreview);
$('density').addEventListener('input',e=>document.documentElement.style.setProperty('--cols',e.target.value));
$('genBtn').onclick=generate;
$('resetBtn').onclick=()=>{['slug','domain','siteName','tagline','heroHeadline','heroSub','pos','neg'].forEach(id=>$(id).value='');$('ideaAdd').value='';selTypes.clear();document.querySelectorAll('.chip.on').forEach(c=>c.classList.remove('on'));syncHeroText();doPreview();};
boot().then(syncHeroText);
</script>
</body></html>