← 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
A public/proto/shots/v8-swipe.pngM public/proto/v8-swipe.html
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=>({'&':'&','<':'<','>':'>','"':'"'}[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 →