← back to Quadrille Showroom

public/proto/v8-swipe.html

853 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Swipe Tower — Curated Wallcovering Discovery</title>
<style>
  /* ───────────────────────────  TOKENS  ─────────────────────────── */
  :root{
    --bg:        #f4f1ec;   /* warm gallery linen   */
    --bg-deep:   #ebe6dd;
    --ink:       #1c1a17;   /* near-black warm ink  */
    --ink-soft:  #6b655c;
    --line:      #d8d1c4;
    --card:      #ffffff;
    --keep:      #2f6b4f;   /* deep botanical green */
    --keep-tint: #e7f0ea;
    --skip:      #8a8178;   /* muted stone          */
    --skip-tint: #efece6;
    --gold:      #b08a4f;
    --shadow:    0 24px 60px -24px rgba(28,26,23,.42), 0 4px 14px -6px rgba(28,26,23,.22);
    --serif: "Hoefler Text", "Iowan Old Style", "Palatino Linotype", Georgia, "Times New Roman", serif;
    --sans:  -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
  }
  *{ box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
  html,body{ height:100%; margin:0; }
  body{
    background:
      radial-gradient(120% 80% at 50% -10%, var(--bg) 0%, var(--bg-deep) 70%, #e3ddd2 100%);
    color:var(--ink);
    font-family:var(--sans);
    overflow:hidden;
    -webkit-font-smoothing:antialiased;
  }

  /* ───────────────────────────  LAYOUT  ─────────────────────────── */
  .app{
    height:100dvh;
    display:grid;
    grid-template-columns: 1fr 300px;
    grid-template-rows: auto 1fr;
    grid-template-areas:
      "head head"
      "stage tray";
    max-width:1240px; margin:0 auto;
  }
  @media (max-width: 860px){
    .app{ grid-template-columns:1fr; grid-template-rows:auto 1fr auto; grid-template-areas:"head" "stage" "tray"; }
  }

  /* ───────────────────────────  HEADER  ─────────────────────────── */
  header{
    grid-area:head;
    padding:22px 30px 14px;
    display:flex; align-items:baseline; justify-content:space-between; gap:18px;
    border-bottom:1px solid var(--line);
  }
  .brand{ display:flex; flex-direction:column; gap:3px; }
  .brand h1{
    font-family:var(--serif); font-weight:500; font-size:26px; letter-spacing:.5px;
    margin:0; line-height:1;
  }
  .brand .sub{
    font-size:11px; letter-spacing:.32em; text-transform:uppercase; color:var(--ink-soft);
  }
  .counter{ text-align:right; font-size:13px; color:var(--ink-soft); line-height:1.5; }
  .counter b{ color:var(--ink); font-variant-numeric:tabular-nums; }
  .curating{
    margin-top:6px; font-size:10px; letter-spacing:.2em; text-transform:uppercase;
    color:var(--keep); font-weight:600; display:inline-flex; align-items:center; gap:6px;
    opacity:0; transition:opacity .3s; justify-content:flex-end;
  }
  .curating::before{ content:""; width:6px; height:6px; border-radius:50%; background:var(--keep); }
  .curating.show{ opacity:1; }
  .curating.pulse::before{ animation:curatePulse .9s ease; }
  @keyframes curatePulse{ 0%{transform:scale(1);opacity:1} 50%{transform:scale(2.4);opacity:.4} 100%{transform:scale(1);opacity:1} }

  /* ───────────────────────────  STAGE  ─────────────────────────── */
  .stage{
    grid-area:stage;
    position:relative;
    display:flex; flex-direction:column; align-items:center; justify-content:center;
    padding:24px 20px 18px;
    min-height:0;
  }
  .deck{
    position:relative;
    width:min(78vmin, 460px);
    aspect-ratio:1/1;
    flex:0 0 auto;
  }

  /* card */
  .card{
    position:absolute; inset:0;
    background:var(--card);
    border-radius:14px;
    box-shadow:var(--shadow);
    overflow:hidden;
    display:flex; flex-direction:column;
    transform-origin:center bottom;
    will-change:transform, opacity;
    user-select:none;
  }
  .card.behind-1{ transform:translateY(14px) scale(.955); }
  .card.behind-2{ transform:translateY(26px) scale(.91); }
  .card .swatch{
    position:relative;
    flex:1 1 auto;
    background:var(--bg-deep);
    background-size:cover; background-position:center;
    min-height:0;
  }
  .card .swatch::after{ /* hairline frame inside swatch */
    content:""; position:absolute; inset:0;
    box-shadow: inset 0 0 0 1px rgba(28,26,23,.06);
    pointer-events:none;
  }
  .card .why{
    position:absolute; left:12px; top:12px; z-index:3;
    background:rgba(47,107,79,.92); color:#fff; backdrop-filter:blur(3px);
    font-size:10.5px; letter-spacing:.1em; text-transform:uppercase; font-weight:600;
    padding:6px 11px; border-radius:30px; box-shadow:0 4px 14px -4px rgba(28,26,23,.5);
    display:inline-flex; align-items:center; gap:6px; max-width:78%;
    animation:whyIn .4s cubic-bezier(.2,.9,.25,1) both;
  }
  .card .why::before{ content:"✦"; font-size:11px; }
  .card .why.curve{ background:rgba(176,138,79,.94); }   /* gold = exploration curveball */
  .card .why.curve::before{ content:"↻"; }
  @keyframes whyIn{ from{opacity:0;transform:translateY(-6px)} to{opacity:1;transform:none} }
  .card .meta{
    flex:0 0 auto;
    padding:14px 18px 16px;
    border-top:1px solid var(--line);
    background:linear-gradient(180deg,#fff, #fbf9f5);
  }
  .card .pname{
    font-family:var(--serif); font-size:21px; line-height:1.15; font-weight:500;
    margin:0 0 4px; color:var(--ink);
    display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;
  }
  .card .pvendor{ font-size:12px; letter-spacing:.16em; text-transform:uppercase; color:var(--gold); margin-bottom:8px; }
  .card .spec{ display:flex; gap:14px; font-size:12.5px; color:var(--ink-soft); flex-wrap:wrap; }
  .card .spec span{ display:inline-flex; align-items:center; gap:5px; }
  .card .spec .dot{ width:5px; height:5px; border-radius:50%; background:var(--line); }

  /* swipe verdict stamps */
  .stamp{
    position:absolute; top:26px; padding:8px 18px;
    font-family:var(--serif); font-size:24px; letter-spacing:.06em;
    border:2.5px solid currentColor; border-radius:8px;
    opacity:0; transition:opacity .08s; pointer-events:none;
    background:rgba(255,255,255,.78); backdrop-filter:blur(2px);
  }
  .stamp.keep{ left:24px; color:var(--keep); transform:rotate(-11deg); }
  .stamp.skip{ right:24px; color:var(--skip); transform:rotate(11deg); }

  /* empty state */
  .empty{
    position:absolute; inset:0; display:none; flex-direction:column;
    align-items:center; justify-content:center; text-align:center; gap:14px; padding:30px;
  }
  .empty.show{ display:flex; }
  .empty h2{ font-family:var(--serif); font-weight:500; font-size:24px; margin:0; }
  .empty p{ color:var(--ink-soft); font-size:14px; margin:0; max-width:32ch; line-height:1.5; }
  .empty button{
    margin-top:8px; font:inherit; font-size:14px; letter-spacing:.04em;
    border:1px solid var(--ink); background:none; color:var(--ink);
    padding:11px 24px; border-radius:30px; cursor:pointer;
  }
  .empty button:hover{ background:var(--ink); color:#fff; }

  /* ───────────────────────────  ACTION BAR  ─────────────────────────── */
  .actions{
    margin-top:22px; display:flex; align-items:center; gap:26px;
  }
  .act{
    --c:var(--ink);
    width:80px; height:80px; border-radius:50%;
    border:2px solid var(--line); background:#fff;
    display:flex; align-items:center; justify-content:center;
    cursor:pointer; transition:transform .12s ease, box-shadow .2s, border-color .2s, background .2s;
    box-shadow:0 6px 18px -8px rgba(28,26,23,.3);
    position:relative;
  }
  .act svg{ width:34px; height:34px; }
  .act:hover{ transform:translateY(-3px); }
  .act:active{ transform:scale(.92); }
  .act .lbl{
    position:absolute; bottom:-24px; left:50%; transform:translateX(-50%);
    font-size:10.5px; letter-spacing:.22em; text-transform:uppercase; color:var(--ink-soft);
    white-space:nowrap;
  }
  .act.skip{ color:var(--skip); }
  .act.skip:hover{ border-color:var(--skip); background:var(--skip-tint); }
  .act.keep{ color:var(--keep); width:96px; height:96px; }
  .act.keep svg{ width:42px; height:42px; }
  .act.keep:hover{ border-color:var(--keep); background:var(--keep-tint); }
  .act.undo{ width:58px; height:58px; color:var(--ink-soft); }
  .act.undo svg{ width:24px; height:24px; }
  .act.undo:disabled{ opacity:.32; cursor:default; transform:none; }
  .hint{ margin-top:34px; font-size:11.5px; letter-spacing:.04em; color:var(--ink-soft); text-align:center; }
  .hint kbd{
    font-family:var(--sans); font-size:11px; background:#fff; border:1px solid var(--line);
    border-bottom-width:2px; border-radius:5px; padding:1px 7px; color:var(--ink);
  }

  /* ───────────────────────────  FAVORITES TRAY  ─────────────────────────── */
  .tray{
    grid-area:tray;
    border-left:1px solid var(--line);
    background:linear-gradient(180deg, #faf8f3, #f3efe7);
    display:flex; flex-direction:column; min-height:0;
  }
  @media (max-width:860px){ .tray{ border-left:none; border-top:1px solid var(--line); max-height:38vh; } }
  .tray-head{
    padding:18px 22px 12px; display:flex; align-items:baseline; justify-content:space-between;
    border-bottom:1px solid var(--line);
  }
  .tray-head h2{ font-family:var(--serif); font-weight:500; font-size:17px; margin:0; letter-spacing:.02em; }
  .tray-head .ct{
    font-size:12px; color:#fff; background:var(--keep);
    min-width:24px; height:24px; padding:0 8px; border-radius:12px;
    display:inline-flex; align-items:center; justify-content:center; font-variant-numeric:tabular-nums;
  }
  .tray-head .ct.zero{ background:var(--line); color:var(--ink-soft); }
  .tray-list{ flex:1 1 auto; overflow-y:auto; padding:12px 14px 18px; display:flex; flex-direction:column; gap:10px; }
  .tray-empty{ color:var(--ink-soft); font-size:13px; line-height:1.6; padding:18px 8px; text-align:center; }
  .tray-empty .heart{ font-size:26px; display:block; margin-bottom:8px; opacity:.4; }

  .fav{
    display:flex; gap:11px; align-items:center;
    background:#fff; border:1px solid var(--line); border-radius:10px;
    padding:8px; position:relative;
    animation:favIn .42s cubic-bezier(.2,.9,.25,1) both;
  }
  @keyframes favIn{ from{ opacity:0; transform:translateX(16px) scale(.96);} to{opacity:1; transform:none;} }
  .fav .thumb{ width:54px; height:54px; border-radius:7px; background:var(--bg-deep) center/cover; flex:0 0 auto; box-shadow:inset 0 0 0 1px rgba(0,0,0,.05); }
  .fav .fmeta{ min-width:0; flex:1 1 auto; }
  .fav .fname{ font-family:var(--serif); font-size:14px; line-height:1.2; margin:0 0 2px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  .fav .fvendor{ font-size:10px; letter-spacing:.12em; text-transform:uppercase; color:var(--gold); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  .fav .fspec{ font-size:10.5px; color:var(--ink-soft); margin-top:2px; }
  .fav .rm{
    position:absolute; top:6px; right:6px; width:20px; height:20px; border-radius:50%;
    border:none; background:var(--skip-tint); color:var(--skip); cursor:pointer;
    font-size:13px; line-height:1; display:flex; align-items:center; justify-content:center;
    opacity:0; transition:opacity .15s;
  }
  .fav:hover .rm{ opacity:1; }
  .tray-foot{ flex:0 0 auto; padding:14px 18px; border-top:1px solid var(--line); }
  .tray-foot button{
    width:100%; font:inherit; font-size:13.5px; letter-spacing:.1em; text-transform:uppercase;
    background:var(--ink); color:#fff; border:none; border-radius:30px; padding:13px; cursor:pointer;
    transition:opacity .2s; display:flex; align-items:center; justify-content:center; gap:8px;
  }
  .tray-foot button:disabled{ opacity:.3; cursor:default; }
  .tray-foot button:not(:disabled):hover{ background:var(--keep); }
  .tray-foot .note{ font-size:11px; color:var(--ink-soft); text-align:center; margin:9px 0 0; line-height:1.4; }

  .loading{ position:absolute; inset:0; display:flex; align-items:center; justify-content:center; color:var(--ink-soft); font-size:14px; letter-spacing:.1em; }
  @media (prefers-reduced-motion: reduce){ *{ animation-duration:.001s !important; transition-duration:.001s !important; } }
</style>
</head>
<body>
<div class="app">
  <header>
    <div class="brand">
      <h1>Swipe Tower</h1>
      <div class="sub">Curated Wallcovering Discovery</div>
    </div>
    <div class="counter">
      <div>Reviewed <b id="seenCt">0</b></div>
      <div><b id="leftCt">0</b> remaining</div>
      <div class="curating" id="curating">Tuning to your taste…</div>
    </div>
  </header>

  <section class="stage">
    <div class="deck" id="deck">
      <div class="loading" id="loading">Curating your collection…</div>
      <div class="empty" id="empty">
        <h2 id="emptyTitle">That's the collection</h2>
        <p id="emptyMsg">You've seen every swatch in this curation. Your favorites are saved in the tray.</p>
        <button id="restartBtn">Start over</button>
      </div>
    </div>

    <div class="actions" id="actions" style="visibility:hidden">
      <button class="act undo" id="undoBtn" title="Undo last (Backspace)" disabled aria-label="Undo last">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"/><path d="M3 13a9 9 0 1 0 3-7.7L3 8"/></svg>
      </button>
      <button class="act skip" id="skipBtn" title="Skip (← arrow)" aria-label="Skip this swatch">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
        <span class="lbl">Skip</span>
      </button>
      <button class="act keep" id="keepBtn" title="Keep (→ arrow)" aria-label="Keep this swatch">
        <svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 21s-7.5-4.7-10-9.2C.3 8.4 1.9 4.6 5.4 4.1 7.6 3.8 9.5 5 12 7.6 14.5 5 16.4 3.8 18.6 4.1c3.5.5 5.1 4.3 3.4 7.7C19.5 16.3 12 21 12 21z"/></svg>
        <span class="lbl">Keep</span>
      </button>
    </div>
    <div class="hint" id="hint" style="visibility:hidden">
      Drag a swatch, tap a button, or use <kbd>←</kbd> skip &nbsp; <kbd>→</kbd> keep &nbsp; <kbd>↩</kbd> undo
    </div>
  </section>

  <aside class="tray">
    <div class="tray-head">
      <h2>Your Favorites</h2>
      <span class="ct zero" id="favCt">0</span>
    </div>
    <div class="tray-list" id="favList">
      <div class="tray-empty" id="trayEmpty">
        <span class="heart">♥</span>
        Swatches you keep collect here.<br>Build a palette, then request samples.
      </div>
    </div>
    <div class="tray-foot">
      <button id="sampleBtn" disabled>Request <span id="sampleCt">0</span> Samples</button>
      <p class="note" id="sampleNote">No order minimum on memo samples.</p>
    </div>
  </aside>
</div>

<script>
const API = "/api/showroom/products?limit=120";
const ENRICH_URL = "./enrichment.json";   // REAL hue / color_bucket / first-word family per sku
let ENRICH = {};
const PLACEHOLDER = "data:image/svg+xml;utf8," + encodeURIComponent(
  '<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80"><rect width="80" height="80" fill="#ebe6dd"/></svg>');

/* Offline fallback so the mechanic is demonstrable even when the showroom API
   is unreachable (e.g. opened as a bare file:// with CORS blocking the fetch).
   Generated swatch tiles via inline SVG so no external image dependency. */
function swatchSVG(bg, fg, motif){
  const m = {
    trellis:`<path d="M0 50 L50 0 L100 50 L50 100 Z" fill="none" stroke="${fg}" stroke-width="3"/><path d="M50 0 L100 50 L50 100 L0 50 Z" fill="none" stroke="${fg}" stroke-width="3"/><circle cx="50" cy="50" r="6" fill="${fg}"/>`,
    leaf:`<path d="M50 8 C30 30 30 70 50 92 C70 70 70 30 50 8 Z" fill="${fg}" opacity=".85"/><path d="M50 8 L50 92" stroke="${bg}" stroke-width="2"/>`,
    floral:`<g fill="${fg}"><circle cx="50" cy="32" r="11"/><circle cx="32" cy="55" r="11"/><circle cx="68" cy="55" r="11"/><circle cx="50" cy="72" r="11"/><circle cx="50" cy="52" r="7" fill="${bg}"/></g>`,
    stripe:`<rect x="14" width="14" height="100" fill="${fg}" opacity=".8"/><rect x="58" width="14" height="100" fill="${fg}" opacity=".8"/>`,
    geo:`<rect x="20" y="20" width="60" height="60" fill="none" stroke="${fg}" stroke-width="4"/><rect x="35" y="35" width="30" height="30" fill="${fg}" opacity=".7"/>`
  }[motif] || '';
  const svg=`<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 100 100"><rect width="100" height="100" fill="${bg}"/>${m}</svg>`;
  return "data:image/svg+xml;utf8,"+encodeURIComponent(svg);
}
const FALLBACK = [
  ["Aga Jungle on Off-White","China Seas","#f3efe6","#2f6b4f","leaf","Jungle Green"],
  ["Lyford Trellis — Indigo","Quadrille","#1f3a5f","#e8edf4","trellis","Indigo"],
  ["Fiorentina — Coral","China Seas","#fbeee6","#c4674e","floral","Coral"],
  ["Henriot Stripe — Oatmeal","Quadrille","#efe9dc","#8a7a5c","stripe","Oatmeal"],
  ["Veneto — Charcoal","China Seas","#2b2926","#d8cfbf","geo","Charcoal"],
  ["Arbre de Matisse — Celadon","Quadrille","#e7efe4","#5e8a6a","leaf","Celadon"],
  ["Bali Hai — Aqua","China Seas","#e2f1f0","#3a8f93","floral","Aqua"],
  ["Cap Ferrat — Rose","Quadrille","#f7e9ec","#b06a82","trellis","Rose"],
  ["Sigourney — Mustard","China Seas","#f4ead0","#c79a3a","stripe","Mustard"],
  ["Persia — Ink","Quadrille","#1c1f2b","#cfc6b4","geo","Ink"],
  ["Lulu — Blush","China Seas","#fbeef0","#d398a6","floral","Blush"],
  ["Kazak — Terracotta","Quadrille","#f0e4dc","#b15a3c","trellis","Terracotta"],
].map((r,i)=>({
  pattern_name:r[0], vendor:r[1], collection:r[1],
  image:swatchSVG(r[2],r[3],r[4]), color_name:r[5],
  width:27, repeat:27, sku:"DEMO-"+(100+i)
}));

let queue = [];      // remaining products (head = top card)
let history = [];     // {item, verdict, reorder} for undo
let favorites = [];   // kept items
let seen = 0;

const $ = id => document.getElementById(id);
const deck = $("deck");

/* ── enrichment-grounded taste model (CONTRARIAN FIX) ──────────────────────
   A keep biases the REMAINING queue toward the kept item's family / colour, so
   the tower curates as you swipe ("more like this"). The taste vector decays so
   recent keeps matter most; skips gently down-weight a family so you're not
   force-fed something you just rejected. Re-ranking is SOFT — it nudges similar
   items up over the next several cards, never teleports the very next card, so
   the deck still feels like discovery, not a filtered list. */
const taste = { fam:{}, bucket:{}, hue:null, keeps:0 };
const HUE_DECAY = 0.78;   // how much the running hue preference leans to the newest keep
function enrichOf(p){ return ENRICH[p.sku] || null; }
function famOf(p){ const e=enrichOf(p); return e&&e.family ? String(e.family).toLowerCase()
    : (p.pattern_name||"").trim().split(/\s+on\s+/i)[0].split(/\s+/)[0].toLowerCase(); }
function bucketOf(p){ const e=enrichOf(p); return e&&e.color_bucket ? e.color_bucket : null; }
function hueOf(p){ const e=enrichOf(p); return e&&typeof e.hue==="number" ? e.hue : null; }
function hueDist(a,b){ const d=Math.abs(a-b)%360; return d>180?360-d:d; }   // 0..180

function recordKeep(p){
  const f=famOf(p), b=bucketOf(p), h=hueOf(p);
  if(f) taste.fam[f]=(taste.fam[f]||0)+1;
  if(b) taste.bucket[b]=(taste.bucket[b]||0)+1;
  if(h!=null) taste.hue = (taste.hue==null) ? h : circMix(taste.hue, h, 1-HUE_DECAY);
  taste.keeps++;
}
function recordSkip(p){
  const f=famOf(p), b=bucketOf(p);
  if(f) taste.fam[f]=(taste.fam[f]||0)-0.6;       // gently down-weight a rejected family
  if(b) taste.bucket[b]=(taste.bucket[b]||0)-0.3;
}
// circular mix of two hues (deg) by t toward b
function circMix(a,b,t){
  const ar=a*Math.PI/180, br=b*Math.PI/180;
  const x=Math.cos(ar)*(1-t)+Math.cos(br)*t, y=Math.sin(ar)*(1-t)+Math.sin(br)*t;
  return ((Math.atan2(y,x)*180/Math.PI)+360)%360;
}
// affinity score of an item vs the current taste. Weights are FLOORED so a
// rejected family is down-weighted, never soft-banned into oblivion.
function affinity(p){
  let s=0;
  const f=famOf(p); if(f && taste.fam[f]) s += Math.max(-1.0, taste.fam[f])*3.0;
  const b=bucketOf(p); if(b && taste.bucket[b]) s += Math.max(-1.0, taste.bucket[b])*1.4;
  const h=hueOf(p); if(h!=null && taste.hue!=null) s += (1 - hueDist(h,taste.hue)/180)*1.6;
  return s;
}
/* Re-rank the queue toward taste — runs on EVERY verdict so skips count too.
   FREEZE protects the whole VISIBLE stack (top + 2 peeking cards) so nothing the
   user can see swaps under them. Every 4th slot is reserved for an EXPLORATION
   pick (the best NON-matching item) so the deck curates toward taste without
   sealing the user in a monochrome filter bubble. Returns a snapshot for undo. */
const FREEZE = 3;        // protect the visible stack (depth 0,1,2)
const EXPLORE_EVERY = 4; // 1-in-4 slots is a deliberate curveball
function rerankQueue(){
  if(taste.keeps===0) return null;
  if(queue.length <= FREEZE+2) return null;
  const before = queue.slice();
  const head = queue.slice(0, FREEZE);
  let rest = queue.slice(FREEZE);
  rest.forEach((p,i)=>{ p.__ord=i; });
  // taste-ranked list (affinity dominates; original order is gentle inertia)
  const ranked = rest.slice().sort((a,b)=>
    (affinity(b)-b.__ord*0.012) - (affinity(a)-a.__ord*0.012));
  // exploration pool = items the taste does NOT pull (low/zero affinity), kept in
  // their natural order so curveballs feel like fresh discovery, not noise
  const meanAff = ranked.reduce((s,p)=>s+affinity(p),0)/ranked.length;
  const explore = rest.filter(p=>affinity(p) <= meanAff*0.5)
                      .sort((a,b)=>a.__ord-b.__ord);
  // weave: mostly taste-ranked, every EXPLORE_EVERY-th slot a curveball
  const out=[]; const used=new Set(); let ri=0, ei=0;
  while(out.length < rest.length){
    const slot = out.length;
    const wantExplore = (slot % EXPLORE_EVERY === EXPLORE_EVERY-1);
    let pick=null;
    if(wantExplore){
      while(ei<explore.length && used.has(explore[ei])) ei++;
      if(ei<explore.length) pick=explore[ei++];
    }
    if(!pick){ while(ri<ranked.length && used.has(ranked[ri])) ri++;
      if(ri<ranked.length) pick=ranked[ri++]; }
    if(!pick) break;
    used.add(pick); out.push(pick);
  }
  out.forEach(p=>{ delete p.__ord; p.__explore = (affinity(p) <= meanAff*0.5); });
  queue = head.concat(out);
  return before;
}

/* ── derive a short color/colorway word from pattern_name (hue is null in data) ── */
const COLORWORDS = ["white","off-white","ivory","cream","oatmeal","alabaster","greige","grey","gray","charcoal","black","ink","navy","blue","indigo","teal","aqua","celadon","sage","green","jungle","olive","moss","gold","ochre","mustard","yellow","apricot","coral","blush","pink","rose","red","crimson","terracotta","rust","brown","taupe","tan","sand","bronze","copper","silver","plum","purple","lavender","aubergine"];
function colorwordFor(p){
  if(p.color_name) return p.color_name;
  const name = (p.pattern_name||"").toLowerCase();
  const hit = COLORWORDS.find(c => name.includes(c));
  if(hit) return hit.replace(/\b\w/g, m=>m.toUpperCase());
  if(p.color) return p.color;
  return null;
}
function specLine(p){
  const w = p.width || 27, r = p.repeat || 27;
  const parts = [`${w}″ wide`];
  if(r) parts.push(`${r}″ repeat`);
  return parts;
}

/* ───────────────────────────  CARD RENDER  ─────────────────────────── */
function makeCard(p, depth){
  const card = document.createElement("div");
  card.className = "card" + (depth===1?" behind-1":depth===2?" behind-2":"");
  card.dataset.depth = depth;

  const swatch = document.createElement("div");
  swatch.className = "swatch";
  const img = new Image();
  img.onload = ()=>{ swatch.style.backgroundImage = `url("${p.image}")`; };
  img.onerror = ()=>{ swatch.style.backgroundImage = `url("${PLACEHOLDER}")`; };
  img.src = p.image || PLACEHOLDER;

  const stampKeep = el("div","stamp keep","Keep");
  const stampSkip = el("div","stamp skip","Skip");
  swatch.append(stampKeep, stampSkip);

  // CONTRARIAN FIX — when this card surfaced BECAUSE of your taste, say so; when
  // it's a deliberate curveball, label THAT too. Curation + exploration, both legible.
  if(depth===0 && taste.keeps>0){
    if(p.__explore){ const w=el("div","why curve","A change of pace"); swatch.appendChild(w); }
    else { const reason = whySurfaced(p);
      if(reason){ const w=el("div","why",reason); swatch.appendChild(w); } }
  }

  const meta = document.createElement("div");
  meta.className = "meta";
  const cw = colorwordFor(p);
  meta.innerHTML = `
    <p class="pname">${esc(cleanName(p.pattern_name))}</p>
    <div class="pvendor">${esc(p.vendor || p.collection || "Designer Wallcoverings")}</div>
    <div class="spec">
      ${specLine(p).map(s=>`<span>${esc(s)}</span>`).join('<span class="dot"></span>')}
      ${cw?`<span class="dot"></span><span>${esc(cw)}</span>`:""}
    </div>`;

  card.append(swatch, meta);
  return card;
}
// why did this card surface? (only when it genuinely matches your taste)
function whySurfaced(p){
  const f=famOf(p); if(f && (taste.fam[f]||0)>=1) return "More like "+cap(f);
  const b=bucketOf(p); if(b && (taste.bucket[b]||0)>=1) return "Your "+b+" picks";
  const h=hueOf(p); if(h!=null && taste.hue!=null && hueDist(h,taste.hue)<26) return "Your palette";
  return null;
}
function el(t,c,txt){ const e=document.createElement(t); e.className=c; e.textContent=txt; return e; }
function esc(s){ return String(s==null?"":s).replace(/[&<>"]/g, m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[m])); }
function cleanName(s){ return (s||"Untitled").replace(/\s*-\s*[^-]+$/, m => m.length<22 ? m : ""); }

/* ───────────────────────────  DECK STATE  ─────────────────────────── */
function renderDeck(){
  // remove existing cards (keep loading/empty nodes)
  deck.querySelectorAll(".card").forEach(c=>c.remove());
  const top3 = queue.slice(0,3);
  // render back-to-front so top card is last (highest z)
  for(let i=top3.length-1; i>=0; i--){
    const card = makeCard(top3[i], i);
    deck.appendChild(card);
    if(i===0) attachDrag(card);
  }
  updateCounters();
  const done = queue.length===0;
  $("empty").classList.toggle("show", done);
  $("actions").style.visibility = done ? "hidden":"visible";
  $("hint").style.visibility = done ? "hidden":"visible";
  $("undoBtn").disabled = history.length===0;
  $("emptyTitle").textContent = favorites.length ? "That's the collection" : "All caught up";
}

function updateCounters(){
  $("seenCt").textContent = seen;
  $("leftCt").textContent = queue.length;
  const n = favorites.length;
  const fc = $("favCt"); fc.textContent = n; fc.classList.toggle("zero", n===0);
  $("sampleCt").textContent = n;
  $("sampleBtn").disabled = n===0;
  if(taste.keeps>0){ $("curating").classList.add("show"); }
}

/* ───────────────────────────  VERDICT / THROW  ─────────────────────────── */
let busy = false;
function topCard(){ return deck.querySelector('.card[data-depth="0"]'); }

function decide(verdict, fromDrag){
  if(busy || queue.length===0) return;
  busy = true;
  const card = topCard();
  const item = queue[0];
  const dir = verdict==="keep" ? 1 : -1;

  // show stamp
  const stamp = card.querySelector(verdict==="keep" ? ".stamp.keep" : ".stamp.skip");
  if(stamp) stamp.style.opacity = "1";

  // throw animation
  const throwX = dir * (window.innerWidth*0.9 + 200);
  const rot = dir * 18;
  card.style.transition = "transform .42s cubic-bezier(.36,.07,.4,1), opacity .42s";
  card.style.transform = `translate(${throwX}px, -40px) rotate(${rot}deg)`;
  card.style.opacity = "0";

  // promote remaining cards immediately so the stack feels live
  const b1 = deck.querySelector('.card[data-depth="1"]');
  const b2 = deck.querySelector('.card[data-depth="2"]');
  if(b1){ b1.style.transition="transform .42s cubic-bezier(.2,.9,.25,1)"; b1.className="card"; b1.dataset.depth="0"; }
  if(b2){ b2.style.transition="transform .42s cubic-bezier(.2,.9,.25,1)"; b2.className="card behind-1"; b2.dataset.depth="1"; }

  // commit state
  queue.shift();
  seen++;
  // update the taste model, then softly re-rank what's left so the tower curates
  // toward what you keep. Snapshot the pre-rerank order so undo is exact.
  if(verdict==="keep") recordKeep(item); else recordSkip(item);
  const reorder = rerankQueue();   // runs on EVERY verdict (skips re-rank too)
  if(verdict==="keep"){ favorites.unshift(item); addFav(item); }
  history.push({item, verdict, reorder});

  if(verdict==="keep" && reorder) flashCurating();

  setTimeout(()=>{
    busy = false;
    renderDeck(); // rebuilds stack cleanly + brings in next back card + wires drag
  }, 430);
}

function undo(){
  if(busy || history.length===0) return;
  const last = history.pop();
  // unwind the taste model + restore the exact pre-rerank queue so undo is faithful
  if(last.verdict==="keep") unrecordKeep(last.item); else unrecordSkip(last.item);
  if(last.reorder){ queue = last.reorder; }
  queue.unshift(last.item);
  seen = Math.max(0, seen-1);
  if(last.verdict==="keep"){
    favorites = favorites.filter(f=>f!==last.item);
    removeFavNode(last.item);
  }
  renderDeck();
}
function unrecordKeep(p){
  const f=famOf(p), b=bucketOf(p);
  if(f && taste.fam[f]) taste.fam[f]-=1;
  if(b && taste.bucket[b]) taste.bucket[b]-=1;
  taste.keeps=Math.max(0,taste.keeps-1);
  // hue is a decayed running value; rebuild it from the remaining keeps for fidelity
  rebuildTasteHue();
}
function unrecordSkip(p){
  const f=famOf(p), b=bucketOf(p);
  if(f) taste.fam[f]=(taste.fam[f]||0)+0.6;
  if(b) taste.bucket[b]=(taste.bucket[b]||0)+0.3;
}
function rebuildTasteHue(){
  taste.hue=null;
  for(const fav of favorites.slice().reverse()){
    const h=hueOf(fav); if(h!=null) taste.hue=(taste.hue==null)?h:circMix(taste.hue,h,1-HUE_DECAY);
  }
}

/* curating indicator — fires when a keep re-ranks the deck toward your taste */
let curatingT;
function flashCurating(){
  const top = topTasteLabel();
  const el = $("curating");
  el.textContent = top ? `More ${top} ahead` : "Tuning to your taste…";
  el.classList.add("show","pulse");
  clearTimeout(curatingT);
  curatingT = setTimeout(()=>{ el.classList.remove("pulse"); }, 950);
}
// the strongest current taste signal, as a human label for the indicator
function topTasteLabel(){
  let bestF=null,bv=0; for(const k in taste.fam){ if(taste.fam[k]>bv){bv=taste.fam[k];bestF=k;} }
  if(bestF && bv>=2) return cap(bestF);
  let bestB=null,bb=0; for(const k in taste.bucket){ if(taste.bucket[k]>bb){bb=taste.bucket[k];bestB=k;} }
  if(bestB) return bestB;
  return null;
}
function cap(s){ return s? s.charAt(0).toUpperCase()+s.slice(1):s; }

/* ───────────────────────────  FAVORITES TRAY  ─────────────────────────── */
const favList = $("favList");
function addFav(p){
  $("trayEmpty").style.display = "none";
  const row = document.createElement("div");
  row.className = "fav";
  row._item = p;
  const cw = colorwordFor(p);
  row.innerHTML = `
    <div class="thumb" style="background-image:url('${(p.image||PLACEHOLDER).replace(/'/g,"%27")}')"></div>
    <div class="fmeta">
      <p class="fname">${esc(cleanName(p.pattern_name))}</p>
      <div class="fvendor">${esc(p.vendor||p.collection||"")}</div>
      <div class="fspec">${(p.width||27)}″ &middot; ${cw?esc(cw):"swatch"}</div>
    </div>
    <button class="rm" title="Remove" aria-label="Remove">&times;</button>`;
  row.querySelector(".rm").onclick = ()=> removeFav(p);
  favList.prepend(row);
  updateCounters();
}
function removeFav(p){
  favorites = favorites.filter(f=>f!==p);
  removeFavNode(p);
  updateCounters();
}
function removeFavNode(p){
  [...favList.querySelectorAll(".fav")].forEach(r=>{ if(r._item===p) r.remove(); });
  if(favorites.length===0) $("trayEmpty").style.display = "";
}

/* ───────────────────────────  DRAG TO SWIPE  ─────────────────────────── */
function attachDrag(card){
  let startX=0, startY=0, dx=0, dy=0, dragging=false;
  const threshold = 110;

  const onDown = e=>{
    if(busy) return;
    dragging = true;
    const pt = e.touches?e.touches[0]:e;
    startX = pt.clientX; startY = pt.clientY; dx=dy=0;
    card.style.transition = "none";
    window.addEventListener("mousemove", onMove);
    window.addEventListener("mouseup", onUp);
    window.addEventListener("touchmove", onMove, {passive:false});
    window.addEventListener("touchend", onUp);
  };
  const onMove = e=>{
    if(!dragging) return;
    const pt = e.touches?e.touches[0]:e;
    dx = pt.clientX-startX; dy = pt.clientY-startY;
    if(e.cancelable && Math.abs(dx)>6) e.preventDefault();
    const rot = dx/22;
    card.style.transform = `translate(${dx}px, ${dy*0.4}px) rotate(${rot}deg)`;
    const ks = card.querySelector(".stamp.keep"), ss = card.querySelector(".stamp.skip");
    if(ks) ks.style.opacity = dx>20 ? Math.min(1, dx/threshold).toFixed(2):0;
    if(ss) ss.style.opacity = dx<-20 ? Math.min(1, -dx/threshold).toFixed(2):0;
  };
  const onUp = ()=>{
    if(!dragging) return;
    dragging = false;
    window.removeEventListener("mousemove", onMove);
    window.removeEventListener("mouseup", onUp);
    window.removeEventListener("touchmove", onMove);
    window.removeEventListener("touchend", onUp);
    if(Math.abs(dx) > threshold){
      decide(dx>0 ? "keep":"skip", true);
    } else {
      card.style.transition = "transform .3s cubic-bezier(.2,.9,.25,1)";
      card.style.transform = "";
      const ks=card.querySelector(".stamp.keep"), ss=card.querySelector(".stamp.skip");
      if(ks) ks.style.opacity=0; if(ss) ss.style.opacity=0;
    }
  };
  card.addEventListener("mousedown", onDown);
  card.addEventListener("touchstart", onDown, {passive:true});
}

/* ───────────────────────────  WIRING  ─────────────────────────── */
$("keepBtn").onclick = ()=> decide("keep");
$("skipBtn").onclick = ()=> decide("skip");
$("undoBtn").onclick = undo;
$("restartBtn").onclick = ()=> { history=[]; favorites.forEach(removeFavNode); favorites=[]; load(true); };
$("sampleBtn").onclick = ()=>{
  if(!favorites.length) return;
  const note = $("sampleNote");
  note.textContent = `${favorites.length} sample${favorites.length>1?"s":""} queued — a stylist will confirm your memo set.`;
  note.style.color = "var(--keep)";
};

document.addEventListener("keydown", e=>{
  if(e.key==="ArrowRight"){ e.preventDefault(); decide("keep"); }
  else if(e.key==="ArrowLeft"){ e.preventDefault(); decide("skip"); }
  else if(e.key==="Backspace"){ e.preventDefault(); undo(); }
});

/* ───────────────────────────  LOAD  ─────────────────────────── */
async function load(reshuffle){
  $("loading").style.display = "flex";
  $("empty").classList.remove("show");
  // reset the taste model on every (re)load
  taste.fam={}; taste.bucket={}; taste.hue=null; taste.keeps=0;
  $("curating").classList.remove("show","pulse");
  // load REAL enrichment once so keeps can bias the queue by family/colour
  if(!Object.keys(ENRICH).length){
    try{ const er=await fetch(ENRICH_URL); const ej=await er.json();
         ENRICH=(ej&&ej.enrich)?ej.enrich:(ej||{}); }catch(e){ ENRICH={}; }
  }
  let items = [];
  try{
    const r = await fetch(API);
    if(!r.ok) throw new Error("HTTP "+r.status);
    const j = await r.json();
    items = (j.products || j || []).filter(p=>p && p.image);
  }catch(err){
    // Standalone / offline: fall back to the embedded sample set so the
    // mechanic is always usable. (Same-origin from :7690 the live fetch works.)
    console.warn("[swipe-tower] live API unavailable, using offline sample set:", err.message);
    items = FALLBACK.slice();
  }
  if(!items.length) items = FALLBACK.slice();
  // CONTRARIAN FIX — ALWAYS shuffle the deck. The API hands back a SKU-sorted,
  // family-clustered list; without a shuffle the taste re-rank is invisible (the
  // head is already 12 of the same family) and there's nothing to "discover".
  items = items.slice().sort(()=>Math.random()-0.5);
  queue = items;
  history = []; seen = 0;
  $("loading").style.display = "none";
  renderDeck();
}
load();

// headless probe — prove keep actually biases the remaining queue
window.__swipe = {
  ready:()=>queue.length>0 && Object.keys(ENRICH).length>0,
  // simulate keeping the current family N times, return how concentrated the
  // next K cards become in that family (curation working = rises after keeps)
  testBias(){
    if(queue.length<10) return {ok:false, reason:"short queue"};
    const fam0 = famOf(queue[0]);
    const famInNext = (k)=> queue.slice(1,1+k).filter(p=>famOf(p)===fam0).length;
    const before = famInNext(8);
    // keep the top card (same family signal)
    let kept=0;
    for(let i=0;i<3 && queue.length>2;i++){
      if(famOf(queue[0])===fam0){ decideSync("keep"); kept++; }
      else break;
    }
    const after = famInNext(8);
    return { fam0, keptSameFam:kept, sameFamInNext8_before:before,
             sameFamInNext8_after:after, taste:JSON.parse(JSON.stringify(taste)),
             biasedUp: after>=before };
  },
  queueFams:()=>queue.slice(0,12).map(famOf),
  taste:()=>JSON.parse(JSON.stringify(taste)),
  // realistic test: pick a target bucket, measure its share of the TOP-20 of the
  // remaining queue before vs after keeping 3 of it. Curation works ⇒ share rises.
  // Also reports exploration density so we prove it's not a sealed filter bubble.
  testBiasShuffled(){
    queue = queue.slice().sort(()=>Math.random()-0.5);
    // choose a target bucket that exists but is NOT already concentrated up top
    const counts={}; queue.forEach(p=>{ const b=bucketOf(p); if(b) counts[b]=(counts[b]||0)+1; });
    const target = Object.keys(counts).sort((a,b)=>counts[b]-counts[a])[1] || Object.keys(counts)[0];
    const shareTop20 = ()=> queue.slice(0,20).filter(p=>bucketOf(p)===target).length/20;
    const before = shareTop20();
    // keep 3 of the target bucket (simulate a user leaning into it)
    let kept=0, guard=0;
    while(kept<3 && guard++<queue.length){
      const i = queue.findIndex(p=>bucketOf(p)===target);
      if(i<0) break;
      const [p]=queue.splice(i,1); queue.unshift(p);   // bring to head, then keep
      decideSync("keep"); kept++;
    }
    const after = shareTop20();
    // exploration density: how many of the next 20 are deliberately off-taste
    const exploreInNext20 = queue.slice(0,20).filter(p=>p.__explore).length;
    return { target, keptOfBucket:kept,
             targetShareTop20_before:+before.toFixed(2),
             targetShareTop20_after:+after.toFixed(2),
             curationWorks: after > before,
             exploreCardsInNext20: exploreInNext20,
             notAFilterBubble: exploreInNext20 >= 2 };
  }
};
function avg(a){ return a.length? a.reduce((x,y)=>x+y,0)/a.length : 0; }
// synchronous decide for the probe (skips the throw animation timing)
function decideSync(verdict){
  const item=queue[0]; if(!item) return;
  queue.shift(); seen++;
  if(verdict==="keep") recordKeep(item); else recordSkip(item);
  const reorder=rerankQueue();
  if(verdict==="keep"){ favorites.unshift(item); }
  history.push({item,verdict,reorder});
}
</script>

<script>window.PROTO_META={"key":"V8","label":"V8 — Swipe Tower","elements":[{"n":1,"label":"One big swatch at a time, centre stage"},{"n":2,"label":"Swipe / button KEEP to save to favorites"},{"n":3,"label":"Swipe / button SKIP to pass"},{"n":4,"label":"Favorites stack builds as you go"},{"n":5,"label":"Card spec (pattern · colorway · SKU)"}]};</script>
<script src="./proto-chrome.js"></script>
</body>
</html>