← back to Wallco Ai
public/admin/styleguides.html
541 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Brand Selector · Style Guides — wallco.ai</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;1,400;1,500&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root{
--bg:#0a0a0e; --bg2:#0d0d12; --card:#121218; --card-hi:#16161d;
--line:#26262f; --line-soft:#1d1d25;
--ink:#F4F4F5; --ink-soft:#b6b6c0; --muted:#8a8a96; --faint:#6a6a76;
--gold:#E4C25A; --gold-soft:#caa84a;
--serif:"Cormorant Garamond","Didot","Times New Roman",serif;
--sans:"Inter",system-ui,-apple-system,sans-serif;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{background:var(--bg);color:var(--ink)}
body{font:15px/1.5 var(--sans);-webkit-font-smoothing:antialiased;padding-bottom:96px}
a{color:inherit}
.wrap{max-width:1312px;margin:0 auto;padding:0 28px}
/* ── Header ── */
header{border-bottom:1px solid var(--line-soft);background:linear-gradient(180deg,#0c0c11,#0a0a0e);padding:42px 0 30px}
.eyebrow{display:flex;align-items:center;gap:9px;font:600 11px var(--sans);letter-spacing:.22em;text-transform:uppercase;color:var(--muted);margin-bottom:14px}
.eyebrow .dot{width:7px;height:7px;border-radius:50%;background:var(--gold);box-shadow:0 0 10px var(--gold)}
.head-row{display:flex;justify-content:space-between;align-items:flex-end;gap:24px;flex-wrap:wrap}
h1{font:500 64px/0.95 var(--serif);letter-spacing:-.01em}
.lede{font:italic 400 18px/1.4 var(--serif);color:var(--ink-soft);margin-top:12px;max-width:680px}
.counts{display:flex;gap:40px}
.counts .c .n{font:500 46px/1 var(--serif);color:var(--gold)}
.counts .c .l{font:600 10.5px var(--sans);letter-spacing:.2em;text-transform:uppercase;color:var(--muted);margin-top:6px}
/* ── Section ── */
section{padding:44px 0 8px}
.sec-top{display:flex;justify-content:space-between;align-items:flex-end;gap:18px;flex-wrap:wrap;margin-bottom:26px}
.sec-no{font:600 11px var(--sans);letter-spacing:.24em;color:var(--faint);margin-bottom:8px}
.sec-h{font:500 38px/1 var(--serif)}
.sec-sub{font:italic 400 14.5px var(--serif);color:var(--muted);margin-top:6px}
.controls{display:flex;gap:10px;align-items:center}
.ctl{font:500 12px var(--sans);color:var(--ink-soft);background:transparent;border:1px solid var(--line);border-radius:999px;padding:8px 16px;cursor:pointer;transition:.15s}
.ctl:hover{border-color:var(--muted);color:var(--ink)}
.ctl.gold{border-color:var(--gold-soft);color:var(--gold)}
.ctl.gold:hover{background:rgba(228,194,90,.08)}
/* ── Grid ── */
.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:18px}
@media(max-width:1100px){.grid{grid-template-columns:repeat(2,1fr)}}
@media(max-width:640px){.grid{grid-template-columns:1fr}}
.card{position:relative;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:22px 22px 20px;min-height:150px;cursor:pointer;transition:.16s;user-select:none}
.card:hover{background:var(--card-hi);border-color:#34343f;transform:translateY(-1px)}
.card.sel{border-color:var(--gold);background:#14130f;box-shadow:0 0 0 1px var(--gold),0 8px 30px rgba(228,194,90,.07)}
.card .name{font:400 21px/1.1 var(--serif);color:var(--ink);padding-right:34px}
.card .meta{display:flex;gap:6px;margin-top:13px}
.chip{border:1px solid var(--line);border-radius:999px;padding:3px 9px;font:500 9.5px var(--sans);letter-spacing:.12em;text-transform:uppercase;color:var(--muted)}
.dots{display:flex;gap:6px;margin-top:16px}
.dot{width:17px;height:17px;border-radius:5px;border:1px solid rgba(255,255,255,.08)}
.tick{position:absolute;top:16px;right:16px;width:22px;height:22px;border-radius:50%;border:1.5px solid var(--line);display:flex;align-items:center;justify-content:center;transition:.15s}
.card.sel .tick{background:var(--gold);border-color:var(--gold)}
.tick svg{opacity:0;transition:.15s;width:12px;height:12px}
.card.sel .tick svg{opacity:1}
/* ── Footer bar ── */
.bar{position:fixed;left:0;right:0;bottom:0;z-index:40;background:rgba(10,10,14,.92);backdrop-filter:blur(14px);border-top:1px solid var(--line)}
.bar-in{max-width:1312px;margin:0 auto;padding:14px 28px;display:flex;align-items:center;gap:16px}
.selpill{font:600 11.5px var(--sans);letter-spacing:.04em;color:var(--ink);background:#1b1b22;border:1px solid var(--line);border-radius:999px;padding:8px 15px;white-space:nowrap}
.selnames{font:13px var(--sans);color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
.bar .actions{display:flex;gap:10px;align-items:center;margin-left:auto}
.btn{font:600 12.5px var(--sans);border-radius:999px;padding:11px 20px;cursor:pointer;border:1px solid var(--line);background:transparent;color:var(--ink-soft);transition:.15s;white-space:nowrap}
.btn:hover{border-color:var(--muted);color:var(--ink)}
.btn.primary{background:var(--gold);color:#1a1505;border-color:var(--gold);font-weight:600}
.btn.primary:hover{background:#eccd6e}
.btn:disabled{opacity:.4;cursor:not-allowed}
/* ── Modal ── */
.scrim{position:fixed;inset:0;z-index:60;background:rgba(0,0,0,.66);display:none;align-items:center;justify-content:center;padding:32px}
.scrim.on{display:flex}
.modal{background:var(--bg2);border:1px solid var(--line);border-radius:16px;max-width:780px;width:100%;max-height:84vh;overflow:auto;padding:28px}
.modal h3{font:500 26px var(--serif);margin-bottom:4px}
.modal .mh-sub{color:var(--muted);font-size:13px;margin-bottom:18px}
pre{background:#08080b;border:1px solid var(--line);border-radius:10px;padding:18px;font:12.5px/1.6 ui-monospace,Menlo,monospace;color:#cfcfd8;overflow:auto;white-space:pre-wrap;word-break:break-word}
.modal-row{display:flex;gap:10px;margin-top:16px;flex-wrap:wrap}
.field{display:flex;align-items:center;gap:8px}
.field label{font:600 11px var(--sans);letter-spacing:.1em;text-transform:uppercase;color:var(--muted)}
.field input[type=number]{width:74px;background:#08080b;border:1px solid var(--line);border-radius:8px;color:var(--ink);padding:9px 11px;font:14px var(--sans)}
.status{font:13px var(--sans);color:var(--ink-soft);margin-top:14px;min-height:20px}
.picker{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-top:16px}
.picker img{width:100%;aspect-ratio:1;object-fit:cover;border-radius:9px;border:1px solid var(--line);cursor:pointer;transition:.15s}
.picker .pi{position:relative}
.picker img:hover{border-color:var(--gold);transform:scale(1.02)}
.brandsel{display:flex;flex-wrap:wrap;gap:7px;margin:14px 0}
.brandsel button{font:500 12px var(--sans);border:1px solid var(--line);background:#101016;color:var(--ink-soft);border-radius:999px;padding:7px 13px;cursor:pointer}
.brandsel button.on{border-color:var(--gold);color:var(--gold);background:rgba(228,194,90,.1)}
.results{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-top:14px}
.results .r{text-align:center}
.results img{width:100%;aspect-ratio:1;object-fit:cover;border-radius:9px;border:1px solid var(--line)}
.results .cap{font:10px var(--sans);color:var(--muted);margin-top:5px}
.x{float:right;font-size:22px;color:var(--muted);cursor:pointer;line-height:1;margin-top:-4px}
.toast{position:fixed;left:50%;bottom:108px;transform:translateX(-50%);z-index:80;background:#16161d;border:1px solid var(--gold-soft);color:var(--ink);font:500 13px var(--sans);padding:12px 20px;border-radius:10px;opacity:0;transition:.25s;pointer-events:none}
.toast.on{opacity:1}
.skeleton{color:var(--faint);font:14px var(--sans);padding:40px 0;text-align:center}
/* ── Add Style Guide form + custom-card delete ── */
.addform{display:flex;flex-direction:column;gap:14px;margin:6px 0 4px}
.afrow{display:flex;gap:14px;flex-wrap:wrap}
.fld{display:flex;flex-direction:column;gap:6px}
.fld.grow{flex:1;min-width:220px}
.fld label{font:600 10px var(--sans);letter-spacing:.14em;text-transform:uppercase;color:var(--muted)}
.fld input[type=text],.fld input[type=number],.fld select{background:#08080b;border:1px solid var(--line);border-radius:8px;color:var(--ink);padding:10px 12px;font:14px var(--sans)}
.fld input:focus,.fld select:focus{outline:none;border-color:var(--gold-soft)}
.colors{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:10px}
.colors .cw{position:relative}
.colors input[type=color]{width:44px;height:44px;border:1px solid var(--line);border-radius:9px;background:#08080b;cursor:pointer;padding:2px}
.colors .rm{position:absolute;top:-7px;right:-7px;width:18px;height:18px;border-radius:50%;border:1px solid var(--line);background:#16161d;color:var(--ink-soft);font:700 11px var(--sans);cursor:pointer;line-height:1;display:flex;align-items:center;justify-content:center}
.colors .rm:hover{border-color:#d57;color:#f88}
.card .del{position:absolute;top:14px;left:14px;width:22px;height:22px;border-radius:50%;border:1px solid var(--line);background:rgba(20,20,26,.85);color:var(--muted);font:700 13px var(--sans);cursor:pointer;display:none;align-items:center;justify-content:center;z-index:3}
.card.custom:hover .del{display:flex}
.card .del:hover{border-color:#d57;color:#f88}
</style>
</head>
<body>
<header>
<div class="wrap">
<div class="eyebrow"><span class="dot"></span> Style Guides · wallco.ai · V1</div>
<div class="head-row">
<div>
<h1>Brand Selector</h1>
<div class="lede">Pick the houses that source the next generative build · Top 20 fashion · Top 10 wallcovering</div>
<a class="ctl gold" href="/admin/styleguide-beverly-hills.html" style="margin-top:10px;display:inline-block;text-decoration:none">↗ Beverly Hills Palette</a>
</div>
<div class="counts">
<div class="c"><div class="n" id="cnt-fashion">20</div><div class="l">Fashion</div></div>
<div class="c"><div class="n" id="cnt-wall">10</div><div class="l">Wallcovering</div></div>
</div>
</div>
</div>
</header>
<main class="wrap">
<section>
<div class="sec-top">
<div>
<div class="sec-no">I.</div>
<div class="sec-h">Fashion</div>
<div class="sec-sub">Top 20 — Luxury & Iconic Houses</div>
</div>
<div class="controls">
<button class="ctl" data-all="fashion">Select all 20</button>
<button class="ctl" data-clear="fashion">Clear</button>
<button class="ctl gold" data-top5="fashion">Top 5 only</button>
</div>
</div>
<div class="grid" id="grid-fashion"><div class="skeleton">Loading houses…</div></div>
</section>
<section>
<div class="sec-top">
<div>
<div class="sec-no">II.</div>
<div class="sec-h">Wallcovering</div>
<div class="sec-sub">Top 10 — Heritage & Designer Houses</div>
</div>
<div class="controls">
<button class="ctl" data-all="wallcovering">Select all 10</button>
<button class="ctl" data-clear="wallcovering">Clear</button>
</div>
</div>
<div class="grid" id="grid-wallcovering"></div>
</section>
<section>
<div class="sec-top">
<div>
<div class="sec-no">III.</div>
<div class="sec-h">Your Style Guides</div>
<div class="sec-sub">Custom houses you've added — usable by Generate & Apply</div>
</div>
<div class="controls">
<button class="ctl gold" id="btn-add-sg">+ Add Style Guide</button>
<button class="ctl" data-all="custom">Select all</button>
<button class="ctl" data-clear="custom">Clear</button>
</div>
</div>
<div class="grid" id="grid-custom"><div class="skeleton">No custom guides yet — click “+ Add Style Guide”.</div></div>
</section>
</main>
<div class="bar">
<div class="bar-in">
<span class="selpill"><b id="sel-count">0</b> selected</span>
<span class="selpill" id="quota-pill" title="Free paid renders left for you today (per IP)" style="display:none"></span>
<span class="selnames" id="sel-names">No houses selected</span>
<div class="actions">
<button class="btn" id="btn-clear">Clear all</button>
<button class="btn" id="btn-json">Preview JSON</button>
<button class="btn" id="btn-export">Export</button>
<button class="btn" id="btn-apply">Apply to Studio →</button>
<button class="btn primary" id="btn-generate">Generate Designs →</button>
</div>
</div>
</div>
<!-- JSON modal -->
<div class="scrim" id="scrim-json">
<div class="modal">
<span class="x" data-close>×</span>
<h3>Style-Guide Payload</h3>
<div class="mh-sub">Portable JSON for the generator, Figma build, or studio recolor.</div>
<pre id="json-out"></pre>
<div class="modal-row">
<button class="btn" id="btn-copy">Copy JSON</button>
<button class="btn" id="btn-copy-figma">Copy for Figma</button>
<button class="btn" id="btn-dl">Download .json</button>
</div>
</div>
</div>
<!-- Generate modal -->
<div class="scrim" id="scrim-gen">
<div class="modal">
<span class="x" data-close>×</span>
<h3>Generate Designs</h3>
<div class="mh-sub">Renders new seamless tiles seeded from the selected houses' palettes & signatures. Each render passes the settlement + seam gates.</div>
<pre id="gen-prompt"></pre>
<div class="modal-row">
<div class="field"><label>Count</label><input type="number" id="gen-n" value="4" min="1" max="50"></div>
<button class="btn primary" id="btn-gen-go">Render batch →</button>
<a class="btn" href="/designs" target="_blank" rel="noopener noreferrer">Open Studio ↗</a>
</div>
<div class="status" id="gen-status"></div>
</div>
</div>
<!-- Apply to Studio modal -->
<div class="scrim" id="scrim-apply">
<div class="modal">
<span class="x" data-close>×</span>
<h3>Apply to Studio</h3>
<div class="mh-sub">Recolor an existing design into a selected house palette. Pick a house, then click a design.</div>
<div class="brandsel" id="apply-brands"></div>
<div class="picker" id="apply-picker"><div class="skeleton">Loading recent designs…</div></div>
<div class="status" id="apply-status"></div>
<div class="results" id="apply-results"></div>
</div>
</div>
<!-- Add Style Guide modal -->
<div class="scrim" id="scrim-add">
<div class="modal">
<span class="x" data-close>×</span>
<h3>Add a Style Guide</h3>
<div class="mh-sub">Define a custom brand palette. It joins the selector above and becomes usable by <b>Generate</b> (seeds the prompt) and <b>Apply</b> (recolors a design).</div>
<div class="addform">
<div class="afrow">
<div class="fld grow"><label>Name</label><input id="af-label" type="text" maxlength="60" placeholder="e.g. Mid-Century Lounge"></div>
<div class="fld"><label>Category</label>
<select id="af-cat"><option value="custom">Custom</option><option value="fashion">Fashion</option><option value="wallcovering">Wallcovering</option></select>
</div>
</div>
<div class="afrow">
<div class="fld"><label>Country</label><input id="af-country" type="text" maxlength="4" placeholder="US"></div>
<div class="fld"><label>Est.</label><input id="af-est" type="number" min="0" placeholder="1960"></div>
</div>
<div class="fld"><label>Signature</label><input id="af-sig" type="text" maxlength="200" placeholder="warm walnut & ochre, atomic-era restraint"></div>
<div class="fld"><label>Colors (1–6)</label>
<div class="colors" id="af-colors"></div>
<button class="ctl" id="af-addcolor" type="button" style="align-self:flex-start">+ color</button>
</div>
</div>
<div class="modal-row">
<button class="btn primary" id="btn-add-save">Save Style Guide</button>
</div>
<div class="status" id="add-status"></div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
(function(){
// ── admin-token passthrough (page may be opened with ?admin=<token>) ──────
const QS = new URLSearchParams(location.search);
const ADMIN = QS.get('admin') || '';
const authd = (p) => ADMIN ? p + (p.includes('?') ? '&' : '?') + 'admin=' + encodeURIComponent(ADMIN) : p;
// X-SG marks this page's paid calls so the server opens recolor to the
// public here (capped) without opening it on the main storefront.
const jfetch = (p, opts={}) => {
const headers = Object.assign({ 'X-SG':'1' }, opts.headers || {});
return fetch(authd(p), Object.assign({ credentials:'same-origin' }, opts, { headers }));
};
const state = { brands: { fashion: [], wallcovering: [], custom: [] }, sel: new Set(), quota: { admin:false, remaining:5, cap:5 } };
const byKey = {};
const $ = (s, r=document) => r.querySelector(s);
const el = (t, c) => { const e=document.createElement(t); if(c)e.className=c; return e; };
let toastT;
function toast(msg){ const t=$('#toast'); t.textContent=msg; t.classList.add('on'); clearTimeout(toastT); toastT=setTimeout(()=>t.classList.remove('on'),2600); }
// ── render cards ──────────────────────────────────────────────────────────
const esc = s => String(s==null?'':s).replace(/[&<>"]/g,m=>({'&':'&','<':'<','>':'>','"':'"'}[m]));
function cardFor(b){
const c = el('div','card'); c.dataset.key=b.key;
if(b.custom) c.classList.add('custom');
const dots = b.hex.slice(0,3).map(h=>`<span class="dot" style="background:${esc(h)}"></span>`).join('');
const del = b.custom ? `<button class="del" title="Delete this style guide">×</button>` : '';
c.innerHTML = del +
`<div class="tick"><svg viewBox="0 0 24 24" fill="none"><path d="M5 12l4.5 4.5L19 7" stroke="#1a1505" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg></div>`+
`<div class="name">${esc(b.label)}</div>`+
`<div class="meta"><span class="chip">${esc(b.country||'—')}</span><span class="chip">est. ${esc(b.est||'—')}</span></div>`+
`<div class="dots">${dots}</div>`;
c.addEventListener('click',()=>toggle(b.key,c));
if(b.custom){ const d=c.querySelector('.del'); d.addEventListener('click',e=>{ e.stopPropagation(); deleteGuide(b.key,b.label); }); }
return c;
}
function renderGrids(){
const gf=$('#grid-fashion'), gw=$('#grid-wallcovering'), gc=$('#grid-custom');
gf.innerHTML=''; gw.innerHTML=''; gc.innerHTML='';
state.brands.fashion.forEach(b=>gf.appendChild(cardFor(b)));
state.brands.wallcovering.forEach(b=>gw.appendChild(cardFor(b)));
if(state.brands.custom.length) state.brands.custom.forEach(b=>gc.appendChild(cardFor(b)));
else gc.innerHTML='<div class="skeleton">No custom guides yet — click “+ Add Style Guide”.</div>';
$('#cnt-fashion').textContent=state.brands.fashion.length;
$('#cnt-wall').textContent=state.brands.wallcovering.length;
}
function cardEl(key){ return document.querySelector(`.card[data-key="${key}"]`); }
function toggle(key,c){ c=c||cardEl(key); if(!c) return;
if(state.sel.has(key)){state.sel.delete(key);c.classList.remove('sel');}
else{state.sel.add(key);c.classList.add('sel');}
syncBar();
}
function setSel(key,on){ const c=cardEl(key); if(!c)return; if(on){state.sel.add(key);c.classList.add('sel');}else{state.sel.delete(key);c.classList.remove('sel');} }
function syncBar(){
const n=state.sel.size;
$('#sel-count').textContent=n;
const names=[...state.sel].map(k=>byKey[k]&&byKey[k].label).filter(Boolean);
$('#sel-names').textContent = n? names.join(' · ') : 'No houses selected';
const noQuota = !state.quota.admin && (state.quota.remaining|0) <= 0;
$('#btn-json').disabled = n===0;
$('#btn-export').disabled = n===0;
$('#btn-apply').disabled = n===0 || noQuota;
$('#btn-generate').disabled = n===0 || noQuota;
$('#btn-generate').textContent = noQuota ? 'Daily limit reached' : 'Generate Designs →';
}
function setQuota(q){ if(q) state.quota=q; renderQuota(); syncBar(); }
function renderQuota(){
const q=state.quota, pill=$('#quota-pill'); pill.style.display='';
if(q.admin){ pill.innerHTML='✦ Admin · unlimited'; pill.style.color='var(--gold)'; return; }
const r=Math.max(0,q.remaining|0);
pill.innerHTML='<b style="color:'+(r?'var(--gold)':'#d57')+'">'+r+'</b> / '+q.cap+' free renders left';
}
async function refreshQuota(){
try{ const r=await jfetch('/api/styleguides/quota'); const j=await r.json();
if(j.ok) setQuota({admin:!!j.admin, remaining:j.unlimited?Infinity:j.remaining, cap:j.cap}); }catch(e){}
}
// ── payload ───────────────────────────────────────────────────────────────
function selected(){ return [...state.sel].map(k=>byKey[k]).filter(Boolean); }
function payload(){
const houses=selected();
const colors=[]; houses.forEach(h=>h.hex.slice(0,2).forEach(x=>{ if(!colors.includes(x)) colors.push(x); }));
return {
version:'wallco-styleguide-v1',
generated_at:new Date().toISOString(),
count:houses.length,
houses:houses.map(h=>({key:h.key,label:h.label,category:h.cat,country:h.country,est:h.est,hex:h.hex,signature:h.signature})),
combined_palette:colors,
signatures:houses.map(h=>h.signature)
};
}
function genPrompt(){
const houses=selected();
const colors=[]; houses.forEach(h=>h.hex.slice(0,2).forEach(x=>{ if(!colors.includes(x)) colors.push(x); }));
const sigs=houses.map(h=>h.signature).join('; ');
return {
n: parseInt($('#gen-n').value,10)||4,
colors: colors.slice(0,6).join(', '),
patterns: houses.map(h=>({artist:h.label, title:h.label+' signature', dominant_hex:h.hex[0]})),
styles: [],
extra_text: 'Drawing on the house signatures of '+houses.map(h=>h.label).join(', ')+': '+sigs+'. Tone-on-tone, archival, no more than 4 colors.'
};
}
// ── controls ────────────────────────────────────────────────────────────
document.querySelectorAll('[data-all]').forEach(b=>b.addEventListener('click',()=>{ state.brands[b.dataset.all].forEach(x=>setSel(x.key,true)); syncBar(); }));
document.querySelectorAll('[data-clear]').forEach(b=>b.addEventListener('click',()=>{ state.brands[b.dataset.clear].forEach(x=>setSel(x.key,false)); syncBar(); }));
document.querySelectorAll('[data-top5]').forEach(b=>b.addEventListener('click',()=>{ const cat=b.dataset.top5; state.brands[cat].forEach(x=>setSel(x.key,false)); state.brands[cat].slice(0,5).forEach(x=>setSel(x.key,true)); syncBar(); }));
$('#btn-clear').addEventListener('click',()=>{ state.sel.forEach(k=>{const c=cardEl(k);if(c)c.classList.remove('sel');}); state.sel.clear(); syncBar(); });
// ── modals ──────────────────────────────────────────────────────────────
function openModal(id){ $('#'+id).classList.add('on'); }
function closeModal(s){ s.classList.remove('on'); }
document.querySelectorAll('[data-close]').forEach(x=>x.addEventListener('click',e=>closeModal(e.target.closest('.scrim'))));
document.querySelectorAll('.scrim').forEach(s=>s.addEventListener('click',e=>{ if(e.target===s) closeModal(s); }));
document.addEventListener('keydown',e=>{ if(e.key==='Escape') document.querySelectorAll('.scrim.on').forEach(closeModal); });
// Preview JSON
$('#btn-json').addEventListener('click',()=>{ $('#json-out').textContent=JSON.stringify(payload(),null,2); openModal('scrim-json'); });
$('#btn-copy').addEventListener('click',()=>{ navigator.clipboard.writeText(JSON.stringify(payload(),null,2)); toast('JSON copied'); });
$('#btn-copy-figma').addEventListener('click',()=>{
const p=payload();
const txt='wallco.ai style guide — '+p.count+' houses\n'+p.houses.map(h=>`${h.label} (${h.country} · est. ${h.est}) — ${h.hex.join(' ')} — ${h.signature}`).join('\n');
navigator.clipboard.writeText(txt); toast('Figma block copied');
});
$('#btn-export').addEventListener('click',()=>dlJson());
$('#btn-dl').addEventListener('click',()=>dlJson());
function dlJson(){
const blob=new Blob([JSON.stringify(payload(),null,2)],{type:'application/json'});
const a=el('a'); a.href=URL.createObjectURL(blob); a.download='wallco-styleguide-'+Date.now()+'.json'; a.click();
setTimeout(()=>URL.revokeObjectURL(a.href),2000); toast('Downloaded');
}
// Generate
$('#btn-generate').addEventListener('click',()=>{
const p=genPrompt();
$('#gen-prompt').textContent='palette: '+(p.colors||'(none)')+'\nlineage: '+p.patterns.map(x=>x.artist).join(', ')+'\n\n'+p.extra_text;
$('#gen-status').textContent=''; openModal('scrim-gen');
});
$('#btn-gen-go').addEventListener('click',async()=>{
const go=$('#btn-gen-go'); go.disabled=true;
const p=genPrompt(); p.n=parseInt($('#gen-n').value,10)||4;
$('#gen-status').textContent='Kicking off render of '+p.n+' design'+(p.n>1?'s':'')+'…';
try{
const r=await jfetch('/api/generator/batch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)});
const j=await r.json();
if(!j.ok) throw new Error(j.error||'failed');
if(typeof j.remaining==='number') setQuota({admin:false,remaining:j.remaining,cap:state.quota.cap}); else refreshQuota();
$('#gen-status').innerHTML='✓ Batch <b>#'+j.run_id+'</b> running ('+j.count+' image'+(j.count>1?'s':'')+') — '+(j.note||'')+' <a href="/designs" target="_blank" rel="noopener noreferrer" style="color:var(--gold)">watch the Studio ↗</a>';
toast('Batch #'+j.run_id+' started');
}catch(e){ $('#gen-status').textContent='✗ '+e.message; refreshQuota(); }
finally{ go.disabled=false; }
});
// Apply to Studio
let applyBrand=null;
$('#btn-apply').addEventListener('click',async()=>{
const houses=selected();
const bs=$('#apply-brands'); bs.innerHTML='';
applyBrand=houses[0]?houses[0].key:null;
houses.forEach(h=>{ const b=el('button'); b.textContent=h.label; b.dataset.key=h.key; if(h.key===applyBrand)b.classList.add('on');
b.addEventListener('click',()=>{ applyBrand=h.key; bs.querySelectorAll('button').forEach(x=>x.classList.toggle('on',x.dataset.key===h.key)); });
bs.appendChild(b); });
$('#apply-results').innerHTML=''; $('#apply-status').textContent='';
openModal('scrim-apply');
const pk=$('#apply-picker'); pk.innerHTML='<div class="skeleton">Loading recent designs…</div>';
try{
const r=await jfetch('/api/designs/random?n=12'); const j=await r.json();
const items=(j.items||j||[]); pk.innerHTML='';
items.forEach(d=>{ const div=el('div','pi'); const img=el('img'); img.src=d.image_url; img.title='#'+d.id+(d.title?' · '+d.title:''); img.loading='lazy';
img.addEventListener('click',()=>recolor(d.id)); div.appendChild(img); pk.appendChild(div); });
if(!items.length) pk.innerHTML='<div class="skeleton">No designs available.</div>';
}catch(e){ pk.innerHTML='<div class="skeleton">Failed to load: '+e.message+'</div>'; }
});
async function recolor(id){
if(!applyBrand){ toast('Pick a house first'); return; }
const label=byKey[applyBrand].label;
$('#apply-status').textContent='→ Recoloring #'+id+' into '+label+'…';
try{
const r=await jfetch('/api/design/'+id+'/recolor',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({brand:applyBrand})});
const j=await r.json();
if(!j.ok) throw new Error(j.error||'failed');
if(typeof j.remaining==='number') setQuota({admin:false,remaining:j.remaining,cap:state.quota.cap}); else refreshQuota();
const div=el('div','r');
div.innerHTML='<a href="/design/'+j.new_id+'" target="_blank" rel="noopener noreferrer"><img src="'+j.new_image_url+'"></a><div class="cap">'+label+' · #'+j.new_id+'</div>';
$('#apply-results').prepend(div);
$('#apply-status').textContent='✓ #'+id+' → '+label+' (#'+j.new_id+')';
toast('Recolored → #'+j.new_id);
}catch(e){ $('#apply-status').textContent='✗ '+e.message; refreshQuota(); }
}
// ── add / delete custom style guides ───────────────────────────────────────
const colorsWrap = () => $('#af-colors');
function addColorInput(val){
if(colorsWrap().children.length>=6){ toast('Max 6 colors'); return; }
const wrap=el('div','cw');
const inp=el('input'); inp.type='color'; inp.value=val||'#8a7f6a';
const rm=el('button','rm'); rm.type='button'; rm.textContent='×';
rm.addEventListener('click',()=>{ if(colorsWrap().children.length>1) wrap.remove(); else toast('Need at least one color'); });
wrap.appendChild(inp); wrap.appendChild(rm); colorsWrap().appendChild(wrap);
}
function resetAddForm(){
['af-label','af-country','af-sig'].forEach(id=>{const e=$('#'+id); if(e)e.value='';});
$('#af-est').value=''; $('#af-cat').value='custom'; $('#add-status').textContent='';
colorsWrap().innerHTML=''; addColorInput('#3a3a44'); addColorInput('#c8a96a'); addColorInput('#f3ecdd');
}
$('#btn-add-sg').addEventListener('click',()=>{ resetAddForm(); openModal('scrim-add'); setTimeout(()=>$('#af-label').focus(),50); });
$('#af-addcolor').addEventListener('click',()=>addColorInput());
$('#btn-add-save').addEventListener('click',async()=>{
const label=$('#af-label').value.trim();
if(!label){ $('#add-status').textContent='✗ Name is required'; $('#af-label').focus(); return; }
const hex=[...colorsWrap().querySelectorAll('input[type=color]')].map(i=>i.value.toUpperCase());
const body={ label, cat:$('#af-cat').value, country:$('#af-country').value.trim(),
est:$('#af-est').value.trim(), signature:$('#af-sig').value.trim(), hex };
const save=$('#btn-add-save'); save.disabled=true; $('#add-status').textContent='Saving…';
try{
const r=await jfetch('/api/styleguides/add',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const j=await r.json();
if(!j.ok) throw new Error(j.error||'failed');
await loadBrands();
const k=j.guide&&j.guide.key; if(k) setSel(k,true), syncBar();
closeModal($('#scrim-add'));
toast((j.updated?'Updated':'Added')+' “'+label+'”');
}catch(e){ $('#add-status').textContent='✗ '+e.message; }
finally{ save.disabled=false; }
});
async function deleteGuide(key,label){
if(!confirm('Delete style guide “'+label+'”? This cannot be undone.')) return;
try{
const r=await jfetch('/api/styleguides/'+encodeURIComponent(key),{method:'DELETE'});
const j=await r.json();
if(!j.ok) throw new Error(j.error||'failed');
state.sel.delete(key); await loadBrands(); syncBar();
toast('Deleted “'+label+'”');
}catch(e){ toast('✗ '+e.message); }
}
// ── load brands (built-ins + custom); preserves current selection ──────────
async function loadBrands(){
const r=await jfetch('/api/styleguides/brands'); const j=await r.json();
state.brands.fashion=j.fashion||[]; state.brands.wallcovering=j.wallcovering||[]; state.brands.custom=j.custom||[];
Object.keys(byKey).forEach(k=>delete byKey[k]);
[...state.brands.fashion,...state.brands.wallcovering,...state.brands.custom].forEach(b=>byKey[b.key]=b);
renderGrids();
// re-apply selection classes for keys that still exist; drop the rest
[...state.sel].forEach(k=>{ if(byKey[k]){ const c=cardEl(k); if(c)c.classList.add('sel'); } else state.sel.delete(k); });
syncBar();
}
// ── boot ──────────────────────────────────────────────────────────────────
(async function(){
try{ await loadBrands(); refreshQuota(); }
catch(e){ $('#grid-fashion').innerHTML='<div class="skeleton">Failed to load houses: '+e.message+'</div>'; }
})();
})();
</script>
</body>
</html>