← 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
A public/logo-agent.htmlA routes/logo-agent.jsM server.js
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 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 →