[object Object]

← back to Costa Rica

costa-rica: logo-agent — hot-or-not tournament brand/logo builder (admin-gated /logo-agent)

2880a1fee66bab54bfbcac5492549e6ca0310859 · 2026-08-07 13:33:34 -0700 · Steve

6-component tournament (glyph/palette/type/tagline/layout/density) with CR-themed
token spaces (volcano/wave/monstera/toucan/sun/coffee motifs), crossover genetics,
deterministic SVG glyph gen, gamification (XP/streak/badges/confetti). Zero deps.
Finalize writes public/img/cr-logo.svg + data/logo-agent-final.json (brand spec + CSS vars).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 2880a1fee66bab54bfbcac5492549e6ca0310859
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Aug 7 13:33:34 2026 -0700

    costa-rica: logo-agent — hot-or-not tournament brand/logo builder (admin-gated /logo-agent)
    
    6-component tournament (glyph/palette/type/tagline/layout/density) with CR-themed
    token spaces (volcano/wave/monstera/toucan/sun/coffee motifs), crossover genetics,
    deterministic SVG glyph gen, gamification (XP/streak/badges/confetti). Zero deps.
    Finalize writes public/img/cr-logo.svg + data/logo-agent-final.json (brand spec + CSS vars).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/logo-agent.html | 159 ++++++++++++++++++++++++++++++++
 routes/logo-agent.js   | 246 +++++++++++++++++++++++++++++++++++++++++++++++++
 server.js              |   3 +
 3 files changed, 408 insertions(+)

diff --git a/public/logo-agent.html b/public/logo-agent.html
new file mode 100644
index 0000000..f0b1e1b
--- /dev/null
+++ b/public/logo-agent.html
@@ -0,0 +1,159 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8"/>
+<meta name="viewport" content="width=device-width, initial-scale=1"/>
+<title>Logo Agent — Costa Rica Directory</title>
+<style>
+  :root { --bg:#0f2e1d; --fg:#eaf5ee; --accent:#2fb673; --card:#16301f; --line:rgba(255,255,255,.14); }
+  * { box-sizing: border-box; }
+  body { margin:0; font:15px/1.5 Inter,system-ui,sans-serif; background:var(--bg); color:var(--fg); }
+  header { display:flex; align-items:center; gap:16px; padding:14px 22px; border-bottom:1px solid var(--line); position:sticky; top:0; background:var(--bg); z-index:5; }
+  header h1 { font-size:16px; margin:0; font-weight:600; letter-spacing:.02em; }
+  .spacer { flex:1; }
+  .chip { font-size:12px; padding:4px 10px; border:1px solid var(--line); border-radius:999px; }
+  .streak { color:var(--accent); font-weight:700; }
+  main { max-width:1080px; margin:0 auto; padding:22px; }
+  .stepbar { display:flex; gap:6px; margin-bottom:18px; flex-wrap:wrap; }
+  .step { font-size:11px; padding:5px 10px; border-radius:6px; border:1px solid var(--line); opacity:.55; text-transform:capitalize; }
+  .step.done { opacity:1; border-color:var(--accent); color:var(--accent); }
+  .step.active { opacity:1; background:var(--accent); color:#05160d; border-color:var(--accent); font-weight:700; }
+  h2.prompt { font-size:22px; margin:6px 0 4px; }
+  .sub { opacity:.7; margin:0 0 20px; font-size:13px; }
+  .grid { display:grid; grid-template-columns:repeat(3,1fr); gap:16px; }
+  .variant { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:18px; cursor:pointer; transition:.15s; position:relative; user-select:none; }
+  .variant:hover { transform:translateY(-3px); border-color:var(--accent); }
+  .variant.ranked { border-color:var(--accent); }
+  .rankbadge { position:absolute; top:10px; right:10px; width:26px; height:26px; border-radius:50%; background:var(--accent); color:#05160d; font-weight:800; display:none; align-items:center; justify-content:center; font-size:13px; }
+  .variant.ranked .rankbadge { display:flex; }
+  .preview { border-radius:10px; padding:22px; min-height:150px; display:flex; align-items:center; justify-content:center; }
+  .lockrow { display:flex; gap:10px; margin-top:18px; flex-wrap:wrap; }
+  button { font:inherit; border:1px solid var(--line); background:transparent; color:var(--fg); padding:10px 16px; border-radius:9px; cursor:pointer; }
+  button.primary { background:var(--accent); color:#05160d; border-color:var(--accent); font-weight:700; }
+  button:disabled { opacity:.4; cursor:not-allowed; }
+  .hint { font-size:12px; opacity:.6; margin-top:10px; }
+  .badges { display:flex; gap:6px; }
+  .badge { font-size:16px; }
+  .final { text-align:center; padding:40px; }
+  .swatch { display:inline-block; width:22px; height:22px; border-radius:5px; margin:0 2px; border:1px solid var(--line); vertical-align:middle; }
+  #confetti { position:fixed; inset:0; pointer-events:none; z-index:50; }
+</style>
+</head>
+<body>
+<header>
+  <h1>🎨 Logo Agent — Costa Rica Directory</h1>
+  <span class="chip">Round <b id="round">1</b></span>
+  <span class="chip">Streak <span class="streak" id="streak">0</span>🔥</span>
+  <span class="chip">XP <b id="xp">0</b></span>
+  <span class="badges" id="badges"></span>
+  <div class="spacer"></div>
+  <button id="restart">New tournament</button>
+</header>
+<main>
+  <div class="stepbar" id="stepbar"></div>
+  <h2 class="prompt" id="prompt">Loading…</h2>
+  <p class="sub" id="sub">Rank the three variants 1 → 2 → 3 by clicking them in order of preference.</p>
+  <div class="grid" id="grid"></div>
+  <div class="lockrow">
+    <button class="primary" id="submit" disabled>Submit ranking (+10 XP)</button>
+    <button id="lock" disabled>🔒 Lock in the winner</button>
+    <button id="clear">Clear ranking</button>
+  </div>
+  <p class="hint" id="conv"></p>
+</main>
+<canvas id="confetti"></canvas>
+<script>
+const STEP_LABELS = { glyph:'Glyph', palette:'Palette', typography:'Typography', tagline:'Tagline', layout:'Layout', openspace:'Open space' };
+const PROMPTS = {
+  glyph:'Pick the mark', palette:'Pick the palette', typography:'Pick the type',
+  tagline:'Pick the tagline', layout:'Pick the lockup', openspace:'Pick the density'
+};
+let sid=null, state=null, ranking=[];
+const $ = (id)=>document.getElementById(id);
+const api = (u,b)=>fetch('/api/logo-agent'+u,{method:'POST',headers:{'Content-Type':'application/json'},body:b?JSON.stringify(b):undefined}).then(r=>r.json());
+
+function lockup(v){
+  const p=v.palette, t=v.type, dens=v.density??50;
+  const gap = Math.round(6 + dens/6);
+  const svgBox = `<div style="width:64px;height:64px;color:${p.accent}">${v.svg}</div>`;
+  const word = `<div style="font-family:${t.display};font-size:22px;line-height:1.05;color:${p.fg}">Costa&nbsp;Rica<br><span style="font-family:${t.ui};font-size:11px;letter-spacing:.18em;text-transform:uppercase;opacity:.8">Directory</span></div>`;
+  const tag = `<div style="font-family:${t.ui};font-size:11px;opacity:.7;margin-top:6px;color:${p.fg}">${v.tagline}</div>`;
+  let body;
+  if(v.layout==='icon-only') body = svgBox;
+  else if(v.layout==='wordmark-only') body = word;
+  else if(v.layout==='icon-top') body = `<div style="display:flex;flex-direction:column;align-items:center;gap:${gap}px">${svgBox}${word}</div>`;
+  else body = `<div style="display:flex;align-items:center;gap:${gap}px">${svgBox}${word}</div>`;
+  return `<div class="preview" style="background:${p.bg}"><div style="text-align:center">${body}${tag}</div></div>`;
+}
+
+function render(){
+  $('round').textContent=state.round; $('streak').textContent=state.winStreak; $('xp').textContent=state.xp;
+  $('badges').innerHTML=(state.badges||[]).map(b=>`<span class="badge" title="${b}">${({'first-critic':'🥉','decisive':'🥈','tastemaker':'🥇','convergence':'🔥'})[b]||'⭐'}</span>`).join('');
+  $('stepbar').innerHTML=Object.keys(STEP_LABELS).map((k,i)=>{
+    const cls = i<state.componentIdx?'done':(i===state.componentIdx?'active':'');
+    return `<span class="step ${cls}">${STEP_LABELS[k]}</span>`;}).join('');
+  $('prompt').textContent=PROMPTS[state.component]||'Pick one';
+  $('conv').textContent = state.converged ? '💎 Converged — the same winner held 3 rounds. Lock it in?' : '';
+  ranking=[];
+  $('grid').innerHTML=state.variants.map(v=>`<div class="variant" data-vid="${v.vid}"><div class="rankbadge"></div>${lockup(v)}</div>`).join('');
+  [...document.querySelectorAll('.variant')].forEach(el=>el.onclick=()=>toggle(el));
+  syncButtons();
+}
+function toggle(el){
+  const vid=el.dataset.vid; const i=ranking.indexOf(vid);
+  if(i>=0) ranking.splice(i,1); else if(ranking.length<3) ranking.push(vid);
+  [...document.querySelectorAll('.variant')].forEach(v=>{
+    const r=ranking.indexOf(v.dataset.vid);
+    v.classList.toggle('ranked', r>=0);
+    v.querySelector('.rankbadge').textContent = r>=0 ? r+1 : '';
+  });
+  syncButtons();
+}
+function syncButtons(){
+  $('submit').disabled = ranking.length<2;
+  $('lock').disabled = ranking.length<1;
+}
+$('clear').onclick=()=>render();
+$('submit').onclick=async()=>{ if(ranking.length<2)return; state=await api('/rank/'+sid,{ranks:ranking}); confetti(12); render(); };
+$('lock').onclick=async()=>{
+  const winner = ranking[0] || (state.variants[0]&&state.variants[0].vid); if(!winner)return;
+  const r=await api('/lock/'+sid,{winner_id:winner});
+  confetti(60);
+  if(r.done){ await finalize(); return; }
+  state=r; render();
+};
+$('restart').onclick=start;
+
+async function finalize(){
+  const f=await api('/finalize/'+sid);
+  const b=f.brand, p=b.palette;
+  document.querySelector('main').innerHTML = `<div class="final">
+    <h2 class="prompt">🥇 Brand locked & written to disk</h2>
+    <div style="max-width:420px;margin:20px auto">${lockup(b)}</div>
+    <p class="sub">Palette
+      <span class="swatch" style="background:${p.bg}"></span>
+      <span class="swatch" style="background:${p.accent}"></span>
+      <span class="swatch" style="background:${p.card}"></span>
+      <span class="swatch" style="background:${p.fg}"></span>
+      · <b>${b.tagline}</b> · ${b.layout}
+    </p>
+    <p class="hint">Wrote <code>public/img/cr-logo.svg</code> + <code>data/logo-agent-final.json</code> (brand spec + CSS vars).</p>
+    <button class="primary" onclick="location.reload()">Run another tournament</button>
+  </div>`;
+  confetti(120);
+}
+
+// tiny confetti
+const cv=$('confetti'), cx=cv.getContext('2d'); let parts=[];
+function resize(){cv.width=innerWidth;cv.height=innerHeight;} resize(); onresize=resize;
+function confetti(n){ const C=['#2fb673','#12a5c7','#ff6b4a','#e0b34a','#eaf5ee'];
+  for(let i=0;i<n;i++) parts.push({x:innerWidth/2,y:innerHeight/3,vx:(Math.random()-.5)*8,vy:Math.random()*-9-2,g:.3,c:C[i%C.length],r:3+Math.random()*4,life:60+Math.random()*40}); }
+(function loop(){ cx.clearRect(0,0,cv.width,cv.height); parts=parts.filter(p=>p.life>0);
+  parts.forEach(p=>{p.x+=p.vx;p.y+=p.vy;p.vy+=p.g;p.life--;cx.fillStyle=p.c;cx.fillRect(p.x,p.y,p.r,p.r);});
+  requestAnimationFrame(loop); })();
+
+async function start(){ const r=await api('/session/start'); sid=r.session_id; state=r; render(); }
+start();
+</script>
+</body>
+</html>
diff --git a/routes/logo-agent.js b/routes/logo-agent.js
new file mode 100644
index 0000000..1c8139d
--- /dev/null
+++ b/routes/logo-agent.js
@@ -0,0 +1,246 @@
+'use strict';
+// Logo Agent — hot-or-not tournament logo/brand builder for the Costa Rica
+// marketplace. Zero external deps: express Router + Node crypto/fs. Sessions are
+// per-file JSON under data/logo-agent-sessions/; the locked brand + SVG land in
+// data/logo-agent-final.json + public/img/cr-logo.svg on finalize.
+//
+// Mechanic per component: show 3 variants -> user ranks 1/2/3 -> top-1 x top-2
+// breed a NEW 3rd variant (categorical 50/50, numeric midpoint+mutation) ->
+// re-rank -> converge when the same variant wins 3 rounds, or user locks.
+
+const express = require('express');
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+
+const router = express.Router();
+const SESS_DIR = path.join(__dirname, '..', 'data', 'logo-agent-sessions');
+const FINAL = path.join(__dirname, '..', 'data', 'logo-agent-final.json');
+const SVG_OUT = path.join(__dirname, '..', 'public', 'img', 'cr-logo.svg');
+fs.mkdirSync(SESS_DIR, { recursive: true });
+
+// ---- token spaces (Costa Rica Directory brand) -----------------------------
+const MOTIFS = ['volcano', 'wave', 'monstera', 'toucan', 'sun', 'coffee'];
+const CONTAINERS = ['none', 'circle', 'shield'];
+const PALETTES = {
+  rainforest: { bg: '#0f2e1d', fg: '#eaf5ee', accent: '#2fb673', card: '#16301f' },
+  pacific:    { bg: '#062a3a', fg: '#eaf6fb', accent: '#12a5c7', card: '#0c3546' },
+  sunset:     { bg: '#2a1410', fg: '#fdeee6', accent: '#ff6b4a', card: '#3a1c16' },
+  volcanic:   { bg: '#17161a', fg: '#f2f0f3', accent: '#e0b34a', card: '#221f27' },
+  sand:       { bg: '#f5efe3', fg: '#231d14', accent: '#c8892f', card: '#efe6d4' },
+  pura:       { bg: '#0b1f16', fg: '#f0faf4', accent: '#4bd07f', card: '#123024' },
+};
+const TYPE = [
+  { display: 'Fraunces, Georgia, serif',        ui: 'Inter, system-ui, sans-serif' },
+  { display: '"Playfair Display", serif',        ui: '"Work Sans", sans-serif' },
+  { display: '"DM Serif Display", serif',        ui: '"DM Sans", sans-serif' },
+  { display: 'Poppins, sans-serif',              ui: 'Inter, sans-serif' },
+  { display: '"Cormorant Garamond", serif',      ui: '"Nunito Sans", sans-serif' },
+];
+const TAGLINES = [
+  'Discover Costa Rica',
+  'Pura Vida, planned',
+  'Stay. Explore. Belong.',
+  'Your Costa Rica, curated',
+  'Tourism · Rentals · Local',
+  'The whole coast, one place',
+  'Book the real Costa Rica',
+  'Where the wild is welcome',
+];
+const LAYOUTS = ['icon-left', 'icon-top', 'icon-only', 'wordmark-only'];
+
+const COMPONENTS = ['glyph', 'palette', 'typography', 'tagline', 'layout', 'openspace'];
+const rnd = (n) => crypto.randomInt(n);
+const pick = (arr) => arr[rnd(arr.length)];
+const jitter = (v, amt, lo, hi) => Math.max(lo, Math.min(hi, v + (rnd(2 * amt + 1) - amt)));
+const id = () => crypto.randomBytes(8).toString('hex');
+
+// A "genome" is the full brand token set; each round mutates ONE component's genes.
+function randomGenesFor(component) {
+  switch (component) {
+    case 'glyph':      return { motif: pick(MOTIFS), container: pick(CONTAINERS), weight: 4 + rnd(9), dominance: 40 + rnd(50) };
+    case 'palette':    return { palette: pick(Object.keys(PALETTES)) };
+    case 'typography': return { type: rnd(TYPE.length) };
+    case 'tagline':    return { tagline: rnd(TAGLINES.length) };
+    case 'layout':     return { layout: pick(LAYOUTS) };
+    case 'openspace':  return { density: 20 + rnd(80) };
+    default:           return {};
+  }
+}
+function crossover(component, a, b) {
+  switch (component) {
+    case 'glyph': return {
+      motif: rnd(2) ? a.motif : b.motif,
+      container: rnd(2) ? a.container : b.container,
+      weight: jitter(Math.round((a.weight + b.weight) / 2), 1, 3, 14),
+      dominance: jitter(Math.round((a.dominance + b.dominance) / 2), 6, 30, 95),
+    };
+    case 'palette':    return { palette: rnd(2) ? a.palette : b.palette };
+    case 'typography': return { type: rnd(2) ? a.type : b.type };
+    case 'tagline':    return { tagline: rnd(2) ? a.tagline : b.tagline };
+    case 'layout':     return { layout: rnd(2) ? a.layout : b.layout };
+    case 'openspace':  return { density: jitter(Math.round((a.density + b.density) / 2), 8, 10, 100) };
+    default:           return { ...a };
+  }
+}
+
+// ---- deterministic SVG glyph -----------------------------------------------
+function motifPath(motif, w) {
+  const sw = w; // stroke width scales with the "weight" gene
+  switch (motif) {
+    case 'volcano': return `<path d="M14 46 L32 16 L50 46 Z" fill="none" stroke="currentColor" stroke-width="${sw}" stroke-linejoin="round"/><path d="M24 30 Q32 22 40 30" fill="none" stroke="currentColor" stroke-width="${sw}" stroke-linecap="round"/>`;
+    case 'wave':    return `<path d="M10 34 Q20 22 30 34 T50 34" fill="none" stroke="currentColor" stroke-width="${sw}" stroke-linecap="round"/><path d="M10 42 Q20 30 30 42 T50 42" fill="none" stroke="currentColor" stroke-width="${sw}" stroke-linecap="round" opacity="0.6"/>`;
+    case 'monstera':return `<path d="M32 12 C14 18 12 44 30 50 C48 44 50 18 32 12 Z" fill="none" stroke="currentColor" stroke-width="${sw}" stroke-linejoin="round"/><path d="M32 16 L32 50 M22 26 L30 30 M42 26 L34 30 M24 38 L30 40 M40 38 L34 40" stroke="currentColor" stroke-width="${Math.max(2, sw - 2)}" stroke-linecap="round"/>`;
+    case 'toucan':  return `<circle cx="26" cy="26" r="10" fill="none" stroke="currentColor" stroke-width="${sw}"/><path d="M34 22 Q52 20 50 30 Q40 30 34 28 Z" fill="currentColor"/>`;
+    case 'sun':     return `<circle cx="32" cy="32" r="9" fill="none" stroke="currentColor" stroke-width="${sw}"/>` + Array.from({ length: 8 }, (_, i) => { const a = (i * Math.PI) / 4; const x1 = 32 + Math.cos(a) * 15, y1 = 32 + Math.sin(a) * 15, x2 = 32 + Math.cos(a) * 21, y2 = 32 + Math.sin(a) * 21; return `<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="currentColor" stroke-width="${sw}" stroke-linecap="round"/>`; }).join('');
+    case 'coffee':  return `<ellipse cx="32" cy="32" rx="12" ry="16" fill="none" stroke="currentColor" stroke-width="${sw}"/><path d="M32 16 Q26 32 32 48 Q38 32 32 16" fill="none" stroke="currentColor" stroke-width="${Math.max(2, sw - 1)}"/>`;
+    default:        return '';
+  }
+}
+function containerWrap(container, inner, w) {
+  if (container === 'circle') return `<circle cx="32" cy="32" r="30" fill="none" stroke="currentColor" stroke-width="${Math.max(2, w - 1)}"/>${inner}`;
+  if (container === 'shield') return `<path d="M32 4 L58 12 V34 Q58 52 32 60 Q6 52 6 34 V12 Z" fill="none" stroke="currentColor" stroke-width="${Math.max(2, w - 1)}" stroke-linejoin="round"/>${inner}`;
+  return inner;
+}
+function buildSvg(genome, { standalone = false } = {}) {
+  const pal = PALETTES[genome.palette] || PALETTES.rainforest;
+  const g = genome.glyph || {};
+  const glyph = containerWrap(g.container || 'none', motifPath(g.motif || 'volcano', g.weight || 6), g.weight || 6);
+  const color = standalone ? pal.accent : 'currentColor';
+  return `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Costa Rica Directory mark" style="color:${color}">${glyph}</svg>`;
+}
+
+// ---- lockbrand assembly (what the UI renders per variant) -------------------
+function assemble(genome) {
+  const pal = PALETTES[genome.palette] || PALETTES.rainforest;
+  const type = TYPE[genome.type ?? 0];
+  const tagline = TAGLINES[genome.tagline ?? 0];
+  return {
+    svg: buildSvg(genome),
+    palette: pal,
+    type,
+    tagline,
+    layout: genome.layout || 'icon-left',
+    density: genome.density ?? 50,
+    genome,
+  };
+}
+
+function loadSession(sid) {
+  const f = path.join(SESS_DIR, `${sid}.json`);
+  if (!fs.existsSync(f)) return null;
+  return JSON.parse(fs.readFileSync(f, 'utf8'));
+}
+function saveSession(s) { fs.writeFileSync(path.join(SESS_DIR, `${s.id}.json`), JSON.stringify(s, null, 2)); }
+
+function newRound(component, base) {
+  // 3 fresh variants; if we have a locked base genome, keep the other components fixed.
+  return [0, 1, 2].map(() => ({ vid: id(), ...(base || {}), ...randomGenesFor(component) }));
+}
+
+// POST /session/start
+router.post('/session/start', (_req, res) => {
+  const s = {
+    id: id(), createdAt: new Date().toISOString(),
+    component: COMPONENTS[0], componentIdx: 0,
+    locked: {}, base: {},
+    round: 1, winStreak: 0, lastWinnerSig: null,
+    xp: 0, badges: [],
+    variants: newRound(COMPONENTS[0], {}),
+  };
+  saveSession(s);
+  res.json({ session_id: s.id, ...roundView(s) });
+});
+
+function sigOf(component, genes) {
+  const g = genes || {};
+  return JSON.stringify(component === 'glyph' ? { m: g.motif, c: g.container } :
+    component === 'palette' ? g.palette : component === 'typography' ? g.type :
+    component === 'tagline' ? g.tagline : component === 'layout' ? g.layout :
+    Math.round((g.density ?? 50) / 15));
+}
+function roundView(s) {
+  return {
+    component: s.component, componentIdx: s.componentIdx, totalComponents: COMPONENTS.length,
+    round: s.round, winStreak: s.winStreak, xp: s.xp, badges: s.badges,
+    converged: s.winStreak >= 3,
+    variants: s.variants.map((v) => ({ vid: v.vid, ...assemble({ ...s.base, ...v }) })),
+    locked: s.locked,
+  };
+}
+
+// POST /rank/:sid  { ranks: [vid1, vid2, vid3] }  (vid1 = best)
+router.post('/rank/:sid', (req, res) => {
+  const s = loadSession(req.params.sid);
+  if (!s) return res.status(404).json({ error: 'session not found' });
+  const ranks = (req.body && req.body.ranks) || [];
+  if (ranks.length < 2) return res.status(400).json({ error: 'need at least 2 ranked vids' });
+  const byId = Object.fromEntries(s.variants.map((v) => [v.vid, v]));
+  const first = byId[ranks[0]], second = byId[ranks[1]];
+  if (!first || !second) return res.status(400).json({ error: 'unknown vid in ranks' });
+
+  s.xp += 10;
+  const sig = sigOf(s.component, first);
+  if (sig === s.lastWinnerSig) { s.winStreak += 1; s.xp += 5; }
+  else { s.winStreak = 1; s.lastWinnerSig = sig; }
+  if (!s.badges.includes('first-critic')) s.badges.push('first-critic');
+  if (s.winStreak >= 3 && !s.badges.includes('convergence')) s.badges.push('convergence');
+
+  // breed the top-2 into a new 3rd; keep the reigning champ + runner-up.
+  const child = { vid: id(), ...crossover(s.component, first, second) };
+  s.variants = [{ ...first }, { ...second }, child];
+  s.round += 1;
+  saveSession(s);
+  res.json({ session_id: s.id, ...roundView(s), championVid: first.vid });
+});
+
+// POST /lock/:sid  { winner_id }
+router.post('/lock/:sid', (req, res) => {
+  const s = loadSession(req.params.sid);
+  if (!s) return res.status(404).json({ error: 'session not found' });
+  const winner = (s.variants || []).find((v) => v.vid === (req.body && req.body.winner_id));
+  if (!winner) return res.status(400).json({ error: 'winner_id not in current variants' });
+
+  const { vid, ...genes } = winner;
+  s.locked[s.component] = genes;
+  s.base = { ...s.base, ...genes };
+  s.xp += 25;
+  if (!s.badges.includes('decisive')) s.badges.push('decisive');
+
+  if (s.componentIdx + 1 < COMPONENTS.length) {
+    s.componentIdx += 1;
+    s.component = COMPONENTS[s.componentIdx];
+    s.round = 1; s.winStreak = 0; s.lastWinnerSig = null;
+    s.variants = newRound(s.component, s.base);
+    saveSession(s);
+    return res.json({ session_id: s.id, done: false, ...roundView(s) });
+  }
+  // all six locked
+  if (!s.badges.includes('tastemaker')) s.badges.push('tastemaker');
+  s.finalized = true;
+  saveSession(s);
+  res.json({ session_id: s.id, done: true, xp: s.xp, badges: s.badges, brand: assemble(s.base) });
+});
+
+// GET /state/:sid
+router.get('/state/:sid', (req, res) => {
+  const s = loadSession(req.params.sid);
+  if (!s) return res.status(404).json({ error: 'session not found' });
+  res.json({ session_id: s.id, ...roundView(s), finalized: !!s.finalized });
+});
+
+// POST /finalize/:sid  -> writes SVG + brand JSON to disk
+router.post('/finalize/:sid', (req, res) => {
+  const s = loadSession(req.params.sid);
+  if (!s) return res.status(404).json({ error: 'session not found' });
+  const brand = assemble(s.base);
+  const pal = brand.palette;
+  fs.mkdirSync(path.dirname(SVG_OUT), { recursive: true });
+  fs.writeFileSync(SVG_OUT, buildSvg(s.base, { standalone: true }));
+  const cssVars = `:root{\n  --cr-bg:${pal.bg};\n  --cr-fg:${pal.fg};\n  --cr-accent:${pal.accent};\n  --cr-card:${pal.card};\n  --cr-font-display:${brand.type.display};\n  --cr-font-ui:${brand.type.ui};\n}`;
+  const out = { finalizedAt: new Date().toISOString(), brand, cssVars, svgPath: '/img/cr-logo.svg' };
+  fs.writeFileSync(FINAL, JSON.stringify(out, null, 2));
+  res.json({ ok: true, ...out });
+});
+
+module.exports = router;
diff --git a/server.js b/server.js
index c44f4c3..745a641 100644
--- a/server.js
+++ b/server.js
@@ -60,6 +60,9 @@ app.use('/api/admin', require('./routes/admin'));
 app.get('/admin', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'admin.html')));
 app.use('/api/build', require('./routes/build'));
 app.get('/build', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'build.html')));
+// Logo Agent — hot-or-not tournament brand/logo builder (admin-gated curation tool)
+app.use('/api/logo-agent', require('./routes/logo-agent'));
+app.get('/logo-agent', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'logo-agent.html')));
 // Map of all geocoded places (desktop viewer)
 app.get('/api/map', async (req, res) => {
   try {

← 1828f55 yoloforever: cycle 4 ledger — security test coverage (Apple  ·  back to Costa Rica  ·  costa-rica: enforce one-default-payout-method-per-host (part 936e3ed →