← 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=>({'&':'&','<':'<','>':'>','"':'"'}[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,'<')}”.`
: '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=>({'&':'&','<':'<','>':'>'}[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>