← back to Whatsmystyle

public/js/app.js

2206 lines

// WhatsMyStyle frontend
// Single-page app with large-pill onboarding + optometrist duel loop.

const $  = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));

function show(id) {
  $$('.screen').forEach(s => s.hidden = true);
  const el = document.getElementById(id);
  if (el) el.hidden = false;
}
function toggleMenu() {
  const m = $('#menu');
  m.hidden = !m.hidden;
  const btn = $('.hamburger');
  btn.setAttribute('aria-expanded', String(!m.hidden));
  if (!m.hidden) m.querySelector('a')?.focus();
}

// click outside / escape closes the menu
document.addEventListener('click', e => {
  const m = $('#menu');
  if (!m.hidden && !m.contains(e.target) && !e.target.closest('.hamburger')) m.hidden = true;
});
document.addEventListener('keydown', e => {
  if (e.key === 'Escape' && !$('#menu').hidden) {
    $('#menu').hidden = true;
    $('.hamburger').focus();
  }
});

async function forgetMe() {
  if (!confirm('This deletes your account, your twin, every photo, every render, every duel. There is no undo. Continue?')) return;
  const r = await fetch('/api/me/forget', { method: 'POST' });
  if (r.ok) {
    alert('Gone. Refreshing.');
    location.reload();
  } else {
    alert('Could not delete — try again or contact us.');
  }
}
window.forgetMe = forgetMe;
$$('.menu a').forEach(a => a.addEventListener('click', e => {
  // Admin-config is a top-level page, not a screen — let the browser handle it.
  if (a.getAttribute('href') && a.getAttribute('href').startsWith('/')) return;
  e.preventDefault();
  const id = a.dataset.screen;
  $('#menu').hidden = true;
  route(id);
}));

// ---------- Three-tier role layer (admin / public / set_decorator) ---------
// Loaded once at boot, refreshed when admin flips view_as. Drives:
//   - role banner above the topbar (preview indicator + exit pill)
//   - menu item visibility via data-role-only=<role>
//   - per-screen feature gates (set_decorator unlocks /productions)
const Role = {
  role: 'public',
  view_as: null,
  effective: 'public',
  is_admin: false,
  couples_pilot: false,
  async refresh() {
    try {
      const r = await fetch('/api/me');
      const me = await r.json();
      this.role = me.role || 'public';
      this.view_as = me.view_as || null;
      this.effective = me.effective_role || this.role;
      this.is_admin = !!me.is_admin;
      // Read couples_pilot flag from admin config when available (admin only).
      if (this.is_admin) {
        try {
          const cfg = await (await fetch('/api/admin/config')).json();
          this.couples_pilot = !!cfg.config?.couples_pilot;
        } catch {}
      } else {
        // Non-admins can't read the flag; rely on backend 503 if they try the
        // couple endpoints. Hide the menu item conservatively.
        this.couples_pilot = false;
      }
      this.applyToDom();
      // Populate any production-picker selects for set-decorator role.
      if (this.effective === 'set_decorator' || this.role === 'set_decorator') {
        populateProductionSelects();
      }
    } catch {}
  },
  applyToDom() {
    // Menu items can be role-gated. data-role-only="admin" shows only when
    // user_role is admin. data-role-only="set_decorator" shows when effective
    // role is set_decorator OR user_role is set_decorator. data-role-only=
    // "couples_pilot" is feature-flag-gated (Role.couples_pilot, refreshed
    // from /api/admin/config).
    const showFor = (need) => {
      if (need === 'admin') return this.is_admin;
      if (need === 'set_decorator') return this.effective === 'set_decorator' || this.role === 'set_decorator';
      if (need === 'couples_pilot') return !!this.couples_pilot;
      return true;
    };
    $$('.menu a[data-role-only]').forEach(a => {
      a.style.display = showFor(a.dataset.roleOnly) ? '' : 'none';
    });
    // Anything with [data-role-only] outside the menu (Tailor panel, set-
    // grade toggle, closet production picker) toggles via the `hidden`
    // attribute so layout collapses.
    $$('[data-role-only]').forEach(el => {
      if (el.matches('.menu a')) return;
      el.hidden = !showFor(el.dataset.roleOnly);
    });
    // Banner: only show when an admin is previewing as something else.
    const banner = $('#role-banner');
    if (this.is_admin && this.view_as) {
      banner.hidden = false;
      banner.innerHTML = `
        <span class="role-banner-label">Previewing as <strong>${this.view_as === 'public' ? 'public consumer' : 'set decorator'}</strong></span>
        <button class="role-banner-exit" type="button">Exit preview →</button>
      `;
      banner.querySelector('.role-banner-exit').onclick = async () => {
        await fetch('/api/admin/view-as', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ view_as: null }) });
        await Role.refresh();
      };
    } else {
      banner.hidden = true;
    }
  },
};
window.Role = Role;

// ---- Floating embed-progress chip (tick 20, admin only) ------------------
// Lives in the lower-right corner across every screen. Polls catalog status
// every 30s. Hides when admin is previewing-as a non-admin role OR when the
// catalog is fully embedded. Surfaces Mac1 state so Steve sees why the
// drainer might be paused.
async function refreshEmbedChip() {
  const chip = $('#embed-chip');
  if (!chip) return;
  // Visibility — admin role only AND there's work to show.
  if (!Role.is_admin || Role.view_as) { chip.hidden = true; return; }
  try {
    const r = await fetch('/api/admin/catalog-status');
    if (!r.ok) { chip.hidden = true; return; }
    const j = await r.json();
    if (!j.total || j.unembedded === 0) { chip.hidden = true; return; }
    chip.hidden = false;
    const pct = Math.round((j.embedded / j.total) * 100);
    $('#embed-chip-pct').textContent = pct;
    $('#embed-chip-left').textContent = j.unembedded;
    $('#embed-chip-fill').style.width = pct + '%';
    // Mac1 status pill
    const state = j.mac1?.state || 'unknown';
    const label = ({
      idle: 'Mac1 idle',
      busy: 'Mac1 busy',
      unreachable: 'Mac1 down',
      unknown: 'Mac1 ?',
    })[state];
    const dot = $('#embed-chip-dot');
    const mac1 = $('#embed-chip-mac1');
    mac1.textContent = label;
    mac1.title = j.mac1?.detail || '';
    dot.className = 'embed-chip-dot embed-chip-dot-' + state;
    // If drainer is OFF, show a small "paused" hint.
    if (!j.drainer_enabled) {
      mac1.textContent = label + ' · paused';
    }
    // Tick 22: paid-API budget row. Color-code on burn percentage.
    if (j.budget) {
      const spent = (j.budget.month_cents / 100);
      const cap = (j.budget.budget_cents / 100);
      const pct = cap > 0 ? (spent / cap) : 0;
      $('#embed-chip-spent').textContent = '$' + spent.toFixed(2);
      $('#embed-chip-budget-cap').textContent = cap.toFixed(2);
      const budgetEl = $('#embed-chip-budget');
      budgetEl.classList.toggle('budget-warn', pct >= 0.75 && pct < 1);
      budgetEl.classList.toggle('budget-over', pct >= 1);
      const top = j.budget.by_provider?.[0];
      $('#embed-chip-burn-by').textContent = top ? `${top.provider} $${(top.c/100).toFixed(2)}` : 'no burn yet';
    }
  } catch {
    chip.hidden = true;
  }
}
setInterval(refreshEmbedChip, 30 * 1000);
// First refresh after Role boots — small delay so role data is loaded.
setTimeout(refreshEmbedChip, 1500);

// Populate every <select> that's a production picker (closet upload, try-on
// submit, header active-production bar). Idempotent — wipes & re-fills.
// Reads / writes localStorage 'wms.activeProduction' so the choice persists
// across pages and is auto-applied to all writes.
async function populateProductionSelects() {
  const selects = $$('select[name="production_id"]');
  if (!selects.length) return;
  let opts = '<option value="">— untagged —</option>';
  try {
    const r = await fetch('/api/productions');
    if (r.ok) {
      const { productions } = await r.json();
      opts += productions.map(p =>
        `<option value="${p.id}">${(p.title || '').replace(/[<>&"']/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;',"'":'&#39;'}[c]))}${p.year ? ' (' + p.year + ')' : ''}</option>`
      ).join('');
    }
  } catch {}
  const active = localStorage.getItem('wms.activeProduction') || '';
  selects.forEach(s => {
    s.innerHTML = opts;
    s.value = active;
  });
}

// Active-production helper — single source of truth for tryOnItem + any form
// that wants the current set-decorator project context.
function getActiveProductionId() {
  return localStorage.getItem('wms.activeProduction') || '';
}

// The header bar select drives the global. Other selects mirror via the
// `wms.activeProduction` localStorage value on populate().
document.addEventListener('change', e => {
  if (e.target.id !== 'active-prod-select') return;
  const v = e.target.value || '';
  if (v) localStorage.setItem('wms.activeProduction', v);
  else localStorage.removeItem('wms.activeProduction');
  // Mirror to any other production-picker selects on the page.
  $$('select[name="production_id"]').forEach(s => { if (s !== e.target) s.value = v; });
});

// ---- Set-grade only toggle (tick 16) — set-decorator role ----
$('#set-grade-only')?.addEventListener('change', e => {
  localStorage.setItem('wms.proOnly', e.target.checked ? '1' : '0');
  loadRecs();
});

// ---- Favorites + brand page + delta toast (tick 24) ---------------------
const Favs = { set: new Set() };   // 'catalog:42', 'closet:7'

async function refreshFavorites() {
  try {
    const r = await fetch('/api/favorites');
    if (r.ok) {
      const j = await r.json();
      Favs.set = new Set(j.items.map(i => `${i.kind}:${i.item_id}`));
    }
  } catch {}
}

function isFav(kind, id) { return Favs.set.has(`${kind}:${id}`); }

async function toggleFav(kind, id, btn) {
  const key = `${kind}:${id}`;
  const wasFav = Favs.set.has(key);
  // Optimistic toggle + animation
  if (wasFav) {
    Favs.set.delete(key);
    await fetch(`/api/favorites/${kind}/${id}`, { method: 'DELETE' });
  } else {
    Favs.set.add(key);
    await fetch('/api/favorites', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kind, id }) });
    btn?.classList.add('star-pop');
    setTimeout(() => btn?.classList.remove('star-pop'), 400);
  }
  if (btn) {
    btn.classList.toggle('is-fav', !wasFav);
    btn.textContent = !wasFav ? '★' : '☆';
  }
}

async function loadFavorites() {
  await refreshFavorites();
  const r = await fetch('/api/favorites');
  const j = r.ok ? await r.json() : { items: [] };
  const g = $('#favorites-grid');
  if (!j.items.length) {
    g.innerHTML = '<p class="muted">No favorites yet. Tap the star on any item card to save it here.</p>';
    return;
  }
  g.innerHTML = j.items.map(it => `
    <div class="card">
      <img src="${escapeHtml(it.image_url || '')}" alt="${escapeHtml(it.title || '')}" loading="lazy">
      <button type="button" class="star-btn is-fav" data-fav-kind="${it.kind}" data-fav-id="${it.item_id}" aria-label="Unfavorite">★</button>
      <div class="meta">
        <div class="title">${escapeHtml(it.title || it.category || 'piece')}</div>
        <div class="brand">${escapeHtml(it.brand || '')}</div>
        ${it.price_cents ? `<div class="price">$${(it.price_cents/100).toFixed(0)}</div>` : ''}
      </div>
    </div>
  `).join('');
}

// Brand page
async function openBrand(name) {
  show('brand');
  $('#brand-title').textContent = 'Loading…';
  $('#brand-meta').textContent = '';
  $('#brand-bio').textContent = '';
  $('#brand-grid').innerHTML = '<p class="muted">Loading…</p>';
  const r = await fetch(`/api/brand/${encodeURIComponent(name)}`);
  if (!r.ok) { $('#brand-grid').innerHTML = '<p class="muted">Brand not found.</p>'; return; }
  const j = await r.json();
  $('#brand-title').textContent = j.brand;
  $('#brand-meta').innerHTML = [
    j.sustain_tier ? `<span class="sustain">♻ ${j.sustain_tier}/5</span>` : '',
    `<span class="muted">${j.item_count} item${j.item_count === 1 ? '' : 's'}</span>`,
  ].filter(Boolean).join(' · ');
  $('#brand-bio').textContent = j.bio || '';
  if (!j.items.length) { $('#brand-grid').innerHTML = '<p class="muted">No items in the catalog for this brand yet.</p>'; return; }
  $('#brand-grid').innerHTML = j.items.map(it => `
    <a class="card" href="${escapeHtml(it.product_url || '#')}" target="_blank" rel="noopener">
      <img src="${escapeHtml(it.image_url || '')}" alt="${escapeHtml(it.title || '')}" loading="lazy">
      <button type="button" class="star-btn ${isFav('catalog', it.id) ? 'is-fav' : ''}" data-fav-kind="catalog" data-fav-id="${it.id}" onclick="event.preventDefault();event.stopPropagation();" aria-label="Favorite">${isFav('catalog', it.id) ? '★' : '☆'}</button>
      <div class="meta">
        <div class="title">${escapeHtml(it.title || '')}</div>
        ${it.price_cents ? `<div class="price">$${(it.price_cents/100).toFixed(0)}</div>` : ''}
      </div>
    </a>
  `).join('');
}

document.addEventListener('click', e => {
  const star = e.target.closest('.star-btn');
  if (star) {
    e.preventDefault(); e.stopPropagation();
    toggleFav(star.dataset.favKind, star.dataset.favId, star);
    return;
  }
  const brandLink = e.target.closest('[data-brand]');
  if (brandLink) {
    e.preventDefault();
    openBrand(brandLink.dataset.brand);
    return;
  }
  // Tick 25: Quick-look modal on tap of catalog card image (preserves the
  // affiliate-out path — the outer anchor opens product_url on tap of
  // anywhere ELSE in the card).
  const qlOn = (localStorage.getItem('wms.quicklook') ?? '1') === '1';
  if (qlOn) {
    const img = e.target.closest('.card > img, .grid .card a > img');
    if (img) {
      const card = img.closest('.card');
      const data = {
        title: card?.querySelector('.title')?.textContent || '',
        brand: card?.querySelector('.brand')?.textContent || card?.querySelector('[data-brand]')?.dataset.brand || '',
        price: card?.querySelector('.price')?.textContent || '',
        image: img.src,
        href:  card?.tagName === 'A' ? card.getAttribute('href') : (card?.querySelector('a')?.getAttribute('href') || '#'),
      };
      if (data.href && data.href !== '#') {
        e.preventDefault(); e.stopPropagation();
        openQuickLook(data);
        return;
      }
    }
  }
  // Quick-look close
  if (e.target.closest('[data-ql-close]')) closeQuickLook();
});

function openQuickLook(data) {
  const modal = $('#quicklook-modal');
  if (!modal) return;
  $('#ql-img').src = data.image;
  $('#ql-img').alt = data.title;
  $('#ql-title').textContent = data.title;
  $('#ql-brand-link').textContent = data.brand;
  $('#ql-brand-link').dataset.brand = data.brand;
  $('#ql-price').textContent = data.price;
  $('#ql-out').href = data.href;
  modal.hidden = false;
  document.body.style.overflow = 'hidden';
}
function closeQuickLook() {
  const modal = $('#quicklook-modal');
  if (!modal) return;
  modal.hidden = true;
  document.body.style.overflow = '';
}
document.addEventListener('keydown', e => {
  if (e.key === 'Escape' && !$('#quicklook-modal')?.hidden) closeQuickLook();
});
$('#brand-back')?.addEventListener('click', e => { e.preventDefault(); route('recs'); });

// Share favorites (tick 25)
$('#fav-share-btn')?.addEventListener('click', async () => {
  const r = await fetch('/api/favorites/share', { method: 'POST' });
  const j = await r.json();
  const url = `${location.origin}${j.url}`;
  try { await navigator.clipboard.writeText(url); $('#fav-share-out').textContent = 'Copied: ' + url; }
  catch { $('#fav-share-out').innerHTML = `Share URL: <a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`; }
});

// Delta toast (tick 24) — show top-3 taste movers after a duel pick
function showDeltaToast(deltas) {
  if (!deltas?.length) return;
  const el = $('#delta-toast');
  if (!el) return;
  el.innerHTML = deltas.map(d => {
    const sign = d.delta >= 0 ? '+' : '−';
    const cls = d.delta >= 0 ? 'delta-up' : 'delta-down';
    return `<span class="${cls}">${sign}${Math.abs(d.delta).toFixed(3)} ${d.dim}</span>`;
  }).join('  ');
  el.hidden = false;
  el.classList.remove('toast-hide');
  clearTimeout(showDeltaToast._t);
  showDeltaToast._t = setTimeout(() => {
    el.classList.add('toast-hide');
    setTimeout(() => { el.hidden = true; }, 400);
  }, 3000);
}
window.showDeltaToast = showDeltaToast;

// ---- Facet pills (tick 23) — toggle color/material filter on click -------
document.addEventListener('click', e => {
  const pill = e.target.closest('.facet-pill');
  if (!pill) return;
  const kind = pill.dataset.facetKind;
  const value = pill.dataset.facetValue;
  const key = `wms.facets.${kind}s`;        // colors/materials
  const current = JSON.parse(localStorage.getItem(key) || '[]');
  const idx = current.indexOf(value);
  if (idx >= 0) current.splice(idx, 1); else current.push(value);
  localStorage.setItem(key, JSON.stringify(current));
  loadRecs();
});

// ---- Couple lifecycle UI (tick 16, flag-gated) ----
async function coupleInvite() {
  const out = $('#couple-invite-out');
  out.hidden = false; out.textContent = 'Generating…';
  const r = await fetch('/api/couple/invite', { method: 'POST', headers: { 'content-type': 'application/json' } });
  const j = await r.json();
  if (!r.ok) { out.textContent = j.reason || j.error || 'Failed'; return; }
  out.innerHTML = `Token: <code>${escapeHtml(j.invite_token || '(reused)')}</code> <button type="button" class="ghost" id="copy-tok">Copy</button>`;
  $('#copy-tok')?.addEventListener('click', async () => {
    try { await navigator.clipboard.writeText(j.invite_token); $('#copy-tok').textContent = 'Copied'; }
    catch { $('#copy-tok').textContent = 'Copy failed'; }
  });
}
async function coupleAccept(e) {
  e.preventDefault();
  const t = $('#couple-token-input').value.trim();
  if (!t) return;
  const r = await fetch(`/api/couple/accept/${encodeURIComponent(t)}`, { method: 'POST' });
  const j = await r.json();
  const out = $('#couple-accept-out');
  if (r.ok) { out.textContent = `Accepted. Couple #${j.couple_id} is now active.`; out.style.color = '#14572a'; $('#couple-token-input').value = ''; }
  else { out.textContent = j.error || j.reason || 'Failed'; out.style.color = '#7a1717'; }
}
async function coupleExit() {
  if (!confirm('Exit your couple? This purges the cross-twin training join. Your individual closet stays intact.')) return;
  const r = await fetch('/api/couple/exit', { method: 'POST' });
  const j = await r.json();
  const out = $('#couple-exit-out');
  if (r.ok) { out.textContent = `Exited couple #${j.couple_id}.`; out.style.color = '#14572a'; }
  else { out.textContent = j.error || j.reason || 'Failed'; out.style.color = '#7a1717'; }
}
$('#couple-invite-btn')?.addEventListener('click', coupleInvite);
$('#couple-accept-form')?.addEventListener('submit', coupleAccept);
$('#couple-exit-btn')?.addEventListener('click', coupleExit);

// ---------- Productions (set-decorator only) -------------------------------
async function loadProductions() {
  const r = await fetch('/api/productions');
  const { productions } = await r.json();
  const list = $('#productions-list');
  if (!productions.length) {
    list.innerHTML = `<p class="muted">No productions yet. Add one above. Once you have closet pieces or try-ons tagged with a production, they'll show up here with credit counts.</p>`;
    return;
  }
  list.innerHTML = productions.map(p => `
    <article class="production-row production-clickable" data-production-id="${p.id}" tabindex="0" role="button" aria-label="Open credits for ${escapeHtml(p.title)}">
      <h3 class="production-title">${escapeHtml(p.title)}${p.year ? ` <span class="muted">(${p.year})</span>` : ''}</h3>
      <div class="production-meta">
        <span>${escapeHtml(p.kind || 'tv-episode')}</span>
        <span>${escapeHtml(p.role || 'Set Decoration Buyer')}</span>
        <span><strong>${p.total_credits}</strong> credits</span>
        <span class="muted">${p.closet_count} closet · ${p.tryon_count} try-on</span>
        <span class="production-cta">view résumé →</span>
      </div>
    </article>
  `).join('');
}

async function openProductionCredits(productionId) {
  show('credits');
  $('#credits-title').textContent = 'Loading résumé…';
  $('#credits-brand').textContent = '';
  $('#credits-count').textContent = '0';
  $('#credits-list').innerHTML = '<p class="muted">Loading…</p>';
  try {
    const r = await fetch(`/api/production-credits/${encodeURIComponent(productionId)}`);
    if (!r.ok) { $('#credits-list').innerHTML = `<p class="muted">Could not load (${r.status}).</p>`; return; }
    const data = await r.json();
    const p = data.production;
    $('#credits-title').textContent = p.title + (p.year ? ` (${p.year})` : '');
    $('#credits-brand').textContent = `${p.kind || 'tv-episode'} · ${p.role || 'Set Decoration Buyer'}`;
    $('#credits-count').textContent = data.total;
    if (!data.credits.length) {
      $('#credits-list').innerHTML = `<p class="muted">No closet pieces or try-ons tagged to this production yet. Open any closet item or try-on, tap "tag with production", and pick this one.</p>`;
      return;
    }
    $('#credits-list').innerHTML = data.credits.map(c => `
      <article class="credit-row">
        <h3 class="credit-role">${escapeHtml(c.role)}</h3>
        <h4 class="credit-title">
          ${c.occasion ? `<span class="credit-occasion">${escapeHtml(c.occasion)}</span>` : '<span class="credit-occasion muted">no occasion tag</span>'}
          <span class="credit-year">${fmtCreditDate(c.date)}</span>
        </h4>
        <div class="credit-meta">
          <span class="credit-type">${escapeHtml(c.type)}</span>
          ${c.provider ? `<span class="credit-provider">via ${escapeHtml(c.provider)}</span>` : ''}
          ${c.status ? `<span class="credit-status credit-status-${escapeHtml(c.status)}">${escapeHtml(c.status)}</span>` : ''}
          ${c.brand ? `<span>${escapeHtml(c.brand)}</span>` : ''}
        </div>
        ${c.with && c.with.length
          ? `<div class="credit-with"><span class="muted">With:</span> ${c.with.map(w => `<span>${escapeHtml(w)}</span>`).join(' · ')}</div>`
          : ''}
      </article>
    `).join('');
  } catch (e) {
    $('#credits-list').innerHTML = `<p class="muted">Could not load: ${escapeHtml(e.message)}</p>`;
  }
}

document.addEventListener('click', e => {
  const card = e.target.closest('.production-clickable');
  if (card) {
    e.preventDefault();
    openProductionCredits(card.dataset.productionId);
  }
});
document.addEventListener('keydown', e => {
  if (e.key !== 'Enter') return;
  const card = e.target.closest('.production-clickable');
  if (card) { e.preventDefault(); openProductionCredits(card.dataset.productionId); }
});

document.addEventListener('submit', async e => {
  if (e.target.id !== 'production-add') return;
  e.preventDefault();
  const body = {
    title: $('#prod-title').value.trim(),
    year: $('#prod-year').value ? Number($('#prod-year').value) : null,
    kind: $('#prod-kind').value,
    role: $('#prod-role').value,
  };
  if (!body.title) return;
  const r = await fetch('/api/productions', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
  if (r.ok) {
    $('#prod-title').value = ''; $('#prod-year').value = '';
    loadProductions();
  } else {
    alert('Could not add — try again.');
  }
});

// ---------- Onboarding -----------------------------------------------------
// Per the 2026-05-12 onboarding-length debate (verdict: modify) — mandatory
// = body-shape, budget, geolocation. Other 8 are optional (skippable).
const onbQuestions = [
  { key: 'shopping_for', optional: true, q: 'Who are you shopping for?',
    pills: [['Myself','self'],['Someone else','other'],['Both','both']] },
  { key: 'gender_expr', optional: true, q: 'What section of the store feels right?',
    pills: [["Women's",'women'],["Men's",'men'],['Unisex','unisex'],['Fluid — show me everything','fluid']] },
  { key: 'height_cm', optional: true, q: 'How tall are you?', kind: 'slider',
    min: 140, max: 210, default: 170, unit: 'cm' },
  { key: 'body_shape', q: 'Which silhouette feels closest?',
    pills: [
      ['Rectangle','rectangle'],['Hourglass','hourglass'],['Pear','pear'],
      ['Apple','apple'],['Inverted triangle','inv-triangle'],['Athletic','athletic'],
      ['Skip — I find this question annoying','skip']
    ]},
  { key: 'hair', optional: true, q: 'Your hair, right now?',
    pills: [
      ['Black, short','black-short'],['Black, long','black-long'],
      ['Brown, short','brown-short'],['Brown, long','brown-long'],
      ['Blonde, short','blonde-short'],['Blonde, long','blonde-long'],
      ['Red','red'],['Gray / silver','gray'],
      ['Shaved / very short','shaved'],['Other','other'],
    ]},
  { key: 'age_band', optional: true, q: 'Age band — for fit + cut only, never displayed.',
    pills: [['Under 25','u25'],['25–34','25-34'],['35–44','35-44'],['45–54','45-54'],['55–64','55-64'],['65+','65p']] },
  { key: 'undertone', optional: true, q: 'Your skin undertone?',
    pills: [
      ['Warm — gold jewelry suits me','warm'],
      ['Cool — silver jewelry suits me','cool'],
      ['Neutral — both work','neutral'],
      ['No idea — show me both','unknown'],
    ]},
  { key: 'city', q: 'Where are you shopping from?', kind: 'city' },
  { key: 'budget_band', q: 'Comfortable price per piece?',
    pills: [
      ['Under $20','u20'],['$20–50','20-50'],['$50–150','50-150'],
      ['$150–500','150-500'],['$500–1500','500-1500'],['$1500+','1500p'],
      ['No cap','nocap'],
    ]},
  { key: 'sustainability', optional: true, q: 'How much should sustainability matter?',
    pills: [['Top priority','top'],['Nice to have','nice'],["Doesn't matter to me",'no']] },
  { key: '_connect', optional: true, q: 'Want me to learn faster? (You can skip any of these.)', kind: 'connect' },
];
let onbI = 0;
const onbAnswers = {};

function onbRender() {
  const total = onbQuestions.length;
  $('#onb-bar').style.width = `${Math.round((onbI / total) * 100)}%`;
  const q = onbQuestions[onbI];
  if (!q) return finishOnboarding();
  // Mark optional questions with a hint so the user knows they can skip
  $('#onb-q').textContent = q.q + (q.optional ? '' : '');
  const opt = q.optional ? ' (optional)' : ' (we need this to dress you well)';
  // Hint shown to the side of the Skip button
  const skipBtn = document.querySelector('.onb-foot .ghost:nth-child(2)');
  if (skipBtn) skipBtn.textContent = q.optional ? 'Skip' : "Can't skip — required";
  if (skipBtn) skipBtn.disabled = !q.optional;
  const pills = $('#onb-pills');
  pills.innerHTML = '';
  if (q.pills) {
    q.pills.forEach(([label, val]) => {
      const b = document.createElement('button');
      b.className = 'pill';
      b.textContent = label;
      b.onclick = () => { onbAnswers[q.key] = val; onbNext(); };
      pills.appendChild(b);
    });
  } else if (q.kind === 'slider') {
    const wrap = document.createElement('div');
    wrap.style.cssText = 'display:flex;gap:14px;align-items:center;width:100%;';
    const out = document.createElement('div');
    out.style.cssText = 'font-family: var(--font-display); font-size:42px; min-width:120px;';
    const r = document.createElement('input');
    r.type = 'range'; r.min = q.min; r.max = q.max; r.value = q.default;
    r.style.cssText = 'flex:1;height:6px;';
    out.textContent = `${q.default} ${q.unit}`;
    r.oninput = () => { out.textContent = `${r.value} ${q.unit}`; };
    const ok = document.createElement('button');
    ok.className = 'pill'; ok.textContent = "That's me →";
    ok.onclick = () => { onbAnswers[q.key] = Number(r.value); onbNext(); };
    wrap.append(out, r);
    pills.append(wrap, ok);
  } else if (q.kind === 'city') {
    const wrap = document.createElement('div');
    wrap.style.cssText = 'display:flex;gap:10px;flex-direction:column;width:100%;';
    const inp = document.createElement('input');
    inp.placeholder = 'City, State';
    inp.style.cssText = 'padding:18px;border:1.5px solid var(--ink);border-radius:14px;font-size:18px;';
    const auto = document.createElement('button');
    auto.className = 'pill';
    auto.textContent = '📍 Use my location';
    auto.onclick = () => {
      navigator.geolocation?.getCurrentPosition(p => {
        onbAnswers.lat = p.coords.latitude;
        onbAnswers.lng = p.coords.longitude;
        inp.value = `(${p.coords.latitude.toFixed(2)}, ${p.coords.longitude.toFixed(2)})`;
      }, () => alert('Location denied. You can type a city instead.'));
    };
    const ok = document.createElement('button');
    ok.className = 'pill';
    ok.textContent = 'Continue →';
    ok.onclick = () => { onbAnswers.city = inp.value || null; onbNext(); };
    wrap.append(auto, inp, ok);
    pills.append(wrap);
  } else if (q.kind === 'connect') {
    const kinds = [
      ['Stripe — bank receipts (read-only)', 'stripe_fc'],
      ['Gmail — order confirmations (read-only)', 'gmail'],
      ['Camera roll — selected albums only', 'camera_roll'],
      ['Social photos — manual upload', 'social'],
      ['Location — for Stores Near Me', 'location'],
    ];
    kinds.forEach(([label, kind]) => {
      const b = document.createElement('button');
      b.className = 'pill illus';
      b.innerHTML = `<span class="swatch" style="background:linear-gradient(135deg,#c0392b,#e3725b)"></span>${label}`;
      b.onclick = async () => {
        b.textContent = '✓ ' + label;
        b.disabled = true;
        await fetch(`/api/connect/${kind}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
      };
      pills.appendChild(b);
    });
    const done = document.createElement('button');
    done.className = 'pill';
    done.textContent = "I'm done — show me the duels →";
    done.onclick = () => onbNext();
    pills.appendChild(done);
  }
}

function onbNext() { onbI++; onbRender(); }
function onbBack() { if (onbI > 0) { onbI--; onbRender(); } }
function onbSkip() { onbNext(); }

async function finishOnboarding() {
  // map onbAnswers → user fields
  const payload = {
    gender_expr: onbAnswers.gender_expr,
    age_band: onbAnswers.age_band,
    height_cm: onbAnswers.height_cm,
    body_shape: onbAnswers.body_shape,
    hair: onbAnswers.hair,
    undertone: onbAnswers.undertone,
    city: onbAnswers.city,
    lat: onbAnswers.lat,
    lng: onbAnswers.lng,
    budget_band: onbAnswers.budget_band,
    sustainability: onbAnswers.sustainability,
  };
  await fetch('/api/onboard', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) });
  route('home');
}

// ---------- Duel -----------------------------------------------------------
let currentDuel = null;
let duelsAnswered = 0;

async function loadDuelHero() {
  if (document.querySelector('#duel-hero .hero-cell')) return;  // only once
  try {
    const r = await fetch('/api/recommend?limit=8').then(r => r.json());
    const cells = (r.items || [])
      .filter(it => (it.sustain || 0) >= 4)
      .slice(0, 5)
      .map(it => `<div class="hero-cell" style="background-image:url('${it.image_url}')" title="${it.brand || ''}"></div>`)
      .join('');
    if (cells) {
      const el = document.getElementById('duel-hero');
      el.innerHTML = cells;
      el.hidden = false;
    }
  } catch {}
}

async function loadDuel() {
  if (avatarReady === false) await checkAvatarReady();  // first call only
  loadDuelHero();
  const r = await fetch('/api/duel');
  const j = await r.json();
  if (j.need_seed || !j.a) {
    $('#duel-q').innerHTML = 'No catalog yet — <a href="#" onclick="seedCatalog()">tap to seed sample items</a>.';
    return;
  }
  currentDuel = j;
  $('#duel-q').textContent = j.question;
  paintCard('a', j.a);
  paintCard('b', j.b);
  // Tick 27: prefetch next pair's images so the next tap is instant.
  if (j.next_pair) {
    [j.next_pair.a, j.next_pair.b].forEach(it => {
      if (it?.image_url) {
        const img = new Image();
        img.decoding = 'async';
        img.referrerPolicy = 'no-referrer';
        img.src = it.image_url;
      }
    });
  }
}

let avatarReady = false;
async function checkAvatarReady() {
  const r = await fetch('/api/avatar').then(r => r.json()).catch(() => ({}));
  avatarReady = r.avatar?.status === 'ready';
  return avatarReady;
}

function paintCard(side, item) {
  const img = $(`#img-${side}`);
  // Duel-on-me — if the user has a ready twin, try the rendered-on-me URL.
  // The server returns the rendered image OR queues + 202s; we fall back to
  // the catalog photo while the render lands.
  const fallback = item.image_url || `/img/placeholder-${item.category || 'top'}.svg`;
  if (avatarReady) {
    img.src = `/api/duel/on-me/${item.id}`;
    img.onerror = () => { img.onerror = null; img.src = fallback; };
  } else {
    img.src = fallback;
  }
  img.alt = `${item.title}${item.brand ? ' by ' + item.brand : ''}`;
  $(`#t-${side}`).textContent = item.title;
  const brandEl = $(`#b-${side}`);
  brandEl.innerHTML = '';
  if (item.brand) brandEl.appendChild(document.createTextNode(item.brand));
  if (item.sustain) {
    const s = document.createElement('span');
    s.className = 'sustain';
    s.title = `Sustainability ${item.sustain}/5`;
    s.setAttribute('aria-label', `Sustainability score ${item.sustain} of 5`);
    s.textContent = `♻ ${item.sustain}/5`;
    brandEl.appendChild(s);
  }
  $(`#p-${side}`).textContent = item.price_cents ? `$${(item.price_cents/100).toFixed(0)}` : '';
}

async function pickDuel(choice) {
  if (!currentDuel) return;
  const r = await fetch('/api/duel', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ duel_id: currentDuel.duel_id, picked: choice })
  });
  // Tick 24: surface top taste-axis movers as a toast.
  try { const j = await r.json(); if (j.deltas?.length) showDeltaToast(j.deltas); } catch {}
  duelsAnswered++;
  const pct = Math.min(100, Math.round((duelsAnswered / 30) * 100));
  $('#taste-fill').style.width = pct + '%';
  $('#taste-pct').textContent = pct + '%';
  const live = $('#duel-live');
  if (live) live.textContent = choice === 'skip' ? 'Skipped. New pair coming up.' : `Picked ${choice}. Your taste lens is ${pct}% sharper.`;
  loadDuel();
}

// Tick 28: keyboard cheat sheet — show until first keyboard pick.
const KB_HINT_KEY = 'wms.duel.kb_hint_dismissed';
function showKbHintIfNew() {
  const el = $('#duel-kb-hint');
  if (!el) return;
  if (localStorage.getItem(KB_HINT_KEY) === '1') { el.hidden = true; return; }
  // Desktop only — kbd hints make no sense on touch
  if (window.matchMedia && window.matchMedia('(hover: none) and (pointer: coarse)').matches) {
    el.hidden = true; return;
  }
  el.hidden = false;
}
function fadeKbHint() {
  const el = $('#duel-kb-hint');
  if (!el || el.hidden) return;
  localStorage.setItem(KB_HINT_KEY, '1');
  el.classList.add('is-fading');
  setTimeout(() => { el.hidden = true; el.classList.remove('is-fading'); }, 700);
}

window.addEventListener('keydown', e => {
  if ($('#home').hidden) return;
  let used = false;
  if (e.key === 'ArrowLeft' || e.key.toLowerCase() === 'a') { pickDuel('A'); used = true; }
  if (e.key === 'ArrowRight' || e.key.toLowerCase() === 'b') { pickDuel('B'); used = true; }
  if (e.key === ' ' || e.key.toLowerCase() === 's') { e.preventDefault(); pickDuel('skip'); used = true; }
  if (used) fadeKbHint();
});

async function seedCatalog() {
  $('#duel-q').textContent = 'Seeding sample catalog…';
  await fetch('/api/admin/seed', { method: 'POST' }).catch(() => {});
  loadDuel();
}

// ---------- Closet ---------------------------------------------------------
$('#closet-form').addEventListener('submit', async e => {
  e.preventDefault();
  const fileInput = e.target.querySelector('input[type=file]');
  const files = fileInput?.files || [];
  const status = $('#closet-upload-status');
  if (status) status.textContent = '';
  if (files.length === 0) return;
  // Tick 27: bulk path when >1 file selected.
  if (files.length === 1) {
    const fd = new FormData(e.target);
    if (status) status.textContent = 'uploading…';
    const r = await fetch('/api/closet/photo', { method: 'POST', body: fd });
    if (r.ok) { if (status) status.textContent = 'done.'; loadCloset(); }
    else if (status) status.textContent = 'failed.';
    return;
  }
  // Multi: re-build FormData with photos[] field name + production_id passthrough.
  const fd = new FormData();
  for (const f of files) fd.append('photos', f);
  const pid = $('#closet-prod-select')?.value;
  if (pid) fd.append('production_id', pid);
  if (status) status.textContent = `uploading ${files.length}…`;
  const r = await fetch('/api/closet/photo/bulk', { method: 'POST', body: fd });
  if (r.ok) {
    const j = await r.json();
    if (status) status.textContent = `uploaded ${j.count}. analyzing in background.`;
    loadCloset();
    // Tick 28: per-file progress dots — one gray dot per upload, fills green
    // as each row's vision-tag (category) lands. Polls /api/closet every 5s.
    if (j.closet_ids?.length) startBulkProgress(j.closet_ids);
  } else if (status) status.textContent = 'bulk upload failed.';
});

// Tick 28: per-file progress dots for bulk closet uploads.
let bulkProgressTimer = null;
function startBulkProgress(ids) {
  if (bulkProgressTimer) { clearInterval(bulkProgressTimer); bulkProgressTimer = null; }
  if (!ids?.length) return;
  let host = $('#closet-bulk-progress');
  if (!host) {
    host = document.createElement('div');
    host.id = 'closet-bulk-progress';
    host.className = 'bulk-progress';
    const status = $('#closet-upload-status');
    (status?.parentNode || $('#closet-form')).insertBefore(host, status?.nextSibling || null);
  }
  host.hidden = false;
  host.classList.remove('is-dismissed');
  host.innerHTML = ids.map(id => `<span class="dot" data-cid="${id}" aria-label="processing ${id}"></span>`).join('') +
                   `<span class="dot-progress-label" id="bulk-progress-label">0 / ${ids.length} tagged</span>` +
                   `<button type="button" class="bulk-progress-close" id="bulk-progress-close" aria-label="Dismiss progress strip">×</button>`;
  $('#bulk-progress-close')?.addEventListener('click', () => {
    if (bulkProgressTimer) { clearInterval(bulkProgressTimer); bulkProgressTimer = null; }
    host.classList.add('is-dismissed');
    setTimeout(() => { host.hidden = true; }, 250);
  });
  const idSet = new Set(ids);
  async function tick() {
    try {
      const r = await fetch('/api/closet').then(r => r.json());
      const map = new Map((r.items || []).map(i => [i.id, i]));
      let tagged = 0;
      ids.forEach(id => {
        const it = map.get(id);
        const dot = host.querySelector(`.dot[data-cid="${id}"]`);
        if (!dot) return;
        if (it?.category) { dot.classList.add('is-done'); tagged++; }
      });
      const label = $('#bulk-progress-label');
      if (label) label.textContent = `${tagged} / ${ids.length} tagged`;
      if (tagged >= ids.length) {
        clearInterval(bulkProgressTimer); bulkProgressTimer = null;
        // Auto-hide the strip 4s after fully tagged so the closet grid takes the focus.
        setTimeout(() => { host.hidden = true; }, 4000);
      }
    } catch {}
  }
  tick();
  bulkProgressTimer = setInterval(tick, 5000);
}
async function loadCloset() {
  const r = await fetch('/api/closet').then(r => r.json());
  const g = $('#closet-grid');
  g.innerHTML = '';
  // Tick 26: hide the "upload a photo…" empty-state and shrink the upload
  // form to a compact "+ Add another" button once the closet has ≥3 items.
  const count = (r.items || []).length;
  const isCompact = count >= 3;
  $('#closet-empty-state')?.toggleAttribute('hidden', isCompact);
  $('#closet-form')?.classList.toggle('compact', isCompact);
  if ($('#closet-upload-btn')) $('#closet-upload-btn').textContent = isCompact ? '+ Add another' : 'Upload';
  (r.items || []).forEach(it => {
    const c = document.createElement('div');
    c.className = 'card';
    const isPrivate = (it.privacy || 'shared') === 'private';
    // Tick 19: production-credit badge — set-decorator role only, item must have ≥1 credit.
    const showBadge = (Role.effective === 'set_decorator' || Role.role === 'set_decorator') && (it.production_credits || 0) > 0;
    const badge = showBadge ? `<span class="prod-credit-badge" title="Used in ${it.production_credits} production${it.production_credits > 1 ? 's' : ''}">★ ${it.production_credits}</span>` : '';
    const favOn = isFav('closet', it.id);
    const star = `<button type="button" class="star-btn ${favOn ? 'is-fav' : ''}" data-fav-kind="closet" data-fav-id="${it.id}" aria-label="${favOn ? 'Unfavorite' : 'Favorite'}">${favOn ? '★' : '☆'}</button>`;
    c.innerHTML = `
      <img src="/api/closet/photo/${it.id}" alt="">
      ${badge}
      ${star}
      <div class="meta">
        <div class="title">${it.category || 'analyzing…'}</div>
        <div class="brand">${it.color || ''}</div>
        <div class="meta-row">
          <a class="credits-pill" href="#" data-credits-kind="closet" data-credits-id="${it.id}">credits →</a>
          <button type="button" class="privacy-pill ${isPrivate ? 'is-private' : 'is-shared'}" data-closet-id="${it.id}" data-privacy="${isPrivate ? 'private' : 'shared'}" aria-label="Toggle item privacy">${isPrivate ? '🔒 private' : '· shared'}</button>
        </div>
      </div>`;
    g.appendChild(c);
  });
}

// Closet privacy pill toggle — tick 15.8. Click flips shared ↔ private.
document.addEventListener('click', async e => {
  const btn = e.target.closest('.privacy-pill');
  if (!btn) return;
  e.preventDefault();
  const id = btn.dataset.closetId;
  const next = btn.dataset.privacy === 'private' ? 'shared' : 'private';
  const r = await fetch(`/api/closet/${id}/privacy`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ privacy: next }) });
  if (r.ok) {
    btn.dataset.privacy = next;
    btn.className = `privacy-pill ${next === 'private' ? 'is-private' : 'is-shared'}`;
    btn.textContent = next === 'private' ? '🔒 private' : '· shared';
  }
});

// ---------- Recommendations -----------------------------------------------
// ---- Tailor the view (tick 16) ------------------------------------------
// Four sliders re-rank the recs grid to match the show being decorated.
// Era → era hint match. Palette → saturation bias. Tone → formal/casual bias.
// Density → CSS card-min. Persisted to localStorage; only shown when
// effective_role === 'set_decorator'.
const TAILOR_DEFAULTS = { era: 1975, palette: 93, density: 277, tone: 73 };

function getTailor() {
  return {
    era:     Number(localStorage.getItem('wms.tailor.era')     || TAILOR_DEFAULTS.era),
    palette: Number(localStorage.getItem('wms.tailor.palette') || TAILOR_DEFAULTS.palette),
    density: Number(localStorage.getItem('wms.tailor.density') || TAILOR_DEFAULTS.density),
    tone:    Number(localStorage.getItem('wms.tailor.tone')    || TAILOR_DEFAULTS.tone),
  };
}

// Heuristic era extraction from item title/brand. Most catalog items have no
// year, so we map common era words to a representative decade.
const ERA_WORDS = [
  [/\b(victorian|edwardian)\b/i, 1900],
  [/\b(1920s|art deco|gatsby|jazz age)\b/i, 1925],
  [/\b(1930s|depression|hollywood golden)\b/i, 1935],
  [/\b(1940s|war[- ]era|forties)\b/i, 1945],
  [/\b(1950s|fifties|midcentury|mid[- ]?century|atomic)\b/i, 1955],
  [/\b(1960s|sixties|mod|psychedelic|space age)\b/i, 1965],
  [/\b(1970s|seventies|disco|bohemian|hippie)\b/i, 1975],
  [/\b(1980s|eighties|new wave|preppy revival|power)\b/i, 1985],
  [/\b(1990s|nineties|grunge|minimal[- ]?ist)\b/i, 1995],
  [/\b(y2k|early 2000s|2000s)\b/i, 2005],
  [/\b(2010s)\b/i, 2015],
  [/\b(modern|contemporary|current|today)\b/i, 2025],
  [/\bvintage\b/i, 1965],   // generic vintage → mid-60s
  [/\bretro\b/i, 1975],     // generic retro → 70s
];
function eraOf(it) {
  const text = `${it.title || ''} ${it.brand || ''} ${it.tags || ''}`;
  for (const [re, year] of ERA_WORDS) if (re.test(text)) return year;
  return null;  // unknown — neutral score
}

const PALETTE_SATURATED = /\b(red|crimson|cobalt|emerald|fuchsia|magenta|electric|neon|vibrant|saffron|chartreuse|tangerine|hot pink|royal blue)\b/i;
const PALETTE_NEUTRAL   = /\b(beige|stone|ivory|cream|oat|sand|taupe|nude|mushroom|greige|bone|natural)\b/i;
const TONE_FORMAL = /\b(blazer|tuxedo|gown|silk|wool|tweed|cashmere|tailored|suit|oxford|loafer|trouser|evening|cocktail)\b/i;
const TONE_CASUAL = /\b(tee|t-shirt|hoodie|sweatpant|jean|denim|sneaker|track|jogger|cargo|baseball)\b/i;

function tailorScore(it, tailor) {
  let s = 0;
  // Era — primary axis. Tested 2026-05-12: with weight 1.0, saturated+formal
  // items outranked era-exact matches at high palette/tone settings. Era is
  // *the* signal for "what show is this", so bump to 1.5 so era-exact wins
  // even when palette+tone both hit (max combined = 0.8 + 0.7 = 1.5).
  const yr = eraOf(it);
  if (yr !== null) {
    const delta = Math.abs(yr - tailor.era);
    s += Math.max(0, 1 - delta / 25) * 1.5;
  }
  const text = `${it.title || ''} ${it.brand || ''} ${it.color || ''} ${it.tags || ''}`;
  // Palette — high palette → saturated bonus, low palette → neutral bonus
  const sat = PALETTE_SATURATED.test(text) ? 1 : 0;
  const neu = PALETTE_NEUTRAL.test(text) ? 1 : 0;
  const palT = tailor.palette / 100;          // 0..1
  s += sat * palT * 0.8 + neu * (1 - palT) * 0.8;
  // Tone — high tone → formal bonus, low tone → casual bonus
  const fml = TONE_FORMAL.test(text) ? 1 : 0;
  const csl = TONE_CASUAL.test(text) ? 1 : 0;
  const tonT = tailor.tone / 100;
  s += fml * tonT * 0.7 + csl * (1 - tonT) * 0.7;
  return s;
}

async function loadRecs() {
  const sort = localStorage.getItem('wms.sort') || 'taste';
  const isSetDec = (Role.effective === 'set_decorator' || Role.role === 'set_decorator');
  const tailor = getTailor();
  // Density: set-decorator's Tailor slider wins; otherwise the legacy slider.
  const minPx = isSetDec ? tailor.density : (Number(localStorage.getItem('wms.density')) || 220);

  $('#sort').value = sort;
  $('#density').value = Math.min(320, Math.max(120, minPx));  // legacy slider clamped to its range

  // Mirror tailor values into the panel inputs.
  if (isSetDec) {
    const ids = ['era', 'palette', 'density', 'tone'];
    ids.forEach(k => {
      const inp = $(`#tailor-${k}`);
      const out = $(`#tailor-${k}-val`);
      if (inp) inp.value = tailor[k];
      if (out) out.textContent = tailor[k];
    });
  }

  // Set-grade only: persisted toggle, default ON for set-decorator role.
  let proOnly = localStorage.getItem('wms.proOnly');
  if (proOnly === null && isSetDec) proOnly = '1';
  const proOnlyChecked = proOnly === '1';
  if ($('#set-grade-only')) $('#set-grade-only').checked = proOnlyChecked;
  const proQS = proOnlyChecked && isSetDec ? '&pro_only=1' : '';
  // Tick 23: color + material facet filters from localStorage.
  const activeColors = JSON.parse(localStorage.getItem('wms.facets.colors') || '[]');
  const activeMaterials = JSON.parse(localStorage.getItem('wms.facets.materials') || '[]');
  const colorQS = activeColors.length ? `&colors=${encodeURIComponent(activeColors.join(','))}` : '';
  const matQS   = activeMaterials.length ? `&materials=${encodeURIComponent(activeMaterials.join(','))}` : '';
  // Tick 30: server-side sort for price/sustain/newest. Client-side sort kept for
  // brand/title (cheap string sort on the 60-item slice) and 'taste' (default).
  const SERVER_SORTS = new Set(['price_asc', 'price_desc', 'sustain', 'newest']);
  const serverSort = SERVER_SORTS.has(sort) ? sort : 'best';
  const sortQS = `&sort=${encodeURIComponent(serverSort)}`;
  // Tick 31: brand filter from localStorage (set by /brands → ?brand= deep link).
  const brandFilter = (localStorage.getItem('wms.brand_filter') || '').trim();
  const brandQS = brandFilter ? `&brand=${encodeURIComponent(brandFilter)}` : '';
  // Render brand-filter pill (clearable).
  const bfr = $('#brand-filter-row');
  if (bfr) {
    if (brandFilter) {
      bfr.hidden = false;
      bfr.innerHTML = `<button type="button" class="facet-pill is-active" id="clear-brand-filter">Brand: ${escapeHtml(brandFilter)} <span style="margin-left: 6px;">×</span></button>`;
    } else {
      bfr.hidden = true;
      bfr.innerHTML = '';
    }
  }
  const r = await fetch(`/api/recommend?limit=60${proQS}${colorQS}${matQS}${sortQS}${brandQS}`).then(r => r.json());
  // Render the facet pill row.
  const facetsRow = $('#facets-row');
  if (facetsRow && r.facets) {
    const pill = (kind, value, count, active) => `
      <button type="button" class="facet-pill ${active ? 'is-active' : ''}" data-facet-kind="${kind}" data-facet-value="${value.replace(/"/g, '&quot;')}">
        ${value} <span class="facet-count">${count}</span>
      </button>`;
    const html = [
      ...r.facets.colors.map(c => pill('color', c.value, c.count, activeColors.includes(c.value))),
      ...r.facets.materials.map(m => pill('material', m.value, m.count, activeMaterials.includes(m.value))),
    ].join('');
    facetsRow.innerHTML = html;
    facetsRow.hidden = !html;
  }
  let items = r.items || [];
  // Tick 30: price_asc/price_desc/sustain/newest are now server-sorted; respect that order.
  // Keep brand/title as client-side string sort (server doesn't expose those).
  if (sort === 'brand') items.sort((a, b) => (a.brand || '').localeCompare(b.brand || ''));
  else if (sort === 'title') items.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
  // For set decorators on Best Match, apply Tailor re-rank on top of taste order.
  if (isSetDec && sort === 'taste') {
    items.forEach((it, i) => { it._origIdx = i; it._tailorScore = tailorScore(it, tailor); });
    items.sort((a, b) => {
      // Tailor score dominates; ties broken by original taste order.
      if (b._tailorScore !== a._tailorScore) return b._tailorScore - a._tailorScore;
      return a._origIdx - b._origIdx;
    });
  }
  const g = $('#recs-grid');
  g.style.setProperty('--card-min', minPx + 'px');
  g.innerHTML = '';
  // Tick 31: empty-state — if no items, show a friendly message + clear-filters button.
  const empty = $('#recs-empty');
  const anyFilter = activeColors.length || activeMaterials.length || proOnlyChecked || brandFilter;
  if (!items.length && empty) {
    empty.hidden = false;
    $('#recs-empty-msg').textContent = anyFilter
      ? 'No matches with your current filters.'
      : 'No catalog yet — your duels are seeding it.';
    $('#recs-empty-clear').hidden = !anyFilter;
  } else if (empty) {
    empty.hidden = true;
  }
  // Tick 32: inline embed-status pill — admin only, surfaces drainer backlog.
  refreshRecsEmbedStatus();
  items.forEach(it => {
    const a = document.createElement('a');
    a.className = 'card';
    a.href = it.product_url || '#';
    a.target = '_blank';
    a.rel = 'noopener';
    const sustain = it.sustain ? `<span class="sustain" aria-label="Sustainability ${it.sustain} of 5">♻ ${it.sustain}/5</span>` : '';
    const showBadge = (Role.effective === 'set_decorator' || Role.role === 'set_decorator') && (it.production_credits || 0) > 0;
    const badge = showBadge ? `<span class="prod-credit-badge" title="Used in ${it.production_credits} production${it.production_credits > 1 ? 's' : ''}">★ ${it.production_credits}</span>` : '';
    const favOn = isFav('catalog', it.id);
    const star = `<button type="button" class="star-btn ${favOn ? 'is-fav' : ''}" data-fav-kind="catalog" data-fav-id="${it.id}" aria-label="${favOn ? 'Unfavorite' : 'Favorite'}">${favOn ? '★' : '☆'}</button>`;
    const brandLink = it.brand ? `<a href="#" data-brand="${escapeHtml(it.brand)}" onclick="event.stopPropagation();">${escapeHtml(it.brand)}</a>` : '';
    a.innerHTML = `<img src="${it.image_url || ''}" alt="${it.title}${it.brand ? ' by ' + it.brand : ''}" loading="lazy">${badge}${star}<div class="meta"><div class="title">${it.title}</div><div class="brand">${brandLink}${sustain}</div><div class="price">${it.price_cents ? '$' + (it.price_cents/100).toFixed(0) : ''}</div><a class="credits-pill" href="#" data-credits-kind="catalog" data-credits-id="${it.id}" onclick="event.stopPropagation();">view credits →</a></div>`;
    g.appendChild(a);
  });
}
$('#sort').addEventListener('change', e => { localStorage.setItem('wms.sort', e.target.value); loadRecs(); });
$('#density').addEventListener('input', e => { localStorage.setItem('wms.density', e.target.value); loadRecs(); });

// Tick 31: brand filter on boot — read ?brand= and stash; clear-brand-filter pill resets.
// Tick 32: when ?brand= is set on boot, also force route to /recs so the
// filter is immediately visible (deep-link from /brands → main app).
let __brandDeepLink = false;
(function hydrateBrandFilterFromURL() {
  try {
    const params = new URLSearchParams(location.search);
    const b = (params.get('brand') || '').trim();
    if (b) {
      localStorage.setItem('wms.brand_filter', b);
      __brandDeepLink = true;
      params.delete('brand');
      const qs = params.toString();
      history.replaceState(null, '', location.pathname + (qs ? `?${qs}` : '') + location.hash);
    }
  } catch {}
})();

// Tick 32: admin-only inline embed-status pill above the recs grid.
async function refreshRecsEmbedStatus() {
  const el = $('#recs-embed-status');
  if (!el) return;
  if (!Role.is_admin || Role.view_as) { el.hidden = true; return; }
  try {
    const r = await fetch('/api/admin/catalog-status');
    if (!r.ok) { el.hidden = true; return; }
    const j = await r.json();
    if (!j.total) { el.hidden = true; return; }
    const pct = Math.round((j.embedded / j.total) * 100);
    $('#recs-embed-text').textContent = `${j.embedded.toLocaleString()} embedded of ${j.total.toLocaleString()} catalog · ${pct}%`;
    $('#recs-embed-fill').style.width = pct + '%';
    el.hidden = false;
  } catch { el.hidden = true; }
}

document.addEventListener('click', e => {
  if (e.target.closest('#clear-brand-filter')) {
    localStorage.removeItem('wms.brand_filter');
    loadRecs();
  }
  if (e.target.closest('#recs-empty-clear')) {
    localStorage.removeItem('wms.facets.colors');
    localStorage.removeItem('wms.facets.materials');
    localStorage.removeItem('wms.proOnly');
    localStorage.removeItem('wms.brand_filter');
    loadRecs();
  }
});

// Tick 31: share-this-list — POST current state, copy URL to clipboard.
$('#recs-share-btn')?.addEventListener('click', async () => {
  const out = $('#recs-share-out');
  out.textContent = 'Generating…';
  const state = {
    sort: localStorage.getItem('wms.sort') || 'best',
    colors: JSON.parse(localStorage.getItem('wms.facets.colors') || '[]'),
    materials: JSON.parse(localStorage.getItem('wms.facets.materials') || '[]'),
    proOnly: localStorage.getItem('wms.proOnly') === '1',
    brand: (localStorage.getItem('wms.brand_filter') || '').trim(),
  };
  const SERVER_SORTS = new Set(['price_asc', 'price_desc', 'sustain', 'newest']);
  if (!SERVER_SORTS.has(state.sort)) state.sort = 'best';
  try {
    const r = await fetch('/api/share/recs', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify(state),
    });
    const j = await r.json();
    if (!r.ok) { out.textContent = j.error || 'failed'; return; }
    const full = `${location.origin}${j.url}`;
    try { await navigator.clipboard.writeText(full); out.innerHTML = `Copied — <a href="${j.url}" target="_blank" rel="noopener">${j.url}</a>`; }
    catch { out.innerHTML = `<a href="${j.url}" target="_blank" rel="noopener">${j.url}</a>`; }
  } catch (e) { out.textContent = 'failed'; }
});

// Tailor sliders — live update output + localStorage + reflow grid.
['era', 'palette', 'density', 'tone'].forEach(k => {
  const inp = $(`#tailor-${k}`);
  const out = $(`#tailor-${k}-val`);
  if (!inp || !out) return;
  inp.addEventListener('input', e => {
    out.textContent = e.target.value;
    localStorage.setItem(`wms.tailor.${k}`, e.target.value);
    if (k === 'density') {
      // Density-only changes: just update CSS, no need to re-fetch + re-sort.
      $('#recs-grid')?.style.setProperty('--card-min', e.target.value + 'px');
    } else {
      loadRecs();
    }
  });
});
$('#tailor-reset')?.addEventListener('click', () => {
  for (const [k, v] of Object.entries(TAILOR_DEFAULTS)) {
    localStorage.setItem(`wms.tailor.${k}`, v);
  }
  loadRecs();
});

// ---------- Stores ---------------------------------------------------------
async function requestLocation() {
  navigator.geolocation?.getCurrentPosition(async p => {
    const r = await fetch(`/api/stores?lat=${p.coords.latitude}&lng=${p.coords.longitude}`).then(r => r.json()).catch(() => ({stores: []}));
    const list = $('#stores-list');
    list.innerHTML = '';
    (r.stores || []).forEach(s => {
      const d = document.createElement('div');
      d.className = 'conn';
      d.innerHTML = `<strong>${s.name}</strong><small>${s.address || ''} · ${s.distance_mi?.toFixed?.(1) || ''}mi</small>`;
      list.appendChild(d);
    });
    if (!r.stores?.length) list.innerHTML = '<p class="muted">No stores indexed yet — like a few brands first.</p>';
  }, () => alert('Location denied.'));
}

// ---------- Connections ---------------------------------------------------
$$('.conn').forEach(b => b.addEventListener('click', async () => {
  const kind = b.dataset.kind;
  await fetch(`/api/connect/${kind}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
  const cs = await fetch('/api/connect').then(r => r.json());
  $('#connect-state').textContent = JSON.stringify(cs.connections, null, 2);
}));

// ---------- Avatar --------------------------------------------------------
// Drag-drop friendly upload widget. Files staged client-side (drag-drop,
// file picker, OR Google Drive) — user clicks Upload to send the batch as
// multipart `photos[]` to POST /api/avatar/photos (unchanged backend contract).
//
// PICKER NEEDS: GOOGLE_PICKER_API_KEY, GOOGLE_CLIENT_ID, GOOGLE_DRIVE_APP_ID
// — see ~/Projects/secrets-manager. If env not configured, GDrive button hides.
const avatarStaged = [];   // [{ file: File, url: ObjectURL, source: 'local'|'drive' }]
let gpickerCfg = null;
let gpickerLoaded = false;
let gpickerTokenClient = null;
let gpickerAccessToken = null;

const dz = $('#avatar-dropzone');
const fileInput = $('#avatar-file-input');
const thumbRow = $('#avatar-thumbs');
const counter = $('#avatar-counter');
const uploadBtn = $('#avatar-upload-btn');
const gdriveBtn = $('#avatar-gdrive-btn');

function renderAvatarThumbs() {
  thumbRow.innerHTML = '';
  avatarStaged.forEach((s, i) => {
    const t = document.createElement('div');
    t.className = 'thumb';
    t.innerHTML = `<img src="${s.url}" alt="staged photo ${i+1}"><button class="x" type="button" aria-label="Remove photo ${i+1}">×</button><span class="src-tag">${s.source === 'drive' ? 'DRIVE' : 'LOCAL'}</span>`;
    t.querySelector('.x').onclick = () => {
      try { URL.revokeObjectURL(s.url); } catch {}
      avatarStaged.splice(i, 1);
      renderAvatarThumbs();
    };
    thumbRow.appendChild(t);
  });
  const n = avatarStaged.length;
  counter.classList.remove('counter-empty', 'counter-ok', 'counter-over');
  if (n === 0)      { counter.textContent = '0 / 5 photos · drop or pick to add';    counter.classList.add('counter-empty'); }
  else if (n < 3)   { counter.textContent = `${n} / 5 photos · add ${3-n} more`;     counter.classList.add('counter-empty'); }
  else if (n <= 5)  { counter.textContent = `${n} / 5 photos · click upload to send`; counter.classList.add('counter-ok'); }
  else              { counter.textContent = `${n} / 5 photos · over the limit — remove ${n-5}`; counter.classList.add('counter-over'); }
  uploadBtn.disabled = n === 0;
}

function addAvatarFiles(files, source = 'local') {
  for (const f of files) {
    if (!f || !f.type || !f.type.startsWith('image/')) continue;
    avatarStaged.push({ file: f, url: URL.createObjectURL(f), source });
  }
  renderAvatarThumbs();
}

// dropzone — click + keyboard open file picker, drag-drop accepts files
dz.addEventListener('click', () => fileInput.click());
dz.addEventListener('keydown', e => {
  if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); }
});
fileInput.addEventListener('change', e => {
  addAvatarFiles(e.target.files, 'local');
  fileInput.value = '';
});
['dragenter','dragover'].forEach(ev => dz.addEventListener(ev, e => {
  e.preventDefault(); e.stopPropagation();
  dz.classList.add('dragover');
}));
['dragleave','drop'].forEach(ev => dz.addEventListener(ev, e => {
  e.preventDefault(); e.stopPropagation();
  if (ev === 'dragleave' && dz.contains(e.relatedTarget)) return;
  dz.classList.remove('dragover');
}));
dz.addEventListener('drop', e => {
  const files = e.dataTransfer?.files || [];
  if (files.length) addAvatarFiles(files, 'local');
});
// also catch document-level drag so the browser doesn't navigate away
['dragover','drop'].forEach(ev => window.addEventListener(ev, e => {
  if (e.target.closest && e.target.closest('#avatar-dropzone')) return;
  e.preventDefault();
}));

// upload button — sends staged set as multipart photos[] (unchanged backend)
uploadBtn.addEventListener('click', async () => {
  if (!avatarStaged.length) return;
  uploadBtn.disabled = true;
  $('#avatar-status').textContent = `Uploading ${avatarStaged.length} photo${avatarStaged.length===1?'':'s'}…`;
  const fd = new FormData();
  avatarStaged.forEach((s, i) => {
    // Server side reads `photos` field (FormData multiple). Use original name where possible.
    const name = s.file.name || `photo-${i+1}.jpg`;
    fd.append('photos', s.file, name);
  });
  try {
    const r = await fetch('/api/avatar/photos', { method: 'POST', body: fd }).then(r => r.json());
    $('#avatar-status').textContent = `${r.photo_count} photo${r.photo_count===1?'':'s'} uploaded. Click "Build my avatar" when ready.`;
    // clear staging on success
    avatarStaged.forEach(s => { try { URL.revokeObjectURL(s.url); } catch {} });
    avatarStaged.length = 0;
    renderAvatarThumbs();
  } catch (err) {
    $('#avatar-status').textContent = 'Upload failed — try again.';
    uploadBtn.disabled = false;
  }
});

$('#avatar-build-btn').addEventListener('click', async () => {
  $('#avatar-status').textContent = 'Building your digital twin… this can take 1–2 min.';
  await fetch('/api/avatar/build', { method: 'POST' });
  pollAvatar();
});

// ---------- Google Drive Picker ------------------------------------------
// Loads gapi + GIS from CDN on demand. Requires GOOGLE_PICKER_API_KEY +
// GOOGLE_CLIENT_ID + GOOGLE_DRIVE_APP_ID env-vars exposed via /api/public-config.
// If any are missing, the GDrive button stays hidden (graceful degrade).
async function bootGoogleDriveButton() {
  try {
    const cfg = await fetch('/api/public-config').then(r => r.json());
    gpickerCfg = cfg.google_picker || {};
    if (gpickerCfg.api_key && gpickerCfg.client_id && gpickerCfg.app_id) {
      gdriveBtn.hidden = false;
    }
  } catch {}
}
bootGoogleDriveButton();

function loadScript(src) {
  return new Promise((resolve, reject) => {
    if (document.querySelector(`script[src="${src}"]`)) return resolve();
    const s = document.createElement('script');
    s.src = src; s.async = true; s.defer = true;
    s.onload = resolve; s.onerror = () => reject(new Error('script load failed: ' + src));
    document.head.appendChild(s);
  });
}

async function ensurePickerLoaded() {
  if (gpickerLoaded) return;
  await loadScript('https://apis.google.com/js/api.js');
  await new Promise(resolve => window.gapi.load('picker', { callback: resolve }));
  await loadScript('https://accounts.google.com/gsi/client');
  gpickerTokenClient = window.google.accounts.oauth2.initTokenClient({
    client_id: gpickerCfg.client_id,
    scope: 'https://www.googleapis.com/auth/drive.readonly',
    callback: () => {},  // overridden per-request
  });
  gpickerLoaded = true;
}

async function getDriveAccessToken() {
  if (gpickerAccessToken) return gpickerAccessToken;
  return new Promise((resolve, reject) => {
    gpickerTokenClient.callback = (resp) => {
      if (resp.error) return reject(new Error(resp.error));
      gpickerAccessToken = resp.access_token;
      resolve(gpickerAccessToken);
    };
    gpickerTokenClient.requestAccessToken({ prompt: '' });
  });
}

async function openGoogleDrivePicker() {
  if (!gpickerCfg || !gpickerCfg.api_key) return;
  try {
    $('#avatar-status').textContent = 'Opening Google Drive picker…';
    await ensurePickerLoaded();
    const token = await getDriveAccessToken();
    const view = new window.google.picker.View(window.google.picker.ViewId.DOCS_IMAGES);
    const picker = new window.google.picker.PickerBuilder()
      .enableFeature(window.google.picker.Feature.MULTISELECT_ENABLED)
      .setOAuthToken(token)
      .setDeveloperKey(gpickerCfg.api_key)
      .setAppId(gpickerCfg.app_id)
      .addView(view)
      .setCallback(async (data) => {
        if (data.action !== window.google.picker.Action.PICKED) return;
        $('#avatar-status').textContent = `Downloading ${data.docs.length} from Drive…`;
        for (const doc of data.docs) {
          try {
            const r = await fetch(`https://www.googleapis.com/drive/v3/files/${doc.id}?alt=media`, {
              headers: { Authorization: `Bearer ${token}` },
            });
            const blob = await r.blob();
            const file = new File([blob], doc.name || `drive-${doc.id}.jpg`, { type: blob.type || 'image/jpeg' });
            addAvatarFiles([file], 'drive');
          } catch (e) {
            console.warn('Drive download failed for', doc.id, e);
          }
        }
        $('#avatar-status').textContent = `Staged ${avatarStaged.length} photo${avatarStaged.length===1?'':'s'} — click Upload when ready.`;
      })
      .build();
    picker.setVisible(true);
  } catch (e) {
    $('#avatar-status').textContent = 'Drive picker failed: ' + (e.message || e);
  }
}
gdriveBtn.addEventListener('click', openGoogleDrivePicker);
async function pollAvatar() {
  for (let i = 0; i < 60; i++) {
    const r = await fetch('/api/avatar').then(r => r.json());
    if (r.avatar?.status === 'ready') {
      $('#avatar-status').textContent = '✓ Avatar ready. You can now "Try this on me" anywhere in the app.';
      $('#avatar-preview').innerHTML = `<img src="/api/avatar/render?t=${Date.now()}" style="max-width:340px;border-radius:18px;box-shadow:var(--shadow);margin-top:14px;">`;
      return;
    }
    if (r.avatar?.status === 'failed') {
      $('#avatar-status').textContent = 'Avatar build failed. Try again with clearer photos.';
      return;
    }
    await new Promise(r => setTimeout(r, 4000));
  }
}

// ---------- Try-on (from duel / recs / closet) ----------------------------
async function tryOnItem({ item_id, closet_id, mode = 'avatar', occasion, source_photo_file, production_id }) {
  const fd = new FormData();
  if (item_id) fd.append('item_id', item_id);
  if (closet_id) fd.append('closet_id', closet_id);
  fd.append('mode', mode);
  if (occasion) fd.append('occasion', occasion);
  if (source_photo_file) fd.append('source_photo', source_photo_file);
  // Auto-inherit the active production for set-decorator context unless the
  // caller passed one explicitly.
  const pid = production_id || getActiveProductionId();
  if (pid) fd.append('production_id', pid);
  const r = await fetch('/api/tryon', { method: 'POST', body: fd }).then(r => r.json());
  if (r.error) { alert(r.error + (r.hint ? ' — ' + r.hint : '')); return null; }
  return r.job_id;
}

async function pollTryon(jobId, onUpdate) {
  for (let i = 0; i < 90; i++) {
    const j = await fetch(`/api/tryon/${jobId}`).then(r => r.json());
    onUpdate(j.job);
    if (j.job.status === 'done' || j.job.status === 'failed') return j.job;
    await new Promise(r => setTimeout(r, 3000));
  }
}

// ---------- Time-Travel ---------------------------------------------------
let ttClosetPick = null;
let ttPhotoFile = null;
let ttOccasion = null;
async function loadTimeTravel() {
  const r = await fetch('/api/closet').then(r => r.json());
  const grid = $('#tt-closet');
  grid.innerHTML = '';
  if (!r.items?.length) {
    grid.innerHTML = '<p class="muted">No closet items yet. Upload a closet photo first (in My Closet).</p>';
    return;
  }
  r.items.forEach(it => {
    const c = document.createElement('button');
    c.className = 'card';
    c.style.cursor = 'pointer';
    c.innerHTML = `<img src="/api/closet/photo/${it.id}" alt=""><div class="meta"><div class="title">${it.category || '—'}</div><div class="brand">${it.color || ''}</div></div>`;
    c.onclick = () => {
      $$('#tt-closet .card').forEach(x => x.classList.remove('tt-closet-pick'));
      c.classList.add('tt-closet-pick');
      ttClosetPick = it.id;
      checkTTReady();
    };
    grid.appendChild(c);
  });
}
$('#tt-photo').addEventListener('change', e => {
  ttPhotoFile = e.target.files[0];
  if (ttPhotoFile) {
    $('#tt-photo-preview').src = URL.createObjectURL(ttPhotoFile);
    $('#tt-photo-preview').style.display = 'block';
  }
  checkTTReady();
});
$$('#timetravel .pills .pill').forEach(p => p.addEventListener('click', () => {
  $$('#timetravel .pills .pill').forEach(x => x.style.background = '');
  p.style.background = 'var(--ink)';
  p.style.color = 'var(--paper)';
  ttOccasion = p.dataset.occ;
  $('#tt-occ').value = ttOccasion;
  checkTTReady();
}));
$('#tt-occ').addEventListener('input', e => { ttOccasion = e.target.value; checkTTReady(); });
function checkTTReady() {
  $('#tt-go').disabled = !(ttClosetPick && ttPhotoFile && ttOccasion);
}
$('#tt-go').addEventListener('click', async () => {
  $('#tt-result').innerHTML = '<div class="tryon-pending">Re-dressing past-you… (this can take a minute)</div>';
  const jobId = await tryOnItem({
    closet_id: ttClosetPick,
    mode: 'closet_to_oldphoto',
    occasion: ttOccasion,
    source_photo_file: ttPhotoFile,
  });
  if (!jobId) return;
  pollTryon(jobId, job => {
    if (job.status === 'done') {
      $('#tt-result').innerHTML = `<div class="tryon-result"><img src="/api/tryon/${jobId}/image?t=${Date.now()}"><p class="muted">Re-dressed in ${Math.round((Date.parse(job.done_at) - Date.parse(job.created_at))/1000) || '~'}s.</p></div>`;
    } else if (job.status === 'failed') {
      $('#tt-result').innerHTML = `<div class="tryon-pending">Render failed — try a clearer photo.</div>`;
    }
  });
});

// ---------- My Try-Ons ----------------------------------------------------
async function loadTryons() {
  const r = await fetch('/api/tryon').then(r => r.json());
  const grid = $('#tryons-grid');
  grid.innerHTML = '';
  (r.jobs || []).forEach(j => {
    const c = document.createElement('div');
    c.className = 'card';
    const niceMode = ({ avatar: 'On my twin', photo_swap: 'In my photo', closet_to_oldphoto: 'Time-travel' })[j.mode] || j.mode;
    c.innerHTML = j.status === 'done'
      ? `<img src="/api/tryon/${j.id}/image" alt=""><div class="meta"><div class="title">${niceMode}</div><div class="brand">${j.created_at}</div></div>`
      : `<div class="tryon-pending" style="min-height:280px;display:grid;place-items:center;">${j.status}…</div><div class="meta"><div class="title">${niceMode}</div></div>`;
    grid.appendChild(c);
  });
}

// inject "Try on me" buttons into duel + recs after they render
const origPaint = paintCard;
paintCard = function(side, item) {
  origPaint(side, item);
  const card = document.querySelector(`.card[data-pick="${side.toUpperCase()}"]`);
  if (!card) return;
  card.querySelectorAll('.tryon-btn').forEach(b => b.remove());
  const b = document.createElement('button');
  b.className = 'tryon-btn';
  b.textContent = 'Try on me';
  b.onclick = (e) => {
    e.stopPropagation();
    tryOnItem({ item_id: item.id, mode: 'avatar' }).then(jobId => {
      if (jobId) alert('Rendering on your avatar — check My Try-Ons in a minute.');
    });
  };
  card.appendChild(b);
};

// ---------- Style this Piece (outfit-builder) ----------------------------
async function loadStylePiece() {
  const r = await fetch('/api/closet').then(r => r.json());
  const c = $('#sp-closet');
  $('#sp-outfits').innerHTML = '';
  c.innerHTML = '';
  if (!r.items?.length) {
    c.innerHTML = '<p class="muted">Upload a closet photo first.</p>';
    return;
  }
  r.items.forEach(it => {
    const b = document.createElement('button');
    b.className = 'card';
    b.innerHTML = `<img src="/api/closet/photo/${it.id}" alt=""><div class="meta"><div class="title">${it.category || '—'}</div><div class="brand">${it.color || ''}</div></div>`;
    b.onclick = () => buildOutfits(it);
    c.appendChild(b);
  });
}
async function buildOutfits(closetItem) {
  $('#sp-anchor').innerHTML = `<p class="muted">Building outfits around your <strong>${closetItem.category || 'piece'}</strong>…</p>`;
  $('#sp-outfits').innerHTML = '<div class="tryon-pending">Thinking…</div>';
  const r = await fetch('/api/outfits', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ closet_id: closetItem.id })
  }).then(r => r.json());
  if (!r.outfits?.length) { $('#sp-outfits').innerHTML = '<p class="muted">No outfits could be built — catalog too thin.</p>'; return; }
  const grid = document.createElement('div');
  r.outfits.forEach((o, i) => {
    const card = document.createElement('div');
    card.className = 'outfit-card';
    const slots = Object.entries(o.slots).map(([cat, s]) => `
      <div class="slot">
        <img src="${s.image_url}" alt="${s.title}" loading="lazy">
        <div class="cat">${cat}</div>
        <div class="name">${s.brand || ''}</div>
      </div>`).join('');
    const rationaleHtml = o.rationale ? `<p class="outfit-rationale">${escapeHtml(o.rationale)}</p>` : '';
    card.innerHTML = `<h4>Look ${i+1}</h4><div class="outfit-slots">${slots}</div>${rationaleHtml}<button class="pick-this">Wear this</button>`;
    card.querySelector('.pick-this').onclick = async () => {
      await fetch(`/api/outfits/${r.pick_id}/pick`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ index: i })});
      grid.querySelectorAll('.outfit-card').forEach(x => x.classList.remove('picked'));
      card.classList.add('picked');
      card.querySelector('.pick-this').textContent = '✓ Picked';
    };
    grid.appendChild(card);
  });
  $('#sp-outfits').innerHTML = '';
  $('#sp-outfits').appendChild(grid);
}

// ---------- Waitlist (on /about) ------------------------------------------
$('#waitlist-form').addEventListener('submit', async e => {
  e.preventDefault();
  const email = e.target.email.value;
  const r = await fetch('/api/waitlist', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, source: 'about-page' }) }).then(r => r.json());
  $('#waitlist-msg').textContent = r.ok ? "You're on the list. We'll be in touch." : (r.error || 'Try again.');
  if (r.ok) e.target.email.value = '';
});

// ---------- Live puppet (Adobe Character Animator pass-through) -----------
// Wraps getUserMedia. When the selected camera is a virtual cam (Adobe Mercury
// Transmit → NDI → OBS Virtual Camera, or KlakNDI), the live puppet feed shows
// here. Audio is captured + played through (so when you talk, your puppet
// moves and the room hears you).
const live = {
  stream: null,
  overlayOn: false,
  async populate() {
    try {
      // Ask permission first so labels populate
      const tmp = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
      tmp.getTracks().forEach(t => t.stop());
    } catch (e) {
      const cs = $('#live-cam'); cs.innerHTML = '<option>Permission denied — grant camera + mic in browser settings</option>';
      return;
    }
    const devs = await navigator.mediaDevices.enumerateDevices();
    const fill = (sel, kind) => {
      sel.innerHTML = devs.filter(d => d.kind === kind)
        .map(d => `<option value="${d.deviceId}">${d.label || d.kind}</option>`).join('');
    };
    fill($('#live-cam'), 'videoinput');
    fill($('#live-mic'), 'audioinput');
  },
  async start() {
    if (live.stream) live.stop();
    const camId = $('#live-cam').value;
    const micId = $('#live-mic').value;
    try {
      live.stream = await navigator.mediaDevices.getUserMedia({
        video: { deviceId: camId ? { exact: camId } : undefined, width: 1280, height: 720 },
        audio: { deviceId: micId ? { exact: micId } : undefined, echoCancellation: true, noiseSuppression: true },
      });
      $('#live-video').srcObject = live.stream;
      $('#pip-video').srcObject = live.stream;
      $('#live-start').disabled = true; $('#live-stop').disabled = false;
    } catch (e) {
      alert('Could not start camera — check permissions. ' + (e.message || e));
    }
  },
  stop() {
    live.stream?.getTracks().forEach(t => t.stop());
    live.stream = null;
    $('#live-video').srcObject = null;
    $('#pip-video').srcObject = null;
    $('#duel-pip').hidden = true;
    $('#live-overlay-toggle').setAttribute('aria-pressed', 'false');
    live.overlayOn = false;
    $('#live-start').disabled = false; $('#live-stop').disabled = true;
  },
  toggleOverlay() {
    if (!live.stream) { alert('Start live first.'); return; }
    live.overlayOn = !live.overlayOn;
    $('#duel-pip').hidden = !live.overlayOn;
    $('#live-overlay-toggle').setAttribute('aria-pressed', String(live.overlayOn));
  },
  snap() {
    if (!live.stream) return;
    const v = $('#live-video');
    const c = $('#live-overlay');
    c.width = v.videoWidth; c.height = v.videoHeight;
    const ctx = c.getContext('2d');
    ctx.translate(c.width, 0); ctx.scale(-1, 1);  // mirror to match preview
    ctx.drawImage(v, 0, 0);
    c.toBlob(blob => {
      const a = document.createElement('a');
      a.href = URL.createObjectURL(blob);
      a.download = `live-snap-${Date.now()}.png`;
      a.click();
    }, 'image/png');
  },
};
$('#live-start').addEventListener('click', () => live.start());
$('#live-stop').addEventListener('click', () => live.stop());
$('#live-overlay-toggle').addEventListener('click', () => live.toggleOverlay());
$('#live-snap').addEventListener('click', () => live.snap());

// ---------- Studio ---------------------------------------------------------
// LEFT pane: pick a photo of yourself.
// MIDDLE pane: live render + history-strip below.
// RIGHT pane: pick a clothing section, then a catalog piece in that category.
const studio = {
  currentSource: null,    // server-side path of selected photo
  currentSourceUrl: null, // proxied URL we can show in <img>
  category: 'top',
  poll: null,

  async loadPhotos() {
    const r = await fetch('/api/studio/photos').then(r => r.json());
    const list = $('#studio-photos');
    list.innerHTML = '';
    if (!r.photos?.length) { list.innerHTML = '<p class="muted">Upload photos in My Avatar first.</p>'; return; }
    r.photos.forEach(p => {
      const b = document.createElement('button');
      b.title = p.path.split('/').pop();
      b.innerHTML = `<img src="${p.url}" alt="" loading="lazy">`;
      b.onclick = () => { studio.selectPhoto(p); list.querySelectorAll('button').forEach(x => x.classList.remove('sel')); b.classList.add('sel'); };
      list.appendChild(b);
    });
    // auto-select first
    if (r.photos.length && !studio.currentSource) {
      studio.selectPhoto(r.photos[0]);
      list.querySelector('button')?.classList.add('sel');
    }
  },
  selectPhoto(p) {
    studio.currentSource = p.path;
    studio.currentSourceUrl = p.url;
    $('#studio-current').innerHTML = `<img src="${p.url}" alt="">`;
    $('#studio-result').innerHTML = '<p class="muted">Pick a category on the right, then a piece.</p>';
    studio.loadHistory();
  },
  setCategory(cat) {
    studio.category = cat;
    $$('.section-btn').forEach(b => b.classList.toggle('active', b.dataset.category === cat));
    studio.loadPicker();
  },
  async loadPicker() {
    const picker = $('#studio-picker');
    picker.innerHTML = '<p class="muted">Loading…</p>';
    // Pull from /api/recommend filtered by category
    const r = await fetch('/api/recommend?limit=60').then(r => r.json());
    const items = (r.items || []).filter(it => it.category === studio.category);
    if (!items.length) { picker.innerHTML = `<p class="muted">No ${studio.category} items in catalog yet.</p>`; return; }
    picker.innerHTML = '';
    items.forEach(it => {
      const b = document.createElement('button');
      b.title = `${it.brand || ''} · ${it.title}`;
      b.innerHTML = `<img src="${it.image_url}" alt="" loading="lazy"><div class="label">${it.brand || it.title.slice(0, 18)}</div>`;
      b.onclick = () => studio.swap(it);
      picker.appendChild(b);
    });
  },
  async swap(item) {
    if (!studio.currentSource) { alert('Pick a photo on the left first.'); return; }
    $('#studio-result').innerHTML = `<div><p>Rendering you in <strong>${item.brand || item.title}</strong>…</p><p class="muted">~30-60s via FASHN</p></div>`;
    const r = await fetch('/api/studio/swap', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ source_photo: studio.currentSource, item_id: item.id, category: studio.category }),
    }).then(r => r.json());
    if (r.error) { $('#studio-result').innerHTML = `<p class="muted">Error: ${r.error}</p>`; return; }
    studio.pollResult(r.job_id);
  },
  async pollResult(jobId) {
    clearInterval(studio.poll);
    studio.poll = setInterval(async () => {
      const j = await fetch(`/api/tryon/${jobId}`).then(r => r.json());
      if (j.job?.status === 'done') {
        clearInterval(studio.poll);
        $('#studio-result').innerHTML = `<img src="/api/tryon/${jobId}/image?t=${Date.now()}" alt="result">`;
        studio.loadHistory();
      } else if (j.job?.status === 'failed') {
        clearInterval(studio.poll);
        $('#studio-result').innerHTML = `<p class="muted">Render failed: ${j.job.error || 'unknown'}</p>`;
      }
    }, 2500);
  },
  async loadHistory() {
    const strip = $('#studio-history');
    if (!studio.currentSource) { strip.innerHTML = ''; return; }
    const r = await fetch('/api/studio/history?source_photo=' + encodeURIComponent(studio.currentSource)).then(r => r.json());
    strip.innerHTML = '';
    const done = (r.history || []).filter(h => h.status === 'done');
    if (!done.length) { strip.innerHTML = '<p class="muted">No renders on this photo yet — pick a piece on the right.</p>'; return; }
    done.forEach(h => {
      const t = document.createElement('div');
      t.className = 'h-tile';
      t.title = `${h.brand || ''} ${h.title || ''}`;
      t.innerHTML = `<img src="/api/tryon/${h.id}/image" alt="" loading="lazy"><div class="h-cat">${h.category || h.mode}</div>`;
      t.onclick = () => { $('#studio-result').innerHTML = `<img src="/api/tryon/${h.id}/image" alt="">`; };
      strip.appendChild(t);
    });
  },
};
$$('.section-btn[data-category]').forEach(b => b.addEventListener('click', () => studio.setCategory(b.dataset.category)));

// ---- Studio: pull-a-piece (garment extractor) ----------------------------
const extract = {
  active: false,
  start: null,   // {x, y} in img-local coords
  end: null,
  imgEl: null,

  enable() {
    if (!studio.currentSource) { alert('Pick a photo first.'); return; }
    // Replace whatever's in the result pane with the source photo so user
    // can draw on the original (not on a rendered result).
    $('#studio-result').innerHTML = `<img src="${studio.currentSourceUrl}" alt="" draggable="false">`;
    $('#studio-result').classList.add('extracting');
    $('#extract-toggle').classList.add('active');
    $('#extract-hint').hidden = false;
    extract.active = true;
    extract.imgEl = $('#studio-result img');
  },
  disable() {
    $('#studio-result').classList.remove('extracting');
    $('#studio-result').querySelector('.extract-rect')?.remove();
    $('#extract-toggle').classList.remove('active');
    $('#extract-hint').hidden = true;
    $('#extract-save').disabled = true;
    extract.active = false;
    extract.start = extract.end = null;
  },
  rect() {
    if (!extract.start || !extract.end) return null;
    const x = Math.min(extract.start.x, extract.end.x);
    const y = Math.min(extract.start.y, extract.end.y);
    const w = Math.abs(extract.end.x - extract.start.x);
    const h = Math.abs(extract.end.y - extract.start.y);
    return { x, y, w, h };
  },
  drawRect() {
    const r = extract.rect();
    if (!r) return;
    let el = $('#studio-result').querySelector('.extract-rect');
    if (!el) { el = document.createElement('div'); el.className = 'extract-rect'; $('#studio-result').appendChild(el); }
    el.style.left = r.x + 'px'; el.style.top = r.y + 'px';
    el.style.width = r.w + 'px'; el.style.height = r.h + 'px';
  },
  async save() {
    const r = extract.rect();
    if (!r || !extract.imgEl) return;
    const imgRect = extract.imgEl.getBoundingClientRect();
    const containerRect = $('#studio-result').getBoundingClientRect();
    // r is in container coords; convert to image-local then normalize 0-1
    const imgLeft = imgRect.left - containerRect.left;
    const imgTop  = imgRect.top - containerRect.top;
    const bbox = {
      x: Math.max(0, (r.x - imgLeft)) / imgRect.width,
      y: Math.max(0, (r.y - imgTop))  / imgRect.height,
      w: r.w / imgRect.width,
      h: r.h / imgRect.height,
    };
    const category = $('#extract-category').value;
    const resp = await fetch('/api/studio/extract-garment', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ source_photo: studio.currentSource, bbox, category, brand_hint: 'Your past wardrobe' }),
    }).then(r => r.json());
    if (resp.error) { alert('Extract failed: ' + resp.error); return; }
    alert(`Extracted! Saved as catalog item #${resp.item_id} — pick a different photo on the left and click "${category}" to swap it on.`);
    extract.disable();
    // Refresh the picker so the new piece shows up
    studio.setCategory(resp.category || category);
  },
};
$('#extract-toggle').addEventListener('click', () => extract.active ? extract.disable() : extract.enable());
$('#extract-save').addEventListener('click', () => extract.save());

$('#studio-result').addEventListener('mousedown', e => {
  if (!extract.active) return;
  const c = $('#studio-result').getBoundingClientRect();
  extract.start = { x: e.clientX - c.left, y: e.clientY - c.top };
  extract.end = { ...extract.start };
  extract.drawRect();
});
$('#studio-result').addEventListener('mousemove', e => {
  if (!extract.active || !extract.start) return;
  const c = $('#studio-result').getBoundingClientRect();
  extract.end = { x: e.clientX - c.left, y: e.clientY - c.top };
  extract.drawRect();
});
$('#studio-result').addEventListener('mouseup', () => {
  if (!extract.active || !extract.start) return;
  const r = extract.rect();
  $('#extract-save').disabled = !(r && r.w > 20 && r.h > 20);
});

// ---------- Router ---------------------------------------------------------
async function loadTasteTimeline() {
  const r = await fetch('/api/me/taste/history').then(r => r.json());
  const container = $('#taste-timeline');
  if (!container) return;
  container.innerHTML = '';
  const snaps = r.snapshots || [];
  if (snaps.length < 2) {
    container.innerHTML = `<p class="muted">${snaps.length === 0 ? 'No snapshots yet — keep picking duels.' : 'One snapshot so far — pick 5 more for a trend line.'}</p>`;
    return;
  }
  // pick the top-5 dims by absolute value in the final snapshot
  const final = snaps[snaps.length - 1].taste_vec;
  const topIdx = final.map((v, i) => [i, Math.abs(v)]).sort((a,b) => b[1]-a[1]).slice(0, 5).map(p => p[0]);
  // Need BASIS labels — pull from /api/me/taste once
  const tasteResp = await fetch('/api/me/taste').then(r => r.json());
  const labels = (tasteResp.dims || []).map(d => d.key);
  const W = 320, H = 28, PAD = 2;
  topIdx.forEach(idx => {
    const vals = snaps.map(s => s.taste_vec[idx]);
    const minV = Math.min(...vals, 0), maxV = Math.max(...vals, 0);
    const range = (maxV - minV) || 1;
    const pts = vals.map((v, i) => {
      const x = PAD + (i * (W - 2*PAD)) / Math.max(1, vals.length - 1);
      const y = H - PAD - ((v - minV) / range) * (H - 2*PAD);
      return `${x.toFixed(1)},${y.toFixed(1)}`;
    }).join(' ');
    const zeroY = H - PAD - ((0 - minV) / range) * (H - 2*PAD);
    const neg = final[idx] < 0 ? ' neg' : '';
    const row = document.createElement('div');
    row.className = 'taste-timeline-row';
    row.innerHTML = `<div class="label">${(labels[idx] || `dim ${idx}`).replace(/_/g, ' ')}</div>
      <svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none">
        <line class="zero" x1="0" y1="${zeroY.toFixed(1)}" x2="${W}" y2="${zeroY.toFixed(1)}"/>
        <polyline class="spark${neg}" points="${pts}"/>
      </svg>`;
    container.appendChild(row);
  });
}

async function loadTaste() {
  loadTasteTimeline();
  const r = await fetch('/api/me/taste').then(r => r.json());
  const summary = $('#taste-summary');
  if (!r.duels_answered) {
    summary.textContent = 'No duels yet — pick a few pairs and your taste lens will start sharpening.';
  } else {
    const top = (r.top || []).map(d => d.key.replace(/_/g, ' ')).slice(0, 3);
    summary.textContent = `${r.duels_answered} duel${r.duels_answered === 1 ? '' : 's'} answered. You lean ${top.join(', ') || 'no direction yet'}.`;
  }
  const top = $('#taste-bars');
  top.innerHTML = '';
  (r.top || []).forEach(d => top.appendChild(renderBar(d, false)));
  (r.bottom || []).forEach(d => top.appendChild(renderBar(d, true)));
  const all = $('#taste-all');
  all.innerHTML = '';
  (r.dims || []).forEach(d => all.appendChild(renderBar(d, false)));
  function renderBar(d, isNeg) {
    const row = document.createElement('div');
    row.className = 'taste-bar';
    const v = d.value;
    const pct = Math.min(50, Math.abs(v) * 100);
    const fillCls = v < 0 ? 'fill neg' : 'fill';
    const fillStyle = v < 0
      ? `width:${pct}%;left:${50 - pct}%;transform:none;`
      : `width:${pct}%;left:50%;`;
    row.innerHTML = `<div class="label">${d.key.replace(/_/g, ' ')}</div>
      <div class="track"><div class="${fillCls}" style="${fillStyle}"></div></div>
      <div class="val">${v > 0 ? '+' : ''}${v}</div>`;
    return row;
  }
}

async function loadSupport() {
  const r = await fetch('/api/support').then(r => r.json());
  const cta = $('#support-cta');
  const fallback = $('#support-cta-fallback');
  if (r.donation_url) {
    cta.href = r.donation_url;
    cta.style.display = 'inline-block';
    if (fallback) fallback.style.display = 'none';
  }
}

async function loadDebates() {
  const r = await fetch('/api/debates').then(r => r.json());
  const list = $('#debates-list');
  list.innerHTML = '';
  (r.debates || []).forEach(d => {
    const div = document.createElement('article');
    div.className = 'debate';
    // strip the "VERDICT: " prefix for a cleaner card; keep full text in detail
    const headline = (d.verdict || '').split('\n')[0].replace(/^VERDICT:\s*/i, '');
    div.innerHTML = `
      <h3>${d.topic}</h3>
      <div class="verdict">${headline}</div>
      <details><summary>Full panel verdict</summary><pre>${(d.verdict || '').replace(/</g,'&lt;')}</pre></details>
      <div class="muted small">${d.created_at}</div>`;
    list.appendChild(div);
  });
  if (!r.debates?.length) list.innerHTML = '<p class="muted">No debates yet.</p>';
}

function route(name) {
  show(name);
  if (name === 'home') { loadDuel(); showKbHintIfNew(); }
  if (name === 'closet') loadCloset();
  if (name === 'stylepiece') loadStylePiece();
  if (name === 'recs') loadRecs();
  if (name === 'avatar') pollAvatar();
  if (name === 'timetravel') loadTimeTravel();
  if (name === 'tryons') loadTryons();
  if (name === 'studio') { studio.loadPhotos(); studio.setCategory(studio.category); }
  if (name === 'live') live.populate();
  if (name === 'taste') loadTaste();
  if (name === 'support') loadSupport();
  if (name === 'debates') loadDebates();
  if (name === 'productions') loadProductions();
  if (name === 'favorites') loadFavorites();
  if (name === 'brand') { /* loadBrand called by openBrand() with arg */ }
  if (name === 'couple') {
    // Reset any leftover panels when re-entering the screen.
    if ($('#couple-invite-out')) $('#couple-invite-out').hidden = true;
    if ($('#couple-accept-out')) $('#couple-accept-out').textContent = '';
    if ($('#couple-exit-out'))   $('#couple-exit-out').textContent = '';
  }
  if (name === 'connect') fetch('/api/connect').then(r => r.json()).then(c => $('#connect-state').textContent = JSON.stringify(c.connections, null, 2));
}

// ---------- Item credits (shotonwhat-style résumé per garment) -------------
function fmtCreditDate(s) {
  if (!s) return '';
  const d = new Date(s.replace(' ', 'T') + 'Z');
  if (isNaN(d)) return s;
  return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}

async function openCredits(kind, id) {
  show('credits');
  $('#credits-title').textContent = 'Loading credits…';
  $('#credits-brand').textContent = '';
  $('#credits-count').textContent = '0';
  $('#credits-list').innerHTML = '<p class="muted">Loading…</p>';
  try {
    const r = await fetch(`/api/item-credits/${encodeURIComponent(kind)}/${encodeURIComponent(id)}`);
    if (!r.ok) { $('#credits-list').innerHTML = `<p class="muted">No credits found (${r.status}).</p>`; return; }
    const data = await r.json();
    $('#credits-title').textContent = data.item.title || `${kind} ${data.item.id}`;
    $('#credits-brand').textContent = data.item.brand || '';
    $('#credits-count').textContent = data.total;
    // If the current viewer is a set decorator, prepend a "Tag with production"
    // affordance so closet pieces / catalog items can be assigned to a project.
    if (kind === 'closet' && (Role.effective === 'set_decorator' || Role.role === 'set_decorator')) {
      await injectProductionTagger(kind, id);
    }
    if (!data.credits.length) {
      $('#credits-list').innerHTML = `<p class="muted">No appearances yet. Try it on, build an outfit with it, or time-travel it into an old photo — every render lands here.</p>`;
      return;
    }
    $('#credits-list').innerHTML = data.credits.map(c => `
      <article class="credit-row">
        <h3 class="credit-role">${escapeHtml(c.role)}</h3>
        <h4 class="credit-title">
          ${c.occasion ? `<span class="credit-occasion">${escapeHtml(c.occasion)}</span>` : '<span class="credit-occasion muted">no occasion tag</span>'}
          <span class="credit-year">${fmtCreditDate(c.date)}</span>
        </h4>
        <div class="credit-meta">
          <span class="credit-type">${escapeHtml(c.type)}</span>
          ${c.provider ? `<span class="credit-provider">via ${escapeHtml(c.provider)}</span>` : ''}
          ${c.status ? `<span class="credit-status credit-status-${escapeHtml(c.status)}">${escapeHtml(c.status)}</span>` : ''}
          ${c.slot ? `<span class="credit-slot">slot: ${escapeHtml(c.slot)}</span>` : ''}
        </div>
        ${c.with && c.with.length
          ? `<div class="credit-with"><span class="muted">With:</span> ${c.with.map(w => `<span>${escapeHtml(w)}</span>`).join(' · ')}</div>`
          : ''}
      </article>
    `).join('');
  } catch (e) {
    $('#credits-list').innerHTML = `<p class="muted">Could not load credits: ${escapeHtml(e.message)}</p>`;
  }
}
function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }

async function injectProductionTagger(kind, id) {
  // Renders a small "tag with production" pill row above the credits list.
  // Only meaningful for set_decorator role. Bails silently if the user has
  // zero productions — the empty case nudges them to /productions instead.
  try {
    const r = await fetch('/api/productions');
    if (!r.ok) return;
    const { productions } = await r.json();
    const header = document.querySelector('.credits-header');
    if (!header || header.querySelector('.tag-prod')) return;
    const div = document.createElement('div');
    div.className = 'tag-prod';
    if (!productions.length) {
      div.innerHTML = `<span class="muted">Tag this piece with a production?</span> <a href="#" data-screen="productions">Add a production first →</a>`;
    } else {
      div.innerHTML = `
        <label class="muted" for="tag-prod-select">Tag with production:</label>
        <select id="tag-prod-select">
          <option value="">— none —</option>
          ${productions.map(p => `<option value="${p.id}">${escapeHtml(p.title)}${p.year ? ' (' + p.year + ')' : ''}</option>`).join('')}
        </select>
        <button type="button" class="ghost" data-tag-prod-kind="${kind}" data-tag-prod-id="${id}">Tag</button>
        <span class="tag-prod-status muted"></span>
      `;
    }
    header.appendChild(div);
  } catch {}
}

document.addEventListener('click', async e => {
  const btn = e.target.closest('button[data-tag-prod-kind][data-tag-prod-id]');
  if (!btn) return;
  const pid = $('#tag-prod-select').value || '';
  const kind = btn.dataset.tagProdKind;
  const id = btn.dataset.tagProdId;
  const endpoint = kind === 'closet' ? `/api/closet/${id}/production` : null;
  if (!endpoint) return;
  const r = await fetch(endpoint, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ production_id: pid || null }) });
  const j = await r.json().catch(() => ({}));
  const status = btn.parentElement.querySelector('.tag-prod-status');
  if (r.ok) { status.textContent = pid ? 'Tagged.' : 'Cleared.'; status.style.color = '#14572a'; }
  else      { status.textContent = j.error || 'Failed'; status.style.color = '#7a1717'; }
});

document.addEventListener('click', e => {
  const tgt = e.target.closest('[data-credits-kind][data-credits-id]');
  if (tgt) {
    e.preventDefault();
    openCredits(tgt.dataset.creditsKind, tgt.dataset.creditsId);
  }
  if (e.target.id === 'credits-back') {
    e.preventDefault();
    route('closet');
  }
});

// ---------- Boot -----------------------------------------------------------
(async function boot() {
  await Role.refresh();  // tick 15.5 — populate role + banner before first paint
  await refreshFavorites();   // tick 24 — populate star state before card renders
  const me = await fetch('/api/me').then(r => r.json());
  if (!me.onboarded) { show('onboard'); onbRender(); }
  // Tick 32: if user came in via /brands → ?brand= deep link, jump straight
  // to the recs grid (which renders the filter pill) instead of duel.
  else if (__brandDeepLink) route('recs');
  else route('home');
})();