[object Object]

← back to Wallco Ai

wallco.ai · refinement signal: ctx-passing through /swipe + /poll + /hot-or-not into WC.reward; /api/personal/recs surface curated picks at 80% refinement

c6eb465ed4fc80f5d1f3030f8588a50a01dd34bf · 2026-05-12 00:18:51 -0700 · SteveStudio2

Files touched

Diff

commit c6eb465ed4fc80f5d1f3030f8588a50a01dd34bf
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 00:18:51 2026 -0700

    wallco.ai · refinement signal: ctx-passing through /swipe + /poll + /hot-or-not into WC.reward; /api/personal/recs surface curated picks at 80% refinement
---
 public/js/wallco-game.js | 121 ++++++++++++++++++++++++++++++++++++++++++++++-
 server.js                |  62 ++++++++++++++++++++++--
 2 files changed, 176 insertions(+), 7 deletions(-)

diff --git a/public/js/wallco-game.js b/public/js/wallco-game.js
index f0f420b..971a27d 100644
--- a/public/js/wallco-game.js
+++ b/public/js/wallco-game.js
@@ -57,6 +57,7 @@
     if (!s.maxStreak) s.maxStreak = 0;
     if (!s.supers) s.supers = 0;
     if (!s.unlocked) s.unlocked = {};
+    if (!s.likedSamples) s.likedSamples = [];  // [{id, category, hex, action, t}]
     if (!s.day || s.day !== today()) {
       s.todayVotes = 0;
       s.questDone = false;
@@ -73,6 +74,20 @@
     s.level = lvl;
     s.levelName = LEVELS[lvl].name;
     s.nextXP = LEVELS[lvl + 1] ? LEVELS[lvl + 1].xp : null;
+
+    // ── Refinement score: 0-100. Reaches 100 at ~30 positive votes with
+    // category variance ≥3. 80% threshold unlocks product recommendations.
+    var positives = (s.likedSamples || []).filter(function (x) {
+      return x.action === 'love' || x.action === 'super' || x.action === 'hot' ||
+             x.action === 'pickA' || x.action === 'pickB';
+    });
+    var cats = {}; positives.forEach(function (p) { if (p.category) cats[p.category] = true; });
+    var catVariance = Object.keys(cats).length;
+    var byCount = Math.min(positives.length / 30, 1);
+    var byVariance = Math.min(catVariance / 3, 1);
+    // 80/20 weight: mostly count, some variance bonus
+    s.refinement = Math.round((byCount * 0.85 + byVariance * 0.15) * 100);
+    s.refinementUnlocked = s.refinement >= 80;
     return s;
   }
 
@@ -166,6 +181,8 @@
       ? Math.round((s.xp - LEVELS[s.level].xp) / (s.nextXP - LEVELS[s.level].xp) * 100)
       : 100;
     var quest = s.todayVotes + '/' + questGoal();
+    var refColor = s.refinementUnlocked ? '#5a9' : '#d2b15c';
+    var refLabel = s.refinementUnlocked ? 'Taste profile · UNLOCKED' : 'Refining your taste';
     b.innerHTML =
       '<div style="display:flex;flex-wrap:wrap;gap:14px;align-items:center;justify-content:center;font-size:12px;color:#f0eadc">' +
         '<span><span style="opacity:.6">Level</span> <b style="color:#d2b15c">' + s.level + ' ' + s.levelName + '</b></span>' +
@@ -178,7 +195,18 @@
       '</div>' +
       '<div style="height:3px;background:rgba(255,255,255,.1);border-radius:2px;margin-top:8px;overflow:hidden">' +
         '<div style="height:100%;background:linear-gradient(90deg,#d2b15c,#e0bd64);width:' + progress + '%;transition:width 400ms ease"></div>' +
-      '</div>';
+      '</div>' +
+      // ── Refinement bar — gold while building, teal once 80%+ + unlock cue
+      '<div style="display:flex;align-items:center;gap:10px;margin-top:10px;font-size:10px;color:#888">' +
+        '<span style="text-transform:uppercase;letter-spacing:.08em">' + refLabel + '</span>' +
+        '<div style="flex:1;height:4px;background:rgba(255,255,255,.08);border-radius:2px;overflow:hidden">' +
+          '<div style="height:100%;background:' + refColor + ';width:' + s.refinement + '%;transition:width 500ms ease"></div>' +
+        '</div>' +
+        '<b style="color:' + refColor + '">' + s.refinement + '%</b>' +
+      '</div>' +
+      (s.refinementUnlocked
+        ? '<div style="text-align:center;margin-top:8px;font-size:11px;color:#5a9;cursor:pointer" onclick="WC.showRecs()">✨ Your curated picks unlocked &mdash; tap to view</div>'
+        : '');
   }
 
   function checkAchievements(s) {
@@ -197,7 +225,9 @@
 
   window.WC = {
     state: state,
-    reward: function (action, originEvent) {
+    // ── Pass design context with the action so refinement signal is captured.
+    //    Pages can call WC.reward('love', evt, {id, category, dominant_hex})
+    reward: function (action, originEvent, ctx) {
       var prev = state();
       var s = state();
       var positive = (action === 'love' || action === 'super' || action === 'hot' || action === 'pickA' || action === 'pickB');
@@ -227,6 +257,14 @@
       s.recent = (s.recent || []).concat([Date.now()]).filter(function (t) { return Date.now() - t < 60000; });
       s.minute = s.recent.length;
 
+      // Record context for refinement signature (keep last 60)
+      if (ctx && ctx.id) {
+        s.likedSamples = (s.likedSamples || []).concat([{
+          id: ctx.id, category: ctx.category || null,
+          hex: ctx.dominant_hex || null, action: action, t: Date.now()
+        }]).slice(-60);
+      }
+
       var newLvl = 0;
       for (var i = LEVELS.length - 1; i >= 0; i--) {
         if (s.xp >= LEVELS[i].xp) { newLvl = i; break; }
@@ -283,10 +321,89 @@
       }
 
       checkAchievements(s);
+
+      // ── 80% refinement crossed: surface curated picks ─────────────────
+      if (s.refinement >= 80 && !prev.refinementUnlocked) {
+        s.unlocked.refinement_80 = Date.now();
+        setTimeout(function () {
+          toast('<span style="font-size:20px;margin-right:8px">✨</span><span>Taste profile unlocked &middot; picks coming up</span>', '#1a3a36');
+          buzz([80, 60, 80, 60, 80, 60, 300]);
+          chord([523, 659, 784, 1047], 0.32);
+          // Auto-render the recs panel
+          setTimeout(function () { window.WC.showRecs(true); }, 1400);
+        }, 250);
+      }
+
       save(s);
       updateBanner();
       return s;
     },
+    // ── Compute taste signature from likedSamples ─────────────────
+    signature: function () {
+      var s = state();
+      var pos = (s.likedSamples || []).filter(function (x) {
+        return x.action === 'love' || x.action === 'super' || x.action === 'hot' ||
+               x.action === 'pickA' || x.action === 'pickB';
+      });
+      var catCount = {}, hexCount = {};
+      pos.forEach(function (p) {
+        if (p.category) catCount[p.category] = (catCount[p.category] || 0) + 1;
+        if (p.hex)      hexCount[p.hex]      = (hexCount[p.hex] || 0) + 1;
+      });
+      function top(o, n) {
+        return Object.entries(o).sort(function (a, b) { return b[1] - a[1]; }).slice(0, n).map(function (x) { return x[0]; });
+      }
+      return { categories: top(catCount, 3), hexes: top(hexCount, 6), total: pos.length };
+    },
+    // ── Render the curated-picks panel into #wc-recs (auto-creates if missing) ──
+    showRecs: async function (firstReveal) {
+      var sig = window.WC.signature();
+      if (sig.total < 5) return;  // not enough data to pick from
+      var host = document.getElementById('wc-recs');
+      if (!host) {
+        host = document.createElement('section');
+        host.id = 'wc-recs';
+        host.style.cssText = 'margin-top:28px;padding:18px;background:linear-gradient(180deg,#1a3a36,#15201d);border:1px solid #2a5a52;border-radius:12px;color:#e8e2d6;';
+        // Insert after the game banner if present, else end of main
+        var anchor = document.getElementById('wc-game-banner') || document.querySelector('main') || document.body;
+        if (anchor.parentNode) anchor.parentNode.insertBefore(host, anchor.nextSibling);
+        else document.body.appendChild(host);
+      }
+      host.innerHTML = '<div style="font-size:11px;text-transform:uppercase;letter-spacing:.1em;color:#7cd;margin-bottom:8px">Curated for you · ' + sig.categories.slice(0,2).join(' · ') + (sig.hexes[0] ? ' · ' + sig.hexes[0] : '') + '</div>' +
+        '<h3 style="font-family:\'Cormorant Garamond\',Georgia,serif;font-weight:300;font-size:24px;margin:0 0 4px">Your top picks</h3>' +
+        '<p style="font-size:12px;color:#aaa;margin:0 0 14px">Designs that match your taste signature. Tap to request a free sample.</p>' +
+        '<div id="wc-recs-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:10px;min-height:160px"><div style="grid-column:1/-1;text-align:center;color:#888;padding:30px">Loading your picks…</div></div>';
+      try {
+        var body = {
+          categories: sig.categories,
+          hexes: sig.hexes,
+          ids:  (state().likedSamples || []).slice(-20).map(function (x) { return x.id; }).filter(Boolean),
+          limit: 8
+        };
+        var r = await fetch('/api/personal/recs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
+        if (!r.ok) throw new Error('recs ' + r.status);
+        var j = await r.json();
+        var picks = j.picks || [];
+        var grid = document.getElementById('wc-recs-grid');
+        if (!picks.length) { grid.innerHTML = '<div style="grid-column:1/-1;text-align:center;color:#888;padding:30px">No matches yet — keep voting!</div>'; return; }
+        grid.innerHTML = picks.map(function (p) {
+          return '<a href="' + (p.sample_url || ('https://wallco.ai/design/' + p.id)) + '" target="_blank" style="display:block;text-decoration:none;color:inherit;border:1px solid #2a4a47;border-radius:8px;overflow:hidden;background:#0f1c1a;transition:transform .15s,border-color .15s" onmouseover="this.style.borderColor=\'#5aa\';this.style.transform=\'scale(1.02)\'" onmouseout="this.style.borderColor=\'#2a4a47\';this.style.transform=\'\'">' +
+            '<img src="' + p.image_url + '" alt="' + (p.title||'').replace(/"/g,'&quot;') + '" style="width:100%;aspect-ratio:1;object-fit:cover;display:block">' +
+            '<div style="padding:8px 10px;font-size:11px">' +
+              '<div style="color:#e8e2d6">' + (p.title||'').slice(0,38) + '</div>' +
+              '<div style="color:#7cd;margin-top:3px">★ ' + (p.combined_score||'?') + ' · ' + (p.category||'') + '</div>' +
+              '<div style="margin-top:6px;padding:6px 8px;background:#5aa;color:#0a1614;border-radius:4px;text-align:center;font-weight:600">Request a sample</div>' +
+            '</div></a>';
+        }).join('');
+      } catch (e) {
+        var grid = document.getElementById('wc-recs-grid');
+        if (grid) grid.innerHTML = '<div style="grid-column:1/-1;color:#c66;padding:30px;text-align:center">Could not load picks: ' + e.message + '</div>';
+      }
+      // Smooth-scroll into view on first reveal
+      if (firstReveal) {
+        setTimeout(function () { host.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 200);
+      }
+    },
     refreshBanner: updateBanner
   };
 
diff --git a/server.js b/server.js
index 6d93810..6368eb2 100644
--- a/server.js
+++ b/server.js
@@ -6256,6 +6256,54 @@ app.get('/api/leaderboard', (req, res) => {
   } catch (e) { res.status(500).json({ error: e.message }); }
 });
 
+// ── POST /api/personal/recs — taste-signature-based product picks.
+//    Body: { categories, hexes, ids (excludeIds), limit }
+//    Returns: { picks:[{id, title, image_url, category, dominant_hex,
+//                       combined_score, sample_url}], signature }
+//    The client-side WC engine on /hot-or-not + /swipe + /play surfaces
+//    these once refinement ≥80%.
+app.post('/api/personal/recs', (req, res) => {
+  var { categories = [], hexes = [], ids = [], limit = 8 } = req.body || {};
+  categories = (categories || []).filter(Boolean).slice(0, 5);
+  hexes      = (hexes      || []).filter(h => /^#[0-9a-f]{6}$/i.test(h)).slice(0, 8);
+  ids        = (ids        || []).map(x => parseInt(x,10)).filter(Number.isFinite);
+
+  var catSql = categories.length
+    ? `category IN (${categories.map(c => "'" + String(c).replace(/'/g,"''") + "'").join(',')})`
+    : '1=1';
+  var exclSql = ids.length ? `AND id NOT IN (${ids.join(',')})` : '';
+
+  try {
+    var raw = psqlQuery(`SELECT COALESCE(json_agg(t), '[]'::json) FROM (
+      SELECT id, prompt, dominant_hex, category, local_path,
+             combined_score, user_score_avg, user_vote_count, elo,
+             (COALESCE(combined_score, 3) * 0.4
+              + COALESCE(user_score_avg, 3) * 0.4
+              + (elo - 1200) / 200.0
+              + CASE WHEN ${catSql} THEN 1.0 ELSE 0 END
+             ) AS rank_score
+      FROM spoon_all_designs
+      WHERE local_path IS NOT NULL
+        ${exclSql}
+      ORDER BY rank_score DESC NULLS LAST
+      LIMIT ${parseInt(limit, 10) || 8}) t;`);
+    var rows = JSON.parse(raw || '[]');
+    var picks = rows.map(r => {
+      var fn = (r.local_path || '').split('/').pop();
+      return {
+        id: r.id,
+        title: titleFor(r.category, r.dominant_hex, r.id),
+        image_url: '/designs/img/' + fn,
+        category: r.category,
+        dominant_hex: r.dominant_hex,
+        combined_score: r.combined_score ? Number(r.combined_score).toFixed(1) : null,
+        sample_url: '/samples?design=' + r.id
+      };
+    });
+    res.json({ picks: picks, signature: { categories: categories, hexes: hexes } });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
 app.get('/api/poll/stats', (_req, res) => {
   try {
     const pairs = psqlQuery(`SELECT COUNT(*) FROM wallco_poll_pairs;`);
@@ -6314,17 +6362,17 @@ async function loadPair() {
     var d = j[k];
     var card = document.getElementById('card-'+k);
     card.innerHTML = '<img src="'+d.image_url+'" alt="'+d.title+'"><div class="meta"><div class="title">'+d.title+'</div><div class="sub">'+(d.category||'')+'</div></div>';
-    card.onclick = function(evt){ vote(d.id, j[k==='a'?'b':'a'].id, evt); };
+    card.onclick = function(evt){ vote(d.id, j[k==='a'?'b':'a'].id, evt, {id: d.id, category: d.category, dominant_hex: d.dominant_hex}); };
   });
   try {
     var s = await (await fetch('/api/poll/stats')).json();
     if (s && s.total_pairs != null) document.getElementById('poll-stats').textContent = s.total_pairs + ' total votes cast · ' + (s.designs_polled||0) + ' designs in rotation';
   } catch(e) {}
 }
-async function vote(winner, loser, evt) {
+async function vote(winner, loser, evt, winnerCtx) {
   document.getElementById('card-a').style.opacity = 0.4;
   document.getElementById('card-b').style.opacity = 0.4;
-  if (window.WC) WC.reward('pickA', evt);
+  if (window.WC) WC.reward('pickA', evt, winnerCtx);
   await fetch('/api/poll/vote', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({winner: winner, loser: loser})});
   setTimeout(loadPair, 400);
   setTimeout(function(){ document.getElementById('card-a').style.opacity = 1; document.getElementById('card-b').style.opacity = 1; loadLB(); }, 500);
@@ -6490,6 +6538,8 @@ async function load(){
   document.getElementById('hon-card').dataset.id = r.id;
   document.getElementById('hon-card').dataset.title = r.title;
   document.getElementById('hon-card').dataset.image = r.image_url;
+  document.getElementById('hon-card').dataset.category = r.category || '';
+  document.getElementById('hon-card').dataset.hex = r.dominant_hex || '';
   seen.push(r.id);
 }
 async function act(action, evt){
@@ -6506,7 +6556,7 @@ async function act(action, evt){
   }
   todayCount++; localStorage.setItem(todayKey, String(todayCount));
   renderStreak();
-  if (window.WC) WC.reward(action, evt);
+  if (window.WC) WC.reward(action, evt, {id: id, category: card.dataset.category, dominant_hex: card.dataset.hex});
   picks.unshift({id: id, action: action, title: card.dataset.title, image_url: card.dataset.image});
   picks = picks.slice(0, 6);
   document.getElementById('hon-history').innerHTML = picks.map(function(h){
@@ -6595,6 +6645,8 @@ function renderStack(){
     var card = document.createElement('div');
     card.className = 'sw-card';
     card.dataset.id = d.id;
+    card.dataset.category = d.category || '';
+    card.dataset.hex = d.dominant_hex || '';
     card.style.zIndex = 100 - i;
     card.style.transform = 'translateY(' + (i * 6) + 'px) scale(' + (1 - i*0.03) + ')';
     card.innerHTML = '<img src="'+d.image_url+'" alt="'+d.title+'">' +
@@ -6674,7 +6726,7 @@ function commit(direction, evt){
   fetch('/api/design/' + id + '/user-vote', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({score: score, quick: quick}) });
   // Hook into the gamification engine
   var wcAction = direction==='right' ? 'love' : direction==='up' ? 'super' : 'skip';
-  if (window.WC) WC.reward(wcAction, evt);
+  if (window.WC) WC.reward(wcAction, evt, {id: id, category: card.dataset.category, dominant_hex: card.dataset.hex});
   if (direction === 'right' || direction === 'up') {
     swStreak++;
     document.getElementById('sw-toast').innerHTML = direction==='up' ? '&#9733; Super-love recorded' : '&#9829; Love recorded · streak ' + swStreak;

← 354cb17 wallco.ai · /designs "In a room ·N" filter chip — surface ro  ·  back to Wallco Ai  ·  wallco.ai · /studio R0c+R0d: studio_renders persistence + so e636fb7 →