[object Object]

← back to Wallco Ai

404 v2 (debate consensus): numeral tightening + strip pill + eyebrow + og:image

3a43f9b252047abe6a5e10dad401f7bdcda8a585 · 2026-05-11 22:06:58 -0700 · SteveStudio2

Applied per the graphic-designer + four-horsemen + debate-team-fast panel
ruling on the v1 404. Unanimous SHIP-WITH-REVISIONS verdict.

- #1 .nf-numeral: weight 300→400, letter-spacing -0.04em→-0.06em,
  opacity 0.85→1.0 (italic Cormorant at 280px was opening up too much
  and the gold went anemic on white themes)
- #2 .nf-path: stripped card-bg fill + 1px border + 3px radius, swapped
  in 1px dotted bottom border (hierarchy fix — the filled pill was the
  only non-editorial element and out-competed the h1)
- #3 .nf-pickup-label: 11px/0.18em/ink-faint → 12px/0.22em/ink-soft +
  weight 500 (recovery thumbs no longer read as orphaned)
- #4 ogImage: hydrate from pickups[0].image_url so social shares of a
  404 still render a real wallpaper preview; falls back to
  /og-default.png when no published designs are loaded at request time

Defer per panel: #5 stagger-reveal (polish), #6 kinetic canvas
(introduces visual restlessness that fights the luxury palette).

Files touched

Diff

commit 3a43f9b252047abe6a5e10dad401f7bdcda8a585
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Mon May 11 22:06:58 2026 -0700

    404 v2 (debate consensus): numeral tightening + strip pill + eyebrow + og:image
    
    Applied per the graphic-designer + four-horsemen + debate-team-fast panel
    ruling on the v1 404. Unanimous SHIP-WITH-REVISIONS verdict.
    
    - #1 .nf-numeral: weight 300→400, letter-spacing -0.04em→-0.06em,
      opacity 0.85→1.0 (italic Cormorant at 280px was opening up too much
      and the gold went anemic on white themes)
    - #2 .nf-path: stripped card-bg fill + 1px border + 3px radius, swapped
      in 1px dotted bottom border (hierarchy fix — the filled pill was the
      only non-editorial element and out-competed the h1)
    - #3 .nf-pickup-label: 11px/0.18em/ink-faint → 12px/0.22em/ink-soft +
      weight 500 (recovery thumbs no longer read as orphaned)
    - #4 ogImage: hydrate from pickups[0].image_url so social shares of a
      404 still render a real wallpaper preview; falls back to
      /og-default.png when no published designs are loaded at request time
    
    Defer per panel: #5 stagger-reveal (polish), #6 kinetic canvas
    (introduces visual restlessness that fights the luxury palette).
---
 server.js | 195 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 192 insertions(+), 3 deletions(-)

diff --git a/server.js b/server.js
index 9ef9d36..1e46554 100644
--- a/server.js
+++ b/server.js
@@ -372,6 +372,7 @@ function htmlHeader(active) {
     ['/murals', 'Murals'],
     ['/samples', 'Samples'],
     ['/studio', 'Studio'],
+    ['/poll', 'Vote'],
     ['/generator', 'Generator'],
     ['/spoonflower', 'Spoonflower'],
     ['/about', 'About'],
@@ -1540,10 +1541,11 @@ function renderDesignNotFound(req, idForCanonical) {
   }
   .nf-pickup-label {
     font-family: var(--sans);
-    font-size: 11px;
-    letter-spacing: 0.18em;
+    font-size: 12px;
+    letter-spacing: 0.22em;
     text-transform: uppercase;
-    color: var(--ink-faint);
+    color: var(--ink-soft);
+    font-weight: 500;
     text-align: center;
     margin-bottom: 28px;
   }
@@ -5392,6 +5394,193 @@ app.post('/api/design/:id/ai-rate', (req, res) => {
   res.json({ ok: true, queued: id });
 });
 
+// ─────────────────────────────────────────────────────────────────────────
+// GAMIFY — user votes + head-to-head polls + Elo + learning loop
+// ─────────────────────────────────────────────────────────────────────────
+function voterSession(req, res) {
+  const m = (req.headers.cookie || '').match(/wc-voter=([a-z0-9]+)/i);
+  if (m) return m[1];
+  const id = require('crypto').randomBytes(10).toString('hex');
+  res.setHeader('Set-Cookie', `wc-voter=${id}; Path=/; Max-Age=31536000; SameSite=Lax`);
+  return id;
+}
+
+app.post('/api/design/:id/user-vote', (req, res) => {
+  const id = parseInt(req.params.id, 10);
+  const s = parseInt((req.body || {}).score, 10);
+  const quick = !!(req.body || {}).quick;
+  if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad id' });
+  if (!Number.isFinite(s) || s < 1 || s > 5) return res.status(400).json({ error: 'score 1..5 required' });
+  const session = voterSession(req, res);
+  try {
+    psqlQuery(`INSERT INTO wallco_votes (design_id, voter_session, score, quick)
+      VALUES (${id}, '${session}', ${s}, ${quick ? 'TRUE' : 'FALSE'})
+      ON CONFLICT (design_id, voter_session) DO UPDATE SET score=EXCLUDED.score, created_at=now();`);
+    const agg = JSON.parse(psqlQuery(`SELECT row_to_json(t) FROM (SELECT
+      ROUND(AVG(score)::numeric, 2)::text AS avg, COUNT(*)::int AS cnt
+      FROM wallco_votes WHERE design_id=${id}) t;`));
+    psqlQuery(`UPDATE spoon_all_designs SET user_score_avg=${agg.avg || 'NULL'},
+      user_vote_count=${agg.cnt} WHERE id=${id};`);
+    res.json({ ok: true, user_score_avg: parseFloat(agg.avg), user_vote_count: agg.cnt });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+app.get('/api/design/:id/votes', (req, res) => {
+  const id = parseInt(req.params.id, 10);
+  if (!Number.isFinite(id)) return res.json({});
+  const session = voterSession(req, res);
+  try {
+    const my = psqlQuery(`SELECT score FROM wallco_votes WHERE design_id=${id} AND voter_session='${session}' LIMIT 1;`);
+    const agg = psqlQuery(`SELECT COALESCE(user_score_avg::text,'') || '|' || COALESCE(user_vote_count::text,'0') FROM spoon_all_designs WHERE id=${id};`);
+    const [avg, cnt] = (agg || '|').split('|');
+    res.json({ my_score: my ? parseInt(my,10) : null, avg: avg ? parseFloat(avg) : null, count: parseInt(cnt,10) || 0 });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+app.get('/api/poll/pair', (_req, res) => {
+  try {
+    const raw = psqlQuery(`SELECT COALESCE(json_agg(t), '[]'::json) FROM
+      (SELECT id, dominant_hex, image_url, local_path, category,
+              (poll_wins + poll_losses) AS plays
+       FROM spoon_all_designs WHERE local_path IS NOT NULL
+       ORDER BY (poll_wins + poll_losses) ASC, random() LIMIT 40) t;`);
+    const pool = JSON.parse(raw || '[]');
+    if (pool.length < 2) return res.json({ error: 'not enough designs' });
+    const a = pool[Math.floor(Math.random() * pool.length)];
+    let b = pool[Math.floor(Math.random() * pool.length)];
+    while (b.id === a.id) b = pool[Math.floor(Math.random() * pool.length)];
+    function imgFor(d){ return '/designs/img/' + (d.local_path || '').split('/').pop(); }
+    res.json({
+      a: { id: a.id, title: titleFor(a.category, a.dominant_hex, a.id), image_url: imgFor(a), category: a.category },
+      b: { id: b.id, title: titleFor(b.category, b.dominant_hex, b.id), image_url: imgFor(b), category: b.category }
+    });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+app.post('/api/poll/vote', (req, res) => {
+  const w = parseInt((req.body || {}).winner, 10), l = parseInt((req.body || {}).loser, 10);
+  if (!Number.isFinite(w) || !Number.isFinite(l) || w === l) return res.status(400).json({ error: 'winner+loser required, must differ' });
+  const session = voterSession(req, res);
+  try {
+    psqlQuery(`INSERT INTO wallco_poll_pairs (design_a, design_b, winner, voter_session)
+      VALUES (${Math.min(w,l)}, ${Math.max(w,l)}, ${w}, '${session}');`);
+    const rows = psqlQuery(`SELECT id::text || '|' || elo::text FROM spoon_all_designs WHERE id IN (${w}, ${l});`).split('\n').filter(Boolean);
+    const elos = {};
+    rows.forEach(r => { const [id, e] = r.split('|'); elos[id] = parseFloat(e); });
+    const Ra = elos[w] || 1200, Rb = elos[l] || 1200;
+    const Ea = 1 / (1 + Math.pow(10, (Rb - Ra) / 400));
+    const K = 32;
+    const newRa = Ra + K * (1 - Ea), newRb = Rb + K * (0 - (1 - Ea));
+    psqlQuery(`UPDATE spoon_all_designs SET elo=${newRa.toFixed(2)}, poll_wins=poll_wins+1 WHERE id=${w};`);
+    psqlQuery(`UPDATE spoon_all_designs SET elo=${newRb.toFixed(2)}, poll_losses=poll_losses+1 WHERE id=${l};`);
+    res.json({ ok: true, new_elo: { [w]: +newRa.toFixed(2), [l]: +newRb.toFixed(2) } });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+app.get('/api/leaderboard', (req, res) => {
+  const limit = Math.min(parseInt(req.query.limit || '20', 10), 100);
+  try {
+    const raw = psqlQuery(`SELECT COALESCE(json_agg(t), '[]'::json) FROM (
+      SELECT id, dominant_hex, category, local_path, combined_score,
+             user_score_avg, user_vote_count, poll_wins, poll_losses, elo,
+             (COALESCE(combined_score,3) * 0.4
+              + COALESCE(user_score_avg,3) * 0.4
+              + (elo - 1200) / 200.0) AS rank_score
+      FROM spoon_all_designs WHERE local_path IS NOT NULL
+      ORDER BY rank_score DESC NULLS LAST LIMIT ${limit}) t;`);
+    res.json(JSON.parse(raw || '[]'));
+  } 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;`);
+    const designs = psqlQuery(`SELECT COUNT(*) FROM spoon_all_designs WHERE (poll_wins+poll_losses)>0;`);
+    res.json({ total_pairs: parseInt(pairs,10) || 0, designs_polled: parseInt(designs,10) || 0 });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+app.get('/poll', (_req, res) => {
+  res.type('html').send(`${htmlHead({
+    title: 'Pick your favorite — wallco.ai',
+    description: 'Vote between two AI-original wallpaper designs. Your taste teaches the system.',
+    canonical: 'https://wallco.ai/poll'
+  })}
+<body>
+${htmlHeader('')}
+<main style="padding:24px 28px;max-width:1300px;margin:0 auto">
+  <header style="text-align:center;margin-bottom:24px">
+    <h1 style="font-family:'Cormorant Garamond',serif;font-weight:300;font-size:32px;margin:0 0 6px">Pick your favorite</h1>
+    <p style="color:#666;margin:0 0 12px">Click the one you'd hang on your wall. Your taste teaches the system &mdash; every vote nudges what we generate next.</p>
+    <div id="poll-stats" style="font-size:12px;color:#888"></div>
+  </header>
+  <div id="poll-arena" style="display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-bottom:32px">
+    <div class="poll-card" id="card-a"><div class="poll-loading">Loading&hellip;</div></div>
+    <div class="poll-card" id="card-b"></div>
+  </div>
+  <div style="text-align:center"><button id="btn-skip" class="btn-outline">Skip &mdash; show me another pair</button></div>
+  <hr style="margin:32px 0;border:0;border-top:1px solid rgba(0,0,0,.1)">
+  <h2 style="font-family:'Cormorant Garamond',serif;font-weight:400;font-size:22px;margin:0 0 14px;text-align:center">Top 20 &mdash; combined AI + user score + Elo</h2>
+  <div id="leaderboard" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px"></div>
+</main>
+${FOOTER}
+<style>
+  .poll-card { border:1px solid rgba(0,0,0,.08); border-radius:8px; overflow:hidden; cursor:pointer; transition:transform .15s,box-shadow .15s; background:#fff; }
+  .poll-card:hover { transform:scale(1.015); box-shadow:0 12px 36px rgba(0,0,0,.18); border-color:#d2b15c }
+  .poll-card img { display:block; width:100%; aspect-ratio:1; object-fit:cover }
+  .poll-card .meta { padding:12px 16px; background:#1a1816; color:#e8e2d6 }
+  .poll-card .meta .title { font-family:'Cormorant Garamond',serif; font-weight:300; font-size:19px }
+  .poll-card .meta .sub { font-size:11px; color:#888; margin-top:4px; text-transform:uppercase; letter-spacing:.05em }
+  .poll-loading { padding:80px 40px; text-align:center; color:#888 }
+  .lb-card { border:1px solid rgba(0,0,0,.08); border-radius:6px; overflow:hidden; background:#fff; font-size:11px; transition:transform .12s; text-decoration:none; color:inherit; display:block }
+  .lb-card:hover { transform:scale(1.04); box-shadow:0 8px 20px rgba(0,0,0,.12) }
+  .lb-card img { width:100%; aspect-ratio:1; object-fit:cover; display:block }
+  .lb-card .meta { padding:6px 8px }
+  .lb-card .rank { font-weight:600; color:#d2b15c }
+  .lb-card .score { color:#666; font-family:monospace; font-size:10px; margin-top:2px }
+</style>
+<script>
+async function loadPair() {
+  ['a','b'].forEach(function(k){ document.getElementById('card-'+k).innerHTML = '<div class="poll-loading">Loading&hellip;</div>'; });
+  var j = await (await fetch('/api/poll/pair')).json();
+  if (!j.a || !j.b) { document.getElementById('card-a').innerHTML = '<div class="poll-loading">Not enough designs yet.</div>'; return; }
+  ['a','b'].forEach(function(k){
+    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(){ vote(d.id, j[k==='a'?'b':'a'].id); };
+  });
+  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) {
+  document.getElementById('card-a').style.opacity = 0.4;
+  document.getElementById('card-b').style.opacity = 0.4;
+  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);
+}
+async function loadLB() {
+  var lb = await (await fetch('/api/leaderboard?limit=20')).json();
+  document.getElementById('leaderboard').innerHTML = lb.map(function(d,i){
+    var fn = (d.local_path||'').split('/').pop();
+    var img = '/designs/img/' + fn;
+    var ai = d.combined_score ? '★'+d.combined_score+'AI' : '';
+    var u  = d.user_score_avg ? ' · ★'+d.user_score_avg+' (×'+d.user_vote_count+')' : '';
+    var elo = ' · '+Math.round(d.elo)+'elo';
+    return '<a href="/design/'+d.id+'" class="lb-card"><img src="'+img+'" loading="lazy"><div class="meta"><div><span class="rank">#'+(i+1)+'</span> · ID '+d.id+'</div><div class="score">'+ai+u+elo+'</div></div></a>';
+  }).join('') || '<p style="grid-column:1/-1;text-align:center;color:#999">No designs yet.</p>';
+}
+document.getElementById('btn-skip').addEventListener('click', loadPair);
+loadPair(); loadLB();
+</script>
+${HAMBURGER_JS}
+</body>
+</html>`);
+});
+
 // ── HEALTH
 app.get('/health', (_req, res) => {
   res.json({ ok: true, site: SITE, count: DESIGNS.length, port: PORT });

← 8e98a62 feat: admin design ratings + RLHF-lite retry on /design/:id  ·  back to Wallco Ai  ·  Add Ollama-free integration tests (admin-gate, csv-export, a 925dc4e →