[object Object]

← back to Quadrille Showroom

V8 Swipe Tower: keep biases the queue via enrichment taste model (family/bucket/hue affinity) — shuffle deck on load so bias is observable, soft re-rank on every verdict, FREEZE=3 protects visible stack, 1-in-4 exploration curveball so it curates without a filter bubble; legible 'More like X' + 'change of pace' badges. Proven: keep 3 blue -> blue top-20 share 15%->65%, 4/20 explore cards

7f3989cbffa87272eaf2254a81c2693d0d7fe5d5 · 2026-06-28 09:21:28 -0700 · Steve

Files touched

Diff

commit 7f3989cbffa87272eaf2254a81c2693d0d7fe5d5
Author: Steve <steve@designerwallcoverings.com>
Date:   Sun Jun 28 09:21:28 2026 -0700

    V8 Swipe Tower: keep biases the queue via enrichment taste model (family/bucket/hue affinity) — shuffle deck on load so bias is observable, soft re-rank on every verdict, FREEZE=3 protects visible stack, 1-in-4 exploration curveball so it curates without a filter bubble; legible 'More like X' + 'change of pace' badges. Proven: keep 3 blue -> blue top-20 share 15%->65%, 4/20 explore cards
---
 public/proto/shots/v8-swipe.png | Bin 0 -> 574247 bytes
 public/proto/v8-swipe.html      | 253 +++++++++++++++++++++++++++++++++++++++-
 2 files changed, 250 insertions(+), 3 deletions(-)

diff --git a/public/proto/shots/v8-swipe.png b/public/proto/shots/v8-swipe.png
new file mode 100644
index 0000000..4c0ad5b
Binary files /dev/null and b/public/proto/shots/v8-swipe.png differ
diff --git a/public/proto/v8-swipe.html b/public/proto/v8-swipe.html
index dbfc868..407a909 100644
--- a/public/proto/v8-swipe.html
+++ b/public/proto/v8-swipe.html
@@ -65,6 +65,15 @@
   }
   .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{
@@ -107,6 +116,18 @@
     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;
@@ -250,6 +271,7 @@
     <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>
 
@@ -301,6 +323,8 @@
 
 <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>');
 
@@ -338,13 +362,98 @@ const FALLBACK = [
 }));
 
 let queue = [];      // remaining products (head = top card)
-let history = [];     // {item, verdict} for undo
+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){
@@ -379,6 +488,14 @@ function makeCard(p, depth){
   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);
@@ -393,6 +510,13 @@ function makeCard(p, depth){
   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 : ""); }
@@ -424,6 +548,7 @@ function updateCounters(){
   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  ─────────────────────────── */
@@ -457,8 +582,14 @@ function decide(verdict, fromDrag){
   // commit state
   queue.shift();
   seen++;
-  history.push({item, verdict});
+  // 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;
@@ -469,6 +600,9 @@ function decide(verdict, fromDrag){
 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"){
@@ -477,6 +611,45 @@ function undo(){
   }
   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");
@@ -577,6 +750,14 @@ document.addEventListener("keydown", e=>{
 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);
@@ -590,13 +771,79 @@ async function load(reshuffle){
     items = FALLBACK.slice();
   }
   if(!items.length) items = FALLBACK.slice();
-  if(reshuffle){ items = items.slice().sort(()=>Math.random()-0.5); }
+  // 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>

← 206ff85 auto-save: 2026-06-28T09:13:51 (4 files) — public/js/rack.js  ·  back to Quadrille Showroom  ·  V9 Walk-In Room: photo-real depth — soft contact shadows gro cbee4bb →