← back to CelebritySignatures

public/wear.html

932 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Wear a Signature — Celebrity Signatures</title>
<link rel="icon" type="image/png" href="/assets/favicon.png">
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,600;1,400&display=swap" rel="stylesheet">
<script type="importmap">
{ "imports": {
  "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
  "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
} }
</script>
<style>
  :root { --cols: 5; --ink:#1a1a1a; --green:#1d7a36; --line:#e6e3dc; --paper:#f7f5f0; }
  * { box-sizing: border-box; }
  body { margin:0; font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; color:var(--ink); background:var(--paper); }
  header { display:flex; align-items:center; justify-content:space-between; padding:16px 22px; border-bottom:1px solid var(--line); }
  .mast { font:italic 600 22px 'Playfair Display',serif; text-decoration:none; color:var(--ink); }
  header nav a { color:#6b6b6b; text-decoration:none; margin-left:16px; font-size:14px; }
  header nav a:hover { color:var(--ink); }
  .cart-btn { margin-left:16px; font:inherit; font-size:14px; background:none; border:1px solid var(--line); border-radius:999px; padding:6px 14px; cursor:pointer; color:var(--ink); position:relative; }
  .cart-btn:hover { border-color:var(--ink); }
  .cart-badge { display:inline-block; margin-left:4px; background:var(--ink); color:#fff; border-radius:999px; font-size:11px; min-width:16px; padding:1px 5px; text-align:center; }
  .cart-item { display:flex; gap:10px; align-items:center; padding:10px 0; border-bottom:1px solid var(--line); }
  .cart-item img { width:44px; height:44px; object-fit:contain; background:#fff; border-radius:8px; padding:4px; mix-blend-mode:multiply; }
  .cart-item .ci-info { flex:1; min-width:0; }
  .cart-item .ci-name { font-size:14px; font-weight:500; }
  .cart-item .ci-meta { font-size:12px; color:#6b6b6b; }
  .cart-item .ci-price { font-size:14px; white-space:nowrap; }
  .cart-item .ci-remove { background:none; border:0; color:#c0392b; cursor:pointer; font-size:18px; padding:0 4px; }
  .cart-empty { text-align:center; color:#6b6b6b; padding:30px 0; }
  .cart-total { display:flex; justify-content:space-between; font:600 18px 'Playfair Display',serif; padding:14px 0; border-top:1px solid var(--line); margin-top:6px; }
  .wrap { max-width:1200px; margin:0 auto; padding:22px; }
  h1 { font:400 34px/1.15 'Playfair Display',serif; margin:6px 0 4px; }
  .lede { color:#6b6b6b; max-width:640px; }
  /* disclosure banner */
  .disclosure { border:1px solid var(--line); border-left:4px solid var(--green); background:#fff; border-radius:10px; padding:12px 16px; margin:18px 0; font-size:14px; }
  .disclosure.warn { border-left-color:#c58a1a; }
  .disclosure b { font-weight:600; }
  /* Signature Edit lookbook promo */
  .edit-promo { display:flex; align-items:center; gap:12px; text-decoration:none; color:var(--ink); background:#fff; border:1px solid var(--green); border-radius:10px; padding:12px 16px; margin:14px 0; font-size:14px; transition:box-shadow .15s, transform .12s; }
  .edit-promo:hover { box-shadow:0 6px 18px rgba(29,122,54,.12); transform:translateY(-1px); }
  .edit-promo .ep-star { color:var(--green); font-size:16px; }
  .edit-promo .ep-txt { flex:1; min-width:0; }
  .edit-promo .ep-cta { color:var(--green); font-weight:600; white-space:nowrap; letter-spacing:.02em; }
  @media(max-width:620px){ .edit-promo{ flex-wrap:wrap; } .edit-promo .ep-cta{ width:100%; } }
  /* controls */
  .controls { display:flex; gap:16px; align-items:center; flex-wrap:wrap; margin:14px 0 18px; }
  .controls label { font-size:13px; color:#6b6b6b; display:flex; align-items:center; gap:6px; }
  select, input[type=range] { font:inherit; }
  select { padding:6px 10px; border:1px solid var(--line); border-radius:8px; background:#fff; }
  .count { font-size:13px; color:#6b6b6b; margin-left:auto; }
  /* grid */
  .grid { display:grid; grid-template-columns:repeat(var(--cols), 1fr); gap:12px; }
  .card { background:#fff; border:1px solid var(--line); border-radius:12px; padding:14px; cursor:pointer; text-align:center; transition:transform .12s, box-shadow .12s; }
  .card:hover { transform:translateY(-2px); box-shadow:0 8px 22px rgba(0,0,0,.07); }
  .card { position:relative; }
  /* ✦ marks signers who have a seated editorial "worn" shot (the Signature Edit). */
  .card.has-edit { box-shadow:inset 0 0 0 1px var(--green); }
  .edit-star { position:absolute; top:7px; right:9px; font-size:12px; line-height:1; color:var(--green); }
  .card img { max-width:100%; height:64px; object-fit:contain; mix-blend-mode:multiply; }
  /* Colored-ink signatures on clean white via the shared preview component. */
  .card .signature-preview { height:64px; }
  .ac-item .signature-preview { width:40px; height:34px; flex:0 0 40px; background:#fff; }
  .card .nm { font-size:13px; margin-top:8px; font-weight:500; }
  .card .cat { font-size:11px; color:#9a978f; text-transform:uppercase; letter-spacing:.05em; }
  .empty { background:#fff; border:1px dashed var(--line); border-radius:12px; padding:44px 24px; text-align:center; color:#6b6b6b; }
  /* signature search + typeahead */
  .searchbar { position:relative; margin:18px 0 4px; }
  .searchbar .ico { position:absolute; left:14px; top:50%; transform:translateY(-50%); color:#9a978f; font-size:16px; pointer-events:none; }
  #sigSearch { width:100%; font:inherit; font-size:16px; padding:13px 42px 13px 40px; border:1px solid var(--line); border-radius:12px; background:#fff; color:var(--ink); }
  #sigSearch:focus { outline:none; border-color:var(--ink); box-shadow:0 4px 18px rgba(0,0,0,.06); }
  .searchbar .clear { position:absolute; right:10px; top:50%; transform:translateY(-50%); border:0; background:none; color:#9a978f; font-size:20px; cursor:pointer; padding:2px 8px; line-height:1; display:none; }
  .searchbar .clear:hover { color:var(--ink); }
  .ac { position:absolute; z-index:20; left:0; right:0; top:calc(100% + 6px); background:#fff; border:1px solid var(--line); border-radius:12px; box-shadow:0 12px 30px rgba(0,0,0,.12); overflow:hidden; display:none; max-height:340px; overflow-y:auto; }
  .ac-item { display:flex; align-items:center; gap:12px; padding:9px 14px; cursor:pointer; border-bottom:1px solid var(--paper); }
  .ac-item:last-child { border-bottom:0; }
  .ac-item.active, .ac-item:hover { background:var(--paper); }
  .ac-item img { width:40px; height:34px; object-fit:contain; mix-blend-mode:multiply; flex:0 0 40px; }
  .ac-item .ac-nm { font-size:14px; font-weight:500; }
  .ac-item .ac-nm b { color:var(--green); font-weight:600; }
  .ac-item .ac-cat { font-size:11px; color:#9a978f; text-transform:uppercase; letter-spacing:.05em; margin-left:auto; }
  /* product overlay */
  .scrim { position:fixed; inset:0; background:rgba(20,18,14,.5); display:none; align-items:center; justify-content:center; padding:20px; z-index:40; }
  .scrim.on { display:flex; }
  .panel { background:#fff; border-radius:16px; max-width:860px; width:100%; max-height:92vh; overflow:auto; display:grid; grid-template-columns:1fr 1fr; }
  @media (max-width:720px){ .panel{ grid-template-columns:1fr; } .grid{ } }
  .preview { background:#efece5; display:flex; align-items:center; justify-content:center; padding:22px; border-radius:16px 0 0 16px; position:relative; overflow:hidden; }
  canvas#stage { cursor: zoom-in; }
  #zoomLens { display:none; position:absolute; width:220px; height:220px; border-radius:12px; border:2px solid #fff; box-shadow:0 8px 24px rgba(0,0,0,.28); pointer-events:none; z-index:5; background:#efece5; }
  .zoom-hint { position:absolute; bottom:14px; left:14px; font-size:11px; color:#6b6b6b; background:rgba(255,255,255,.85); border-radius:999px; padding:4px 10px; pointer-events:none; transition:opacity .15s; }
  .zoom-hint.hide { opacity:0; }
  #stage3d { width:100%; max-width:340px; aspect-ratio:1/1; cursor:grab; }
  #stage3d:active { cursor:grabbing; }
  #stage3d canvas { width:100%!important; height:100%!important; display:block; border-radius:8px; }
  .view3d-btn { position:absolute; bottom:14px; right:14px; font:inherit; font-size:12px; background:#fff; border:1px solid var(--line); border-radius:999px; padding:6px 12px; cursor:pointer; }
  .view3d-btn:hover { border-color:var(--ink); }
  /* Editorial "worn" shot — the celebrity seated in the full Signature Edit. */
  #lifestyleShot { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; display:block; }
  #lifestyleShot[hidden] { display:none; }  /* the explicit display:block above defeats the [hidden] default — restore it so "See on garment" actually reveals the mockup */
  .lifestyle-toggle { position:absolute; top:14px; right:14px; z-index:6; font:inherit; font-size:12px; background:rgba(255,255,255,.92); border:1px solid var(--line); border-radius:999px; padding:6px 12px; cursor:pointer; }
  .lifestyle-toggle:hover { border-color:var(--ink); }
  @media (max-width:720px){ .preview{ border-radius:16px 16px 0 0; } }
  canvas#stage { width:100%; max-width:340px; height:auto; }
  .buy { padding:24px; }
  #scrim .buy { text-align:center; }
  #scrim .chips { justify-content:center; }
  #scrim input[type=email] { text-align:center; }
  .buy h2 { font:400 24px 'Playfair Display',serif; margin:0 0 2px; }
  .buy .who { color:#6b6b6b; font-size:14px; margin-bottom:14px; }
  .row { margin:14px 0; }
  .row .lab { font-size:12px; text-transform:uppercase; letter-spacing:.05em; color:#6b6b6b; margin-bottom:6px; }
  .chips { display:flex; gap:8px; flex-wrap:wrap; }
  .chip { border:1px solid var(--line); border-radius:999px; padding:6px 12px; font-size:13px; cursor:pointer; background:#fff; }
  .chip.sel { border-color:var(--ink); background:var(--ink); color:#fff; }
  .swatch { width:30px; height:30px; border-radius:50%; border:2px solid #fff; box-shadow:0 0 0 1px var(--line); cursor:pointer; }
  .swatch.sel { box-shadow:0 0 0 2px var(--ink); }
  .chip.ink-original, .chip.ink-custom { padding:5px 12px; font-size:12px; }
  .price { font:600 22px 'Playfair Display',serif; margin:10px 0; }
  .buy input[type=email] { width:100%; padding:10px 12px; border:1px solid var(--line); border-radius:8px; font:inherit; }
  .btn { display:inline-block; width:100%; text-align:center; margin-top:12px; padding:12px 20px; border:0; border-radius:999px; background:var(--ink); color:#fff; font:inherit; cursor:pointer; }
  .btn:disabled { opacity:.45; cursor:not-allowed; }
  .msg { font-size:13px; color:#c0392b; margin-top:8px; min-height:18px; }
  .fine { font-size:12px; color:#9a978f; margin-top:12px; }
  .close { position:absolute; top:14px; right:18px; font-size:26px; color:#fff; cursor:pointer; z-index:41; }
</style>
<link rel="stylesheet" href="/assets/signature-preview.css">
<script type="module" src="/assets/signature-preview.js"></script>
<script type="module">
  // Bridge the shared per-signer ink color into the classic garment-canvas
  // script, so the on-shirt signature matches its swatch color exactly.
  import {inkColorFor} from '/assets/signature-ink.js';
  window.__signerInk = key => { const [r,g,b]=inkColorFor(key); return '#'+[r,g,b].map(v=>v.toString(16).padStart(2,'0')).join(''); };
</script>
</head>
<body>
<header>
  <a class="mast" href="/">Celebrity Signatures</a>
  <nav><a href="/">Gallery</a><a href="/wallpaper">Wallpaper</a><a href="/wear">Wear</a><a href="/game">Games</a>
    <button id="cartBtn" class="cart-btn" aria-label="Cart">🛍 Cart <span id="cartCount" class="cart-badge" hidden>0</span></button>
  </nav>
</header>

<div class="wrap">
  <h1>Wear a Signature</h1>
  <p class="lede">Historic signatures on shirts, hats and socks. Made to order.</p>

  <div id="disclosure" class="disclosure">Loading trademark screen…</div>
  <p id="selectionNotice" class="disclosure warn" role="status" hidden></p>
  <a class="edit-promo" href="/signature-edit"><span class="ep-star">✦</span> <span class="ep-txt"><b>The Signature Edit</b> — 53 icons seated in the full look, each wearing their own autograph</span> <span class="ep-cta">Explore the lookbook →</span></a>

  <div class="searchbar">
    <span class="ico">🔍</span>
    <input id="sigSearch" type="text" autocomplete="off" spellcheck="false"
           placeholder="Search a name — Lincoln, Einstein, Marilyn Monroe…"
           role="combobox" aria-expanded="false" aria-autocomplete="list" aria-controls="ac">
    <button class="clear" id="sigClear" aria-label="Clear search">×</button>
    <div class="ac" id="ac" role="listbox"></div>
  </div>

  <div class="controls">
    <label>Sort
      <select id="sort">
        <option value="name">Name A→Z</option>
        <option value="featured">✦ Signature Edit first</option>
        <option value="cat">Category</option>
        <option value="shuffle">Shuffle</option>
      </select>
    </label>
    <label>Density
      <input id="density" type="range" min="3" max="8" value="5">
    </label>
    <span class="count" id="count"></span>
  </div>

  <div id="grid" class="grid"></div>
  <div id="empty" class="empty" style="display:none"></div>
</div>

<div class="scrim" id="scrim">
  <span class="close" id="close">×</span>
  <div class="panel" style="position:relative">
    <div class="preview">
      <img id="lifestyleShot" alt="" hidden>
      <canvas id="stage" width="400" height="480"></canvas>
      <div id="stage3d" style="display:none"></div>
      <canvas id="zoomLens"></canvas>
      <span class="zoom-hint" id="zoomHint">🔍 hover the signature to zoom</span>
      <button class="view3d-btn" id="view3dToggle" hidden>View in 3D</button>
      <button class="lifestyle-toggle" id="lifestyleToggle" hidden></button>
    </div>
    <div class="buy">
      <h2 id="pName">—</h2>
      <div class="who" id="pCat"></div>
      <div class="row"><div class="lab">Product</div><div class="chips" id="garments"></div></div>
      <div class="row"><div class="lab">Color</div><div class="chips" id="colors"></div></div>
      <div class="row"><div class="lab">Signature ink</div><div class="chips" id="inkChips"></div><input type="color" id="inkCustom" hidden></div>
      <div class="row"><div class="lab">Size</div><div class="chips" id="sizes"></div><div class="fine" id="sizeNote"></div></div>
      <div class="price" id="price"></div>
      <div class="row"><input id="email" type="email" placeholder="your@email.com"></div>
      <button class="btn" id="buy">Add to cart</button>
      <div class="msg" id="msg"></div>
      <div class="fine" id="productNote"></div>
    </div>
  </div>
</div>

<div class="scrim" id="cartScrim">
  <span class="close" id="cartClose">×</span>
  <div class="panel" style="grid-template-columns:1fr;max-width:480px">
    <div class="buy">
      <h2>Your cart</h2>
      <div id="cartList"></div>
      <div class="cart-total"><span>Total</span><span id="cartTotal">$0.00</span></div>
      <div class="row"><input id="cartEmail" type="email" placeholder="your@email.com"></div>
      <button class="btn" id="cartCheckout">Checkout</button>
      <div class="msg" id="cartMsg"></div>
      <div class="fine">This is a TEST checkout — no card is charged and no print order is submitted; each order is drafted for review.</div>
    </div>
  </div>
</div>

<script>
const state = { data:null, sig:null, garment:null, color:null, size:null, inkColor:null };
// Curated signature-ink swatches (buyer override; null state.inkColor = Auto =
// the signer's own color on light cloth / white on dark). "Custom…" opens the
// full picker. Every preset is legible on white AND on a light garment.
const INK_PRESETS = [
  { id:'black',    label:'Black',    hex:'#1a1a1a' },
  { id:'navy',     label:'Navy',     hex:'#1f3a5f' },
  { id:'burgundy', label:'Burgundy', hex:'#6e1f2e' },
  { id:'forest',   label:'Forest',   hex:'#234a34' },
  { id:'gold',     label:'Gold',     hex:'#8a6a1e' },
  { id:'sepia',    label:'Sepia',    hex:'#5a4632' },
  { id:'white',    label:'White',    hex:'#ffffff' },
];
// Editorial "worn" shots — the celebrity seated in the full Signature Edit
// (AI-generated, /assets/campaign-<slug>.jpg). Keyed on the signer's EXACT name,
// NOT a surname guess: a surname match would wrongly show, e.g., John Quincy
// Adams's photo on all 10 other "Adams" pages, or Napoleon's on Napoleon III.
// This map is the definitive top-50 (+ existing); anyone not listed shows the
// normal garment mockup. Generated from the ranked list; extend when the
// pipeline adds a signer.
const LIFESTYLE_MAP = {"william shakespeare":"shakespeare","leonardo da vinci":"davinci","abraham lincoln":"lincoln","napoleon":"napoleon","johann wolfgang von goethe":"goethe","charles darwin":"darwin","george washington":"washington","galileo galilei":"galilei","victor hugo":"hugo","vincent van gogh":"vangogh","michelangelo":"michelangelo","michelangelo buonarroti":"michelangelo","martin luther":"luther","voltaire":"voltaire","fyodor dostoyevsky":"dostoyevsky","friedrich nietzsche":"nietzsche","rené descartes":"descartes","miguel de cervantes":"cervantes","charles dickens":"dickens","hans christian andersen":"andersen","alexander pushkin":"pushkin","edgar allan poe":"poe","molière":"moliere","oscar wilde":"wilde","lord byron":"byron","benjamin franklin":"franklin","louis pasteur":"pasteur","rembrandt":"rembrandt","richard wagner":"wagner","raphael":"raphael","georg wilhelm friedrich hegel":"hegel","alfred nobel":"nobel","francis bacon":"bacon","albrecht dürer":"durer","louis xiv of france":"france","friedrich schiller":"schiller","francisco goya":"goya","charles baudelaire":"baudelaire","geoffrey chaucer":"chaucer","katsushika hokusai":"hokusai","alexandre dumas":"dumas","honoré de balzac":"balzac","peter paul rubens":"rubens","heinrich heine":"heine","john quincy adams":"adams","diego velázquez":"velazquez","william blake":"blake","jonathan swift":"swift","montesquieu":"montesquieu","gustave flaubert":"flaubert","frida kahlo":"frida","albert einstein":"einstein","mark twain":"twain","pablo picasso":"picasso"};
function lifestyleSlug(name){
  return LIFESTYLE_MAP[(name || '').toLowerCase().trim()] || null;
}
// Toggle the modal's left visual between the editorial "worn" shot and the
// interactive garment mockup.
function setLifeView(mode){
  state.lifeMode = mode;
  const worn = mode === 'worn';
  document.getElementById('lifestyleShot').hidden = !worn;
  document.getElementById('stage').style.visibility = worn ? 'hidden' : '';
  document.getElementById('zoomHint').style.display = worn ? 'none' : '';
  document.getElementById('view3dToggle').style.display = worn ? 'none' : '';
  document.getElementById('lifestyleToggle').textContent = worn ? 'See on garment' : 'See it worn';
}
function applyLifestyle(){
  const shot = document.getElementById('lifestyleShot'), tog = document.getElementById('lifestyleToggle');
  // Default to the garment mockup; switch to the worn shot only if this EXACT
  // signer is mapped AND the file exists (it may not be generated yet).
  tog.hidden = true; shot.hidden = true; shot.removeAttribute('src'); setLifeView('garment');
  const sig = state.sig;
  const slug = lifestyleSlug(sig && sig.full_name);
  if (!slug) return;
  const url = `/assets/campaign-${slug}.jpg`;
  const probe = new Image();
  probe.onload = () => {
    if (state.sig !== sig) return;                 // signer changed while loading
    shot.src = url; shot.alt = `${sig.full_name} in the Signature Edit`;
    tog.hidden = false; setLifeView('worn');
  };
  probe.src = url;
}
// Top-level HTML-attribute escape (the autocomplete's own `esc` is scoped to
// that block, so the grid renderer needs its own).
const escA = s => String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));

// ---- cart (localStorage-backed; no login required) ----
const CART_KEY = 'wear_cart';
function loadCart(){ try { return JSON.parse(localStorage.getItem(CART_KEY) || '[]'); } catch { return []; } }
function saveCart(cart){ try { localStorage.setItem(CART_KEY, JSON.stringify(cart)); } catch {} renderCartBadge(cart); }
function renderCartBadge(cart){
  cart = cart || loadCart();
  const el = document.getElementById('cartCount');
  el.textContent = cart.length; el.hidden = cart.length === 0;
}
function cartUnitPrice(garment, size){
  const up = (garment.sizeUpchargeUsd && garment.sizeUpchargeUsd[size]) || 0;
  return (garment.priceUsd || 0) + up;
}
function addToCart(item){
  const cart = loadCart();
  cart.push({ cid: Date.now()+'-'+Math.random().toString(36).slice(2,7), ...item });
  saveCart(cart);
}
function removeFromCart(cid){
  saveCart(loadCart().filter(i=>i.cid!==cid));
  renderCart();
}
function renderCart(){
  const cart = loadCart();
  const list = document.getElementById('cartList');
  if (!cart.length){
    list.innerHTML = '<div class="cart-empty">Your cart is empty.</div>';
    document.getElementById('cartTotal').textContent = '$0.00';
    return;
  }
  list.innerHTML = cart.map(i=>`<div class="cart-item" data-cid="${i.cid}">
    <img src="${i.thumb}" alt="">
    <div class="ci-info"><div class="ci-name">${i.signature_name}</div><div class="ci-meta">${i.garment_label} · ${i.color_label} · ${i.size}</div></div>
    <div class="ci-price">$${i.unitPriceUsd.toFixed(2)}</div>
    <button class="ci-remove" data-cid="${i.cid}" title="Remove">×</button>
  </div>`).join('');
  list.querySelectorAll('.ci-remove').forEach(b=>b.onclick=()=>removeFromCart(b.dataset.cid));
  const total = cart.reduce((s,i)=>s+i.unitPriceUsd,0);
  document.getElementById('cartTotal').textContent = '$'+total.toFixed(2);
}
document.getElementById('cartBtn').onclick = ()=>{ renderCart(); document.getElementById('cartScrim').classList.add('on'); };
document.getElementById('cartClose').onclick = ()=>document.getElementById('cartScrim').classList.remove('on');
document.getElementById('cartScrim').onclick = e => { if(e.target.id==='cartScrim') document.getElementById('cartScrim').classList.remove('on'); };
document.getElementById('cartCheckout').onclick = async ()=>{
  const cart = loadCart();
  const msg = document.getElementById('cartMsg'); msg.style.color='#c0392b';
  if (!cart.length){ msg.textContent = 'Your cart is empty.'; return; }
  const email = document.getElementById('cartEmail').value.trim();
  if(!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)){ msg.textContent='Enter a valid email.'; return; }
  const btn = document.getElementById('cartCheckout'); btn.disabled=true; btn.textContent='Starting checkout…';
  try{
    const r = await fetch('/api/wear-checkout',{method:'POST',headers:{'Content-Type':'application/json'},
      body:JSON.stringify({ email, items: cart.map(i=>({ qid:i.qid, garment:i.garment, color:i.color, size:i.size, inkColor:i.inkColor })) })}).then(r=>r.json());
    if(r.ok && r.url){ localStorage.removeItem(CART_KEY); location.href = r.url; return; }
    if(r.comingSoon){ msg.style.color='#2e7d32'; msg.textContent = `You're on the list — we'll email ${email} the moment sales go live.`; btn.textContent='On the list ✓'; return; }
    msg.textContent = r.error || 'Checkout unavailable.';
  }catch(e){ msg.textContent='Network error.'; }
  btn.disabled=false; btn.textContent='Checkout';
};

async function boot(){
  const r = await fetch('/api/wear/signatures').then(r=>r.json());
  state.data = r;
  const d = document.getElementById('disclosure');
  d.className = 'disclosure' + (r.disclosure.purchasable ? '' : ' warn');
  d.innerHTML = `<b>${(r.count||0).toLocaleString()} historic signatures</b> — public-domain figures (deceased on or before ${r.disclosure.cutoffDeathYear||1900}) on signature apparel.` +
    (r.disclosure.purchasable ? '' : ' <b>Sales open soon</b> — browse now; add your email to be notified.');
  renderCartBadge();
  render();
  // Gallery purchase links carry the chosen signature into its configurator.
  // Only resolve from the server's eligible catalog; never bypass that gate.
  const selected = new URLSearchParams(location.search);
  if (selected.has('qid') || selected.has('name')) {
    const sig = selected.has('qid')
      ? r.signatures.find(s=>s.qid===selected.get('qid'))
      : r.signatures.find(s=>s.full_name.toLowerCase()===selected.get('name').toLowerCase());
    if (sig) openProduct(sig.qid);
    else {
      const notice = document.getElementById('selectionNotice');
      notice.textContent = 'This signature is not available on clothing. Choose from the available historical signatures below.';
      notice.hidden = false;
    }
  }
}

function render(){
  const grid = document.getElementById('grid'), empty = document.getElementById('empty');
  let sigs = (state.data.signatures||[]).slice();
  const q = (state.q||'').trim().toLowerCase();
  if (q) sigs = sigs.filter(s => (s.full_name||'').toLowerCase().includes(q) || (s.category||'').toLowerCase().includes(q));
  const sort = document.getElementById('sort').value;
  if (sort==='name') sigs.sort((a,b)=>a.full_name.localeCompare(b.full_name));
  else if (sort==='featured') sigs.sort((a,b)=>(lifestyleSlug(b.full_name)?1:0)-(lifestyleSlug(a.full_name)?1:0) || a.full_name.localeCompare(b.full_name));
  else if (sort==='cat') sigs.sort((a,b)=>(a.category||'').localeCompare(b.category||'')||a.full_name.localeCompare(b.full_name));
  else if (sort==='shuffle') sigs.sort(()=>Math.random()-0.5);
  document.getElementById('count').textContent = sigs.length ? `${sigs.length.toLocaleString()} available` : '';
  if (!sigs.length){
    grid.style.display='none'; empty.style.display='block';
    empty.innerHTML = (state.q||'').trim()
      ? `No signatures match “${(state.q).replace(/</g,'&lt;')}”.`
      : 'No signatures matched the eligibility gate.';
    return;
  }
  grid.style.display='grid'; empty.style.display='none';
  grid.innerHTML = sigs.map(s=>`<div class="card${lifestyleSlug(s.full_name)?' has-edit':''}" data-qid="${s.qid}">${lifestyleSlug(s.full_name)?'<span class="edit-star" title="Signature Edit — see them wearing it">✦</span>':''}
    <span class="signature-preview" data-signature-src="${escA(s.signature_image_url)}" data-signature-label="Signature of ${escA(s.full_name)}" data-signature-tint-key="${escA(s.qid||s.full_name)}"></span>
    <div class="nm">${s.full_name}</div><div class="cat">${s.category||''}</div></div>`).join('');
  grid.querySelectorAll('.card').forEach(c=>c.onclick=()=>openProduct(c.dataset.qid));
}

function openProduct(qid){
  const s = state.data.signatures.find(x=>x.qid===qid); if(!s) return;
  state.sig = s;
  state.inkColor = null;                    // each signer starts on Auto (its own color)
  state.garment = state.data.garments[0];
  state.color = state.garment.colors[0];
  state.size = state.garment.sizes[Math.floor(state.garment.sizes.length/2)] || state.garment.sizes[0];
  document.getElementById('pName').textContent = s.full_name;
  document.getElementById('pCat').textContent = s.category||'';
  document.getElementById('email').value = '';
  document.getElementById('msg').textContent = '';
  buildOptions();
  applyLifestyle();
  loadSigThenDraw();
  document.getElementById('scrim').classList.add('on');
}

function buildOptions(){
  const G = document.getElementById('garments'), C = document.getElementById('colors'), Z = document.getElementById('sizes');
  G.innerHTML = state.data.garments.map(g=>`<span class="chip ${g.id===state.garment.id?'sel':''}" data-g="${g.id}">${g.label}</span>`).join('');
  G.querySelectorAll('.chip').forEach(el=>el.onclick=()=>{
    // Re-resolve to the NEW garment's own color object even when the id
    // matches (e.g. both offer "white") — each garment's color now carries
    // its own real product photo, so reusing the old garment's color object
    // silently showed the wrong garment's photo.
    state.garment = state.data.garments.find(g=>g.id===el.dataset.g);
    state.color = state.garment.colors.find(c=>c.id===state.color.id) || state.garment.colors[0];
    if(!state.garment.sizes.includes(state.size)) state.size=state.garment.sizes[0];
    buildOptions(); draw();
  });
  C.innerHTML = state.garment.colors.map(c=>`<span class="swatch ${c.id===state.color.id?'sel':''}" data-c="${c.id}" title="${c.label}" style="background:${c.hex}"></span>`).join('');
  C.querySelectorAll('.swatch').forEach(el=>el.onclick=()=>{ state.color = state.garment.colors.find(c=>c.id===el.dataset.c); buildOptions(); draw(); });
  buildInkChips();
  Z.innerHTML = state.garment.sizes.map(z=>`<span class="chip ${z===state.size?'sel':''}" data-z="${z}">${z}</span>`).join('');
  Z.querySelectorAll('.chip').forEach(el=>el.onclick=()=>{ state.size=el.dataset.z; buildOptions(); });
  const _up = (state.garment.sizeUpchargeUsd && state.garment.sizeUpchargeUsd[state.size]) || 0;
  const held = state.garment.saleEnabled === false;
  const live = !!state.data.disclosure.purchasable;
  document.getElementById('price').textContent = held ? 'Coming soon' : '$'+(state.garment.priceUsd + _up).toFixed(2) + (_up ? ` (${state.size} +$${_up})` : '');
  document.getElementById('sizeNote').textContent = state.garment.sizeNote || '';
  const buy = document.getElementById('buy'); buy.disabled = held;
  buy.textContent = held ? 'Coming soon' : live ? 'Add to cart' : 'Notify me when live';
  document.getElementById('email').closest('.row').style.display = live || held ? 'none' : '';
  document.getElementById('productNote').textContent = (state.garment.placementLabel || 'Signature placed at the left chest.') +
    (held ? ' Placement preview — this product is coming soon.' : live ? ' Made to order by our print partner.' : ' Apparel sales open soon.');
}

let sigImg = null;
function loadSigThenDraw(){
  sigImg = new Image();
  // Wikimedia serves permissive CORS (access-control-allow-origin: *) — this
  // is required so the 3D decal canvas (which draws sigImg then gets read
  // back as a WebGL texture) isn't "tainted" by a cross-origin image. Has no
  // effect on the 2D <canvas> path, which never reads pixels back.
  sigImg.crossOrigin = 'anonymous';
  sigImg.onload = draw; sigImg.onerror = ()=>{ sigImg=null; draw(); };
  sigImg.src = state.sig.signature_image_url;
}

// Perceived luminance (0=black, 1=white) — decides whether 'multiply' (needs a
// light ground) or a recolored-to-light rendition (for a dark ground) is used.
function luminance(hex){
  const n = parseInt(hex.slice(1),16), r=(n>>16)&255, g=(n>>8)&255, b=n&255;
  return (0.299*r + 0.587*g + 0.114*b) / 255;
}
// Recolor ONLY the ink strokes to a solid color on a TRANSPARENT ground. It
// thresholds each pixel (dark = ink → colored + opaque; light = background →
// transparent) so a white-background source becomes colored strokes, NOT a
// solid colored rectangle (the old source-in fill painted every opaque pixel,
// which is what turned white-bg signatures into a green box). Only used when the
// buyer picks a color or on dark cloth; the default keeps the original art.
function recolorInk(img, hex){
  const w = img.width || img.naturalWidth || 300, h = img.height || img.naturalHeight || 150;
  const off = document.createElement('canvas'); off.width = w; off.height = h;
  const octx = off.getContext('2d', { willReadFrequently:true });
  octx.drawImage(img, 0, 0, w, h);
  let id; try { id = octx.getImageData(0, 0, w, h); } catch { return img; } // tainted → keep original
  const d = id.data, n = parseInt(hex.slice(1),16), R=(n>>16)&255, G=(n>>8)&255, B=n&255;
  for (let i = 0; i < d.length; i += 4){
    const a = d[i+3]/255;
    const lum = (0.299*d[i] + 0.587*d[i+1] + 0.114*d[i+2]) * a + 255*(1-a); // as seen over white
    if (lum < 160){ d[i]=R; d[i+1]=G; d[i+2]=B; d[i+3]=255; }               // ink → chosen color
    else { d[i+3] = 0; }                                                    // background → transparent
  }
  octx.putImageData(id, 0, 0);
  return off;
}
// Knock out a near-white/transparent background to transparent while KEEPING the
// ink's original colors — for the 3D decal, whose alpha-tested texture needs a
// transparent ground (it can't lean on the 2D 'multiply' trick) but must still
// show the signature in its true source colors.
function knockoutWhite(img){
  const w = img.width || img.naturalWidth || 300, h = img.height || img.naturalHeight || 150;
  const off = document.createElement('canvas'); off.width = w; off.height = h;
  const octx = off.getContext('2d', { willReadFrequently:true });
  octx.drawImage(img, 0, 0, w, h);
  let id; try { id = octx.getImageData(0, 0, w, h); } catch { return img; }
  const d = id.data;
  for (let i = 0; i < d.length; i += 4){
    const lum = 0.299*d[i] + 0.587*d[i+1] + 0.114*d[i+2];
    if (d[i+3] < 10 || lum > 210) d[i+3] = 0;                  // transparent / near-white → transparent
  }
  octx.putImageData(id, 0, 0);
  return off;
}
// The ink color for the CURRENT signature on garment color `col`:
//   • buyer picked a color  → that color
//   • dark cloth, no pick    → white (a dark signature would vanish on black)
//   • light cloth, no pick   → null = keep the ORIGINAL art (true source colors,
//                              composited via 'multiply' so the white ground
//                              drops out and fabric shading still reads)
function inkHexFor(col){
  if (state.inkColor) return state.inkColor;
  if (luminance(col.hex) < 0.4) return '#ffffff';
  return null;
}
// Render the SIGNATURE INK chips: Original (the signature's own colors) + curated
// presets + Custom…, reflecting the current state.inkColor selection.
function buildInkChips(){
  const I = document.getElementById('inkChips'); if (!I) return;
  const cur = state.inkColor;
  const isPreset = cur && INK_PRESETS.some(p=>p.hex.toLowerCase()===cur.toLowerCase());
  let html = `<span class="chip ink-original ${cur===null?'sel':''}" data-ink="auto" title="Original — the signature's own colors">Original</span>`;
  html += INK_PRESETS.map(p=>`<span class="swatch ${cur&&cur.toLowerCase()===p.hex.toLowerCase()?'sel':''}" data-ink="${p.hex}" title="${p.label}" style="background:${p.hex}"></span>`).join('');
  html += `<span class="chip ink-custom ${cur&&!isPreset?'sel':''}" data-ink="custom">${cur&&!isPreset?cur.toUpperCase():'Custom…'}</span>`;
  I.innerHTML = html;
  I.querySelectorAll('[data-ink]').forEach(el=>el.onclick=()=>{
    const v = el.dataset.ink;
    if (v==='auto'){ state.inkColor=null; buildOptions(); draw(); }
    else if (v==='custom'){ const p=document.getElementById('inkCustom'); p.value = (state.inkColor||'#1a1a1a'); p.click(); }
    else { state.inkColor=v; buildOptions(); draw(); }
  });
  const picker=document.getElementById('inkCustom');
  if (picker && !picker._wired){ picker._wired=true; picker.oninput=()=>{ state.inkColor=picker.value; buildOptions(); draw(); }; }
}

// ---- 3D viewer (Three.js) — classic-tee only ----------------------------
// Free MIT-licensed crew-neck tee model (Starklord17/threejs-t-shirt, itself
// the well-known JS Mastery "3D T-shirt customizer" tutorial asset by
// Anderson Mancini + Paul Henschel). It's a generic crew-neck shape, so it's
// only offered for classic-tee — showing it for the polo or pocket-tee would
// render the wrong garment silhouette, which is worse than no 3D view at all.
const THREE3D = { loaded:false, THREE:null, scene:null, camera:null, renderer:null, controls:null, mesh:null, decalMesh:null, DecalGeometryCtor:null };

async function ensureThree3D(){
  if (THREE3D.loaded) return;
  const [THREE, { GLTFLoader }, { OrbitControls }, { DecalGeometry }] = await Promise.all([
    import('three'),
    import('three/addons/loaders/GLTFLoader.js'),
    import('three/addons/controls/OrbitControls.js'),
    import('three/addons/geometries/DecalGeometry.js'),
  ]);
  const el = document.getElementById('stage3d');
  const size = 340;
  const renderer = new THREE.WebGLRenderer({ antialias:true, alpha:true });
  renderer.setSize(size, size); renderer.setPixelRatio(Math.min(devicePixelRatio||1, 2));
  el.appendChild(renderer.domElement);
  const scene = new THREE.Scene();
  const camera = new THREE.PerspectiveCamera(35, 1, 0.1, 100);
  camera.position.set(0, 0, 2.4);
  scene.add(new THREE.AmbientLight(0xffffff, 1.1));
  const key = new THREE.DirectionalLight(0xffffff, 0.9); key.position.set(1, 1, 2); scene.add(key);
  const fill = new THREE.DirectionalLight(0xffffff, 0.4); fill.position.set(-1, 0.5, -1); scene.add(fill);
  const controls = new OrbitControls(camera, renderer.domElement);
  controls.enablePan = false; controls.minDistance = 1.6; controls.maxDistance = 3.2;
  controls.enableDamping = true;
  const gltf = await new GLTFLoader().loadAsync('/assets/models/shirt_baked.glb');
  const mesh = gltf.scene.getObjectByProperty('type', 'SkinnedMesh') || gltf.scene.getObjectByProperty('type', 'Mesh');
  mesh.material = mesh.material.clone();
  scene.add(gltf.scene);
  (function frameLoop(){ requestAnimationFrame(frameLoop); controls.update(); renderer.render(scene, camera); })();
  Object.assign(THREE3D, { loaded:true, THREE, scene, camera, renderer, controls, mesh, DecalGeometryCtor: DecalGeometry });
}

// Rebuilds the color + signature decal on the 3D shirt to match current state.
function update3DGarment(){
  if (!THREE3D.loaded) return;
  const { THREE, scene, mesh, DecalGeometryCtor } = THREE3D;
  mesh.material.color.set(state.color.hex);
  if (THREE3D.decalMesh) { scene.remove(THREE3D.decalMesh); THREE3D.decalMesh.geometry.dispose(); THREE3D.decalMesh.material.dispose(); THREE3D.decalMesh = null; }
  if (!(sigImg && sigImg.width)) return;
  // Draw the signature onto a canvas (same multiply-vs-recolor logic as the
  // 2D photo view) so the decal texture already carries the right ink color
  // and has a transparent ground for DecalGeometry's alpha-tested material.
  const cnv = document.createElement('canvas'); cnv.width = 512; cnv.height = 512;
  const cctx = cnv.getContext('2d');
  const ar = sigImg.width/sigImg.height; let w = 420, h = w/ar; if (h > 200) { h = 200; w = h*ar; }
  const _ink3d = inkHexFor(state.color);
  const drawSrc = _ink3d ? recolorInk(sigImg, _ink3d) : knockoutWhite(sigImg);  // original art on transparent, else recolor
  cctx.drawImage(drawSrc, (512-w)/2, (512-h)/2, w, h);
  const decalTexture = new THREE.CanvasTexture(cnv);
  const decalMaterial = new THREE.MeshStandardMaterial({
    map: decalTexture, transparent: true, depthTest: true, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -4,
  });
  // Standard chest placement for this exact model (position/rotation/scale
  // are this tutorial asset's well-established defaults for a front decal).
  const position = new THREE.Vector3(0, 0.04, 0.15);
  const rotation = new THREE.Euler(0, 0, 0);
  const size = new THREE.Vector3(0.15, 0.15, 0.15);
  const decalGeom = new DecalGeometryCtor(mesh, position, rotation, size);
  const decalMesh = new THREE.Mesh(decalGeom, decalMaterial);
  scene.add(decalMesh);
  THREE3D.decalMesh = decalMesh;
}

let in3DMode = false;
document.getElementById('view3dToggle').onclick = async ()=>{
  const btn = document.getElementById('view3dToggle');
  if (!in3DMode){
    btn.textContent = 'Loading 3D…'; btn.disabled = true;
    try { await ensureThree3D(); update3DGarment(); }
    catch(e){ console.error('3D view failed', e); btn.textContent = 'View in 3D'; btn.disabled = false; return; }
    document.getElementById('stage').style.display = 'none';
    document.getElementById('stage3d').style.display = 'block';
    btn.textContent = 'View photo'; btn.disabled = false; in3DMode = true;
  } else {
    document.getElementById('stage3d').style.display = 'none';
    document.getElementById('stage').style.display = '';
    btn.textContent = 'View in 3D'; in3DMode = false;
  }
};

// Real garment photos are loaded once per URL and cached — switching sizes
// (which doesn't change the photo) never re-fetches.
const photoCache = {};
function loadPhoto(src, cb){
  if (photoCache[src] && photoCache[src].complete) { cb(photoCache[src]); return; }
  const img = photoCache[src] || (photoCache[src] = new Image());
  img.onload = () => cb(img);
  img.onerror = () => cb(null);
  if (!img.src) img.src = src;
}

// Composites the signature onto ctx for every overlay box on garment g,
// color col — shared by the main draw() (tx=0,ty=0,s=1) and the zoom lens
// (tx/ty = -crop origin * s, s = zoom factor) so the lens paints FRESH from
// the original source images at the zoomed target size instead of CSS-
// stretching an already-rasterized bitmap (which is what made the first
// hover-zoom pass blurry — canvas is a raster surface, so scaling the
// finished bitmap via CSS transform just magnifies its existing pixels).
function paintSignatures(ctx, g, col, tx, ty, s){
  if (!(sigImg && sigImg.width)) return;
  for (const ob of g.overlayBoxes || [g.overlayBox]) {
    if (!ob) continue;
    const pad = ob.w * 0.08, bw = ob.w - 2*pad, bh = ob.h - 2*pad;
    const ar = sigImg.width/sigImg.height; let w=bw, h=w/ar; if(h>bh){ h=bh; w=h*ar; }
    const x = ob.x + (ob.w-w)/2, y = ob.y + (ob.h-h)/2;
    const cx = ob.x + ob.w/2, cy = ob.y + ob.h/2;
    const ink = inkHexFor(col);
    // null = original art (multiply on light cloth drops the white ground);
    // a chosen/white ink = clean recolor on transparent (source-over).
    const src = ink ? recolorInk(sigImg, ink) : sigImg;
    ctx.save();
    ctx.translate(tx, ty); ctx.scale(s, s);
    if (ob.rotation) { ctx.translate(cx, cy); ctx.rotate(ob.rotation * Math.PI / 180); ctx.translate(-cx, -cy); }
    if (!ink) ctx.globalCompositeOperation = 'multiply';
    if (ob.curve) {
      // Cap crowns are domed, not flat — a plain rectangle overlay reads as
      // a pasted sticker instead of a real DTF/embroidery print that would
      // visibly follow the fabric's roll (graphic-design review, TK-10286,
      // 2026-09-09). No canvas mesh-warp API exists, so this slices the
      // signature into thin vertical strips and bends each one vertically
      // along a parabola — enough to sell the curve at normal preview size
      // without a real 3D pipeline. ob.curve = peak vertical offset in px.
      const STRIPS = 28;
      for (let i = 0; i < STRIPS; i++) {
        const t0 = i / STRIPS, t1 = (i + 1) / STRIPS;
        const sx0 = t0 * src.width, sx1 = t1 * src.width;
        const dx0 = x + t0 * w, dx1 = x + t1 * w;
        const mid = (t0 + t1) / 2 - 0.5; // -0.5..0.5 across the strip
        const yOff = ob.curve * (1 - Math.pow(mid * 2, 2)); // parabola, peak at center
        ctx.drawImage(src, sx0, 0, Math.max(1, sx1 - sx0), src.height, dx0, y + yOff, (dx1 - dx0) + 0.5, h);
      }
    } else {
      ctx.drawImage(src, x, y, w, h);
    }
    ctx.restore();
  }
}

// ---- all-over-print (AOP) render ----
// Cache of garment silhouette masks, keyed by photo URL. An AOP garment tiles
// the signature edge-to-edge; we key out the flat photo's plain backdrop so the
// tiled script lands ONLY on fabric (body + sleeves + collar), never the ground.
const _maskCache = {};
function garmentMask(photoImg, w, h, key){
  if (_maskCache[key]) return _maskCache[key];
  const oc = document.createElement('canvas'); oc.width = w; oc.height = h;
  const octx = oc.getContext('2d'); octx.drawImage(photoImg, 0, 0, w, h);
  const id = octx.getImageData(0, 0, w, h), d = id.data;
  let br=0, bg=0, bb=0;                     // backdrop = mean of the 4 corners
  for (const [cx,cy] of [[0,0],[w-1,0],[0,h-1],[w-1,h-1]]){ const o=(cy*w+cx)*4; br+=d[o]; bg+=d[o+1]; bb+=d[o+2]; }
  br/=4; bg/=4; bb/=4;
  // threshold well above the soft drop-shadow (shirt-vs-backdrop distance ~220)
  let m = new Uint8Array(w*h);
  for (let p=0,i=0;p<w*h;p++,i+=4){
    m[p] = (Math.abs(d[i]-br)+Math.abs(d[i+1]-bg)+Math.abs(d[i+2]-bb)) > 80 ? 1 : 0;
  }
  // erode 3px to shave the anti-aliased halo so no ink lands off-fabric
  for (let it=0; it<3; it++){
    const e = m.slice();
    for (let y=1;y<h-1;y++) for (let x=1;x<w-1;x++){ const o=y*w+x;
      if (m[o] && !(m[o-1]&&m[o+1]&&m[o-w]&&m[o+w])) e[o]=0; }
    m = e;
  }
  for (let p=0,i=0;p<w*h;p++,i+=4){ d[i]=d[i+1]=d[i+2]=0; d[i+3]=m[p]?255:0; }
  octx.putImageData(id, 0, 0); _maskCache[key] = oc; return oc;
}
// Tiles the signature across the whole garment, tone-on-tone, clipped to the
// silhouette. Shared by draw() (tx0/ty0/s1) and the zoom lens.
function paintAllover(ctx, g, col, photoImg, tx, ty, s){
  if (!(sigImg && sigImg.width) || !photoImg) return;
  const W = ctx.canvas.width, H = ctx.canvas.height;
  const ink = inkHexFor(col);
  const src = ink ? recolorInk(sigImg, ink) : sigImg;   // null = original art
  const tileW = Math.round(W * (g.alloverScale || 0.20));
  const tileH = Math.round(tileW * (src.height / src.width));
  const stepX = tileW + tileW*0.35, stepY = tileH + tileH*1.6;   // brick spacing
  const layer = document.createElement('canvas'); layer.width = W; layer.height = H;
  const lx = layer.getContext('2d');
  lx.globalAlpha = g.alloverOpacity || 0.5;
  let row = 0;
  for (let y=-tileH; y<H+tileH; y+=stepY, row++){
    const off = (row % 2) ? stepX/2 : 0;                          // staggered rows
    for (let x=-tileW+off; x<W+tileW; x+=stepX) lx.drawImage(src, x, y, tileW, tileH);
  }
  lx.globalAlpha = 1; lx.globalCompositeOperation = 'destination-in';
  lx.drawImage(garmentMask(photoImg, W, H, col.photo), 0, 0);     // clip to shirt
  ctx.save(); ctx.translate(tx, ty); ctx.scale(s, s);
  if (!ink) ctx.globalCompositeOperation = 'multiply';            // original art: tone-on-tone
  ctx.drawImage(layer, 0, 0); ctx.restore();
}

function draw(){
  const cv = document.getElementById('stage'), ctx = cv.getContext('2d');
  const g = state.garment, col = state.color;
  // The free 3D model is a generic crew-neck shape — only correct for
  // classic-tee. Hide the toggle (and fall back to the photo) for garments
  // it doesn't actually match, rather than show the wrong silhouette.
  const has3D = g.id === 'classic-tee';
  const toggleBtn = document.getElementById('view3dToggle');
  toggleBtn.hidden = !has3D;
  if (!has3D && in3DMode){ toggleBtn.onclick(); } // forces back to the photo view
  if (has3D && in3DMode) update3DGarment();
  cv.width = g.photoWidth; cv.height = g.photoHeight;
  loadPhoto(col.photo, photoImg => {
    // A late photo must not repaint a different product selected meanwhile.
    if (state.garment !== g || state.color !== col) return;
    ctx.clearRect(0, 0, cv.width, cv.height);
    if (photoImg) ctx.drawImage(photoImg, 0, 0, cv.width, cv.height);
    // Light cloth: 'multiply' so the dark ink reads naturally against the
    // photo (fabric shading still shows through, same as a real print).
    // Dark cloth: multiply would render invisible (it can only darken), so
    // recolor the signature to a light ink and composite normally instead.
    if (g.allover) paintAllover(ctx, g, col, photoImg, 0, 0, 1);   // AOP: tile edge-to-edge
    else paintSignatures(ctx, g, col, 0, 0, 1);                    // single centered decal
  });
}

// Hover 3x magnifier LENS on the signature only (2026-09-09, v2 — the first
// pass CSS-transform-scaled the whole already-rendered canvas, which just
// blows up its existing pixels and looks blurry). This version only
// activates when the cursor is actually over the printed signature (not the
// whole garment photo), and repaints a small lens canvas FRESH from the
// original photo + signature source images at the zoomed resolution, so it
// stays as crisp as those source images allow instead of stretching a
// bitmap. Lens backing store is 2x its CSS size for extra sharpness on
// retina displays.
(function initStageZoom(){
  const cv = document.getElementById('stage');
  const hint = document.getElementById('zoomHint');
  const lens = document.getElementById('zoomLens');
  const lctx = lens.getContext('2d');
  const MAX_ZOOM = 3, LENS_CSS = 220, LENS_BACK = LENS_CSS * 2;
  lens.width = LENS_BACK; lens.height = LENS_BACK;

  function overlayHitRect(cvRect){
    // Screen-space rect(s) of the garment's signature overlay box(es),
    // padded generously so the zoom is easy to trigger without pixel-hunting.
    const g = state.garment; if (!g) return [];
    const scaleX = cvRect.width / cv.width, scaleY = cvRect.height / cv.height;
    return (g.overlayBoxes || [g.overlayBox]).filter(Boolean).map(ob => {
      const padX = ob.w * 0.5, padY = ob.h * 0.9;
      return {
        left: cvRect.left + (ob.x - padX) * scaleX, top: cvRect.top + (ob.y - padY) * scaleY,
        right: cvRect.left + (ob.x + ob.w + padX) * scaleX, bottom: cvRect.top + (ob.y + ob.h + padY) * scaleY,
        ob,
      };
    });
  }

  function paintLens(ob){
    // Locks the crop to the SIGNATURE's own center (not the raw cursor
    // position) — once you're hovering anywhere in its hit-zone, the lens
    // always shows the full signature centered, never clipped at an edge.
    const g = state.garment, col = state.color;
    const photoImg = photoCache[col.photo];
    lctx.clearRect(0, 0, lens.width, lens.height);
    lctx.imageSmoothingEnabled = true; lctx.imageSmoothingQuality = 'high';
    // Zoom as much as MAX_ZOOM allows without clipping the box (padded 15%)
    // out of frame — a wide signature on a narrow box gets a lower zoom so
    // the whole thing stays visible instead of running off the lens edge.
    const zoom = Math.min(MAX_ZOOM, lens.width / (ob.w * 1.15), lens.height / (ob.h * 1.15));
    const sampleW = lens.width / zoom, sampleH = lens.height / zoom;
    let sx = (ob.x + ob.w/2) - sampleW/2, sy = (ob.y + ob.h/2) - sampleH/2;
    sx = Math.max(0, Math.min(cv.width - sampleW, sx));
    sy = Math.max(0, Math.min(cv.height - sampleH, sy));
    if (photoImg && photoImg.complete) lctx.drawImage(photoImg, sx, sy, sampleW, sampleH, 0, 0, lens.width, lens.height);
    paintSignatures(lctx, g, col, -sx * zoom, -sy * zoom, zoom);
  }

  function onMove(e){
    if (in3DMode) { hide(); return; }
    const cvRect = cv.getBoundingClientRect();
    const hits = overlayHitRect(cvRect);
    const hit = hits.find(h => e.clientX >= h.left && e.clientX <= h.right && e.clientY >= h.top && e.clientY <= h.bottom);
    if (!hit) { hide(); return; }
    if (hint) hint.classList.add('hide');
    paintLens(hit.ob);
    const prect = cv.closest('.preview').getBoundingClientRect();
    let lx = e.clientX - prect.left - LENS_CSS/2, ly = e.clientY - prect.top - LENS_CSS - 16;
    lx = Math.max(4, Math.min(prect.width - LENS_CSS - 4, lx));
    ly = Math.max(4, ly < 4 ? e.clientY - prect.top + 16 : ly);
    lens.style.left = lx + 'px'; lens.style.top = ly + 'px';
    lens.style.display = 'block';
  }
  function hide(){ lens.style.display = 'none'; if (hint) hint.classList.remove('hide'); }

  cv.addEventListener('mousemove', onMove);
  cv.addEventListener('mouseleave', hide);
})();

document.getElementById('sort').onchange = e => { localStorage.setItem('wear_sort', e.target.value); render(); };

// ---- search + typeahead over the signature names ----
(function initSearch(){
  const input = document.getElementById('sigSearch');
  const clear = document.getElementById('sigClear');
  const ac    = document.getElementById('ac');
  let items = [], active = -1;

  const esc = t => String(t).replace(/[&<>]/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
  const hl  = (name, q) => { // bold the matched substring
    const i = name.toLowerCase().indexOf(q); if (i<0) return esc(name);
    return esc(name.slice(0,i))+'<b>'+esc(name.slice(i,i+q.length))+'</b>'+esc(name.slice(i+q.length));
  };
  function closeAc(){ ac.style.display='none'; input.setAttribute('aria-expanded','false'); active=-1; items=[]; }
  function suggest(q){
    const all = state.data && state.data.signatures || [];
    const scored = [];
    for (const s of all){
      const n = (s.full_name||'').toLowerCase(); const i = n.indexOf(q);
      if (i<0) continue;
      // prefix / word-start matches rank first, then earliest position
      const wordStart = i===0 || n[i-1]===' ';
      scored.push({ s, rank: (i===0?0:wordStart?1:2), pos:i });
      if (scored.length>400) break; // cap the scan work
    }
    scored.sort((a,b)=> a.rank-b.rank || a.pos-b.pos || a.s.full_name.localeCompare(b.s.full_name));
    return scored.slice(0,8).map(x=>x.s);
  }
  function openAc(q){
    items = suggest(q);
    if (!items.length){ closeAc(); return; }
    ac.innerHTML = items.map((s,i)=>`<div class="ac-item${i===active?' active':''}" role="option" data-qid="${s.qid}">
      <span class="signature-preview" data-signature-src="${s.signature_image_url}" data-signature-label="Signature of ${esc(s.full_name||'')}" data-signature-tint-key="${esc(s.qid||s.full_name||'')}"></span>
      <span class="ac-nm">${hl(s.full_name||'', q)}</span><span class="ac-cat">${esc(s.category||'')}</span></div>`).join('');
    ac.style.display='block'; input.setAttribute('aria-expanded','true');
    ac.querySelectorAll('.ac-item').forEach(el=>{
      el.onmousedown = ev => { ev.preventDefault(); pick(el.dataset.qid); };
    });
  }
  function pick(qid){ closeAc(); input.blur(); openProduct(qid); }
  function onType(){
    const raw = input.value; state.q = raw; clear.style.display = raw ? 'block':'none';
    const q = raw.trim().toLowerCase();
    render();                       // live-filter the grid
    if (q) openAc(q); else closeAc();
  }
  input.addEventListener('input', onType);
  input.addEventListener('focus', ()=>{ const q=input.value.trim().toLowerCase(); if(q) openAc(q); });
  input.addEventListener('blur',  ()=> setTimeout(closeAc,120));
  input.addEventListener('keydown', e=>{
    if (ac.style.display!=='block') return;
    if (e.key==='ArrowDown'){ e.preventDefault(); active=Math.min(active+1,items.length-1); paintActive(); }
    else if (e.key==='ArrowUp'){ e.preventDefault(); active=Math.max(active-1,0); paintActive(); }
    else if (e.key==='Enter'){ e.preventDefault(); const s=items[active>=0?active:0]; if(s) pick(s.qid); }
    else if (e.key==='Escape'){ closeAc(); }
  });
  function paintActive(){
    ac.querySelectorAll('.ac-item').forEach((el,i)=>el.classList.toggle('active',i===active));
    const el = ac.querySelector('.ac-item.active'); if(el) el.scrollIntoView({block:'nearest'});
  }
  clear.onclick = ()=>{ input.value=''; state.q=''; clear.style.display='none'; closeAc(); render(); input.focus(); };
})();
document.getElementById('density').oninput = e => { document.documentElement.style.setProperty('--cols', e.target.value); localStorage.setItem('wear_cols', e.target.value); };
document.getElementById('close').onclick = ()=>document.getElementById('scrim').classList.remove('on');
document.getElementById('scrim').onclick = e => { if(e.target.id==='scrim') document.getElementById('scrim').classList.remove('on'); };

document.getElementById('lifestyleToggle').onclick = ()=> setLifeView(state.lifeMode === 'worn' ? 'garment' : 'worn');
document.getElementById('buy').onclick = async ()=>{
  if (state.garment?.saleEnabled === false) return;
  const live = !!(state.data && state.data.disclosure && state.data.disclosure.purchasable);
  const msg = document.getElementById('msg'); msg.style.color='#c0392b';
  const btn = document.getElementById('buy');
  if (live){
    // Sales are live: add the current selection to the cart — no email needed
    // yet, that's collected once at cart checkout, not per item.
    addToCart({
      qid: state.sig.qid, signature_name: state.sig.full_name,
      garment: state.garment.id, garment_label: state.garment.label,
      color: state.color.id, color_label: state.color.label, size: state.size,
      inkColor: state.inkColor,
      unitPriceUsd: cartUnitPrice(state.garment, state.size), thumb: state.sig.signature_image_url,
    });
    msg.style.color='#2e7d32'; msg.textContent = 'Added to cart ✓';
    const addedGarment = state.garment;
    btn.textContent = 'Added ✓'; setTimeout(()=>{ if(state.garment === addedGarment) buildOptions(); }, 1200);
    return;
  }
  // Not live yet: keep the existing per-signature "notify me" email capture.
  const email = document.getElementById('email').value.trim();
  if(!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)){ msg.textContent='Enter a valid email.'; return; }
  btn.disabled=true; btn.textContent = 'Saving…';
  try{
    const r = await fetch('/api/wear-checkout',{method:'POST',headers:{'Content-Type':'application/json'},
      body:JSON.stringify({ qid:state.sig.qid, garment:state.garment.id, color:state.color.id, size:state.size, email })}).then(r=>r.json());
    if(r.comingSoon){ msg.style.color='#2e7d32'; msg.textContent = `You're on the list — we'll email ${email} the moment ${state.sig.full_name} apparel goes live.`; btn.textContent='On the list ✓'; return; }
    msg.textContent = r.error || 'Checkout unavailable.';
  }catch(e){ msg.textContent='Network error.'; }
  btn.disabled=false; btn.textContent = 'Notify me when live';
};

// restore density + sort prefs
const dc = localStorage.getItem('wear_cols'); if(dc){ document.getElementById('density').value=dc; document.documentElement.style.setProperty('--cols', dc); }
const sp = localStorage.getItem('wear_sort'); if(sp){ document.getElementById('sort').value=sp; }
boot();
</script>
</body>
</html>