← 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 — 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…</div></div>
+ <div class="poll-card" id="card-b"></div>
+ </div>
+ <div style="text-align:center"><button id="btn-skip" class="btn-outline">Skip — 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 — 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…</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 →