← 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
M public/js/wallco-game.jsM server.js
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 — 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 · 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,'"') + '" 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' ? '★ Super-love recorded' : '♥ 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 →