[object Object]

← back to Model Arena

model-arena: arena server + neon UI — challenges, per-host ollama queue, opt-in metered adapters, votes + win-rate ledger, sort+density, basic auth

e2ce01534bb852e156bba567e3dc04371d1ebe00 · 2026-07-22 22:02:30 -0700 · Steve

Files touched

Diff

commit e2ce01534bb852e156bba567e3dc04371d1ebe00
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Jul 22 22:02:30 2026 -0700

    model-arena: arena server + neon UI — challenges, per-host ollama queue, opt-in metered adapters, votes + win-rate ledger, sort+density, basic auth
---
 BRIEF.md           |  28 ++++-
 README.md          |  19 ---
 data/products.json |   6 -
 public/index.html  | 362 +++++++++++++++++++++++++++++++++++++++++++++--------
 server.js          | 325 ++++++++++++++++++++++++++++++++++++++++++-----
 5 files changed, 628 insertions(+), 112 deletions(-)

diff --git a/BRIEF.md b/BRIEF.md
index c167b3a..1b16cfe 100644
--- a/BRIEF.md
+++ b/BRIEF.md
@@ -1,5 +1,25 @@
 # model-arena — brief
-- Client:
-- Outcome / "high value":
-- Constraints (brand, domain, deadline):
-- Reference / competitor URLs:
+
+- **Seed:** Julian Goldie SEO (@JulianGoldieSEO) X post, Jul 20 2026
+  (https://x.com/JulianGoldieSEO/status/2079008392811544994) — head-to-head
+  real-world testing of Kimi K3 vs Claude Fable 5 vs GPT 5.6 on build
+  challenges (Skyrim-style open world, Dragon Realm, Neon City driving).
+  Takeaway: *"Don't marry one AI model. Build a workflow where every model
+  does the job it performs best at — trust real-world testing over
+  leaderboard screenshots."*
+- **Client:** internal (Steve) first; productizable later.
+- **Outcome / "high value":** a Model Arena — fan ONE real-world build
+  challenge out to multiple AI models, render each model's single-file HTML
+  artifact side-by-side in sandboxed iframes, crown a winner per challenge,
+  and accumulate a **real-world win-rate ledger per model** over time.
+  First-party benchmark data nobody else has (Codex panel: "defensible moat").
+- **Direction locked by DTD panel 2026-07-22: unanimous 5/5** for the arena
+  (vs an AI-SEO tool or a static SEO landing page). Panel cost ~$0.006.
+- **Constraints:**
+  - $0 by default — local Ollama models (Mac2 localhost + Mac1 192.168.1.133).
+  - Paid model calls (Claude / Kimi / Codex / Grok) are OPT-IN, per-run,
+    cost-estimated up front and cost-logged after.
+  - Demo behind basic auth `admin / DW2024!` (env-overridable `BASIC_AUTH`).
+  - House rules: grid gets sort + density slider; admin cards show created
+    date + time; gitified; zero-dep Node server.
+  - Launch is GATED — nothing deploys or goes public without Steve's go.
diff --git a/README.md b/README.md
deleted file mode 100644
index 9c6f5b4..0000000
--- a/README.md
+++ /dev/null
@@ -1,19 +0,0 @@
-# Project
-
-Scaffolded by the Claude web-dev accelerator. Storefront starter with a
-server-side-sorted product grid + density slider (both persist in localStorage).
-
-## Run
-```bash
-PORT=3900 node server.js                                # open http://localhost:3900
-BASIC_AUTH="admin:DW2024!" PORT=3900 node server.js     # gated
-```
-
-## Fill it in
-- `data/products.json` — array of `{ title, sku, price, hex, image }`.
-- `server.js` `sortProducts()` — add sort modes as needed.
-- `public/index.html` — the grid + controls.
-
-## Preview publicly
-From the accelerator dir: `scripts/preview-tunnel.sh <slug> <port>` stands up
-`<slug>.agentabrams.com` (basic-auth gated). The DNS step is Steve-gated.
diff --git a/data/products.json b/data/products.json
deleted file mode 100644
index 8eee5f2..0000000
--- a/data/products.json
+++ /dev/null
@@ -1,6 +0,0 @@
-[
-  { "title": "Sample One", "sku": "SMP-001", "price": 129.00, "hex": "#2b3a55", "image": "" },
-  { "title": "Sample Two", "sku": "SMP-002", "price": 89.00, "hex": "#d8c3a5", "image": "" },
-  { "title": "Sample Three", "sku": "SMP-003", "price": 210.00, "hex": "#8e8d8a", "image": "" },
-  { "title": "Sample Four", "sku": "SMP-004", "price": 54.00, "hex": "#e98074", "image": "" }
-]
diff --git a/public/index.html b/public/index.html
index ceb7ab1..b5ecc07 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1,55 +1,315 @@
-<!doctype html><html lang="en"><head><meta charset="utf-8">
-<meta name="viewport" content="width=device-width,initial-scale=1"><title>Storefront starter</title>
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Model Arena — real-world testing over leaderboard screenshots</title>
 <style>
-  :root{--cols:4}
-  *{box-sizing:border-box} body{margin:0;font:15px/1.4 -apple-system,Segoe UI,Roboto,sans-serif;color:#1a1a1a;background:#fafafa}
-  header{display:flex;gap:14px;align-items:center;flex-wrap:wrap;padding:14px 20px;border-bottom:1px solid #e6e6e6;position:sticky;top:0;background:#fff;z-index:5}
-  h1{font-size:17px;margin:0;font-weight:700} .spacer{flex:1}
-  label{font-size:12px;color:#666;display:flex;gap:6px;align-items:center}
-  select,input[type=range]{font:inherit} .count{font-size:12px;color:#999}
-  .grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:14px;padding:20px}
-  .card{background:#fff;border:1px solid #e6e6e6;border-radius:8px;overflow:hidden}
-  .card img{width:100%;aspect-ratio:1/1;object-fit:cover;display:block;background:#f0f0f0}
-  .meta{padding:8px 10px} .t{font-size:13px;font-weight:600;margin:0 0 2px} .s{font-size:11px;color:#888}
-  .p{font-size:13px;margin-top:4px} .empty{padding:40px;color:#999;text-align:center}
-</style></head><body>
+:root{
+  --bg:#07080f; --panel:#0d0f1c; --panel2:#12152a; --line:#1e2342;
+  --neon:#00e5ff; --neon2:#ff2ea6; --gold:#ffc72e; --ok:#2eff8f; --err:#ff5d5d;
+  --txt:#dfe4ff; --dim:#7d84ad; --cols:3;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+body{background:var(--bg);color:var(--txt);font:14px/1.5 "SF Mono",ui-monospace,Menlo,monospace;min-height:100vh}
+body::before{content:"";position:fixed;inset:0;pointer-events:none;z-index:0;
+  background:radial-gradient(60% 40% at 20% 0%,rgba(0,229,255,.07),transparent 60%),
+             radial-gradient(50% 35% at 85% 10%,rgba(255,46,166,.06),transparent 60%)}
+header{position:sticky;top:0;z-index:5;background:rgba(7,8,15,.92);backdrop-filter:blur(6px);
+  border-bottom:1px solid var(--line);padding:14px 22px;display:flex;align-items:baseline;gap:16px;flex-wrap:wrap}
+h1{font-size:19px;letter-spacing:2px;text-transform:uppercase}
+h1 .a{color:var(--neon)} h1 .b{color:var(--neon2)}
+.tag{color:var(--dim);font-size:12px;font-style:italic}
+nav{margin-left:auto;display:flex;gap:8px}
+nav button{background:none;border:1px solid var(--line);color:var(--dim);padding:6px 14px;cursor:pointer;
+  font:inherit;font-size:12px;letter-spacing:1px;text-transform:uppercase}
+nav button.on{color:var(--neon);border-color:var(--neon);box-shadow:0 0 12px rgba(0,229,255,.25)}
+main{position:relative;z-index:1;max-width:1500px;margin:0 auto;padding:20px 22px}
+.controls{display:flex;gap:14px;align-items:center;flex-wrap:wrap;margin-bottom:16px}
+.controls label{color:var(--dim);font-size:12px}
+select{background:var(--panel);color:var(--txt);border:1px solid var(--line);padding:6px 8px;font:inherit;font-size:12px}
+input[type=range]{accent-color:var(--neon);width:130px}
+.btn{background:linear-gradient(90deg,rgba(0,229,255,.15),rgba(255,46,166,.15));border:1px solid var(--neon);
+  color:var(--neon);padding:8px 18px;cursor:pointer;font:inherit;font-size:12px;letter-spacing:1px;text-transform:uppercase}
+.btn:hover{box-shadow:0 0 14px rgba(0,229,255,.4)}
+.btn.pink{border-color:var(--neon2);color:var(--neon2)}
+.btn[disabled]{opacity:.4;cursor:default;box-shadow:none}
+.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:14px}
+.card{background:var(--panel);border:1px solid var(--line);padding:14px;cursor:pointer;position:relative}
+.card:hover{border-color:var(--neon);box-shadow:0 0 16px rgba(0,229,255,.15)}
+.card h3{font-size:14px;margin-bottom:6px;color:#fff}
+.when{color:var(--dim);font-size:11px;margin-bottom:8px}
+.chips{display:flex;gap:6px;flex-wrap:wrap}
+.chip{font-size:10px;padding:2px 8px;border:1px solid var(--line);color:var(--dim);border-radius:999px}
+.chip.done{border-color:var(--ok);color:var(--ok)}
+.chip.running{border-color:var(--neon);color:var(--neon);animation:pulse 1.2s infinite}
+.chip.queued{border-color:var(--dim)}
+.chip.error{border-color:var(--err);color:var(--err)}
+.chip.winner{border-color:var(--gold);color:var(--gold);font-weight:700}
+@keyframes pulse{50%{opacity:.35}}
+.empty{color:var(--dim);padding:40px;text-align:center;border:1px dashed var(--line)}
+/* new challenge */
+.form{background:var(--panel);border:1px solid var(--line);padding:20px;max-width:860px}
+.form h2,#detail h2,#board h2{font-size:15px;letter-spacing:2px;text-transform:uppercase;color:var(--neon);margin-bottom:14px}
+.form input[type=text],.form textarea{width:100%;background:var(--panel2);border:1px solid var(--line);color:var(--txt);
+  padding:10px;font:inherit;margin-bottom:12px}
+.form textarea{height:110px;resize:vertical}
+.presets{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}
+.preset{font-size:11px;padding:4px 10px;border:1px solid var(--neon2);color:var(--neon2);border-radius:999px;cursor:pointer}
+.preset:hover{background:rgba(255,46,166,.12)}
+.models{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:8px;margin-bottom:14px}
+.mrow{display:flex;align-items:center;gap:8px;border:1px solid var(--line);padding:8px 10px;font-size:12px}
+.mrow.off{opacity:.45}
+.mrow .cost{margin-left:auto;font-size:11px}
+.mrow .cost.free{color:var(--ok)} .mrow .cost.paid{color:var(--gold)}
+.note{color:var(--dim);font-size:11px;margin-bottom:12px}
+/* detail */
+#detail .meta{color:var(--dim);font-size:12px;margin-bottom:14px;white-space:pre-wrap}
+.arena{display:grid;grid-template-columns:repeat(auto-fit,minmax(420px,1fr));gap:16px}
+.pane{background:var(--panel);border:1px solid var(--line)}
+.pane.winner{border-color:var(--gold);box-shadow:0 0 18px rgba(255,199,46,.25)}
+.pane .bar{display:flex;align-items:center;gap:10px;padding:8px 12px;border-bottom:1px solid var(--line);font-size:12px}
+.pane .bar .name{color:#fff;font-weight:700}
+.pane .bar .stat{color:var(--dim);font-size:11px}
+.pane .bar .right{margin-left:auto;display:flex;gap:6px}
+.pane .bar button{font-size:10px;padding:3px 10px}
+.pane iframe{width:100%;height:460px;border:0;background:#000;display:block}
+.pane .msg{height:460px;display:flex;align-items:center;justify-content:center;color:var(--dim);font-size:12px;padding:20px;text-align:center}
+/* leaderboard */
+table{width:100%;border-collapse:collapse;font-size:13px}
+th,td{padding:10px 12px;border-bottom:1px solid var(--line);text-align:left}
+th{color:var(--dim);font-size:11px;text-transform:uppercase;letter-spacing:1px}
+tr.first td{color:var(--gold)}
+.back{color:var(--neon);cursor:pointer;font-size:12px;margin-bottom:12px;display:inline-block}
+.view{display:none}.view.on{display:block}
+</style>
+</head>
+<body>
 <header>
-  <h1>Storefront starter</h1>
-  <label>Sort
-    <select id="sort">
-      <option value="newest">Newest</option>
-      <option value="title">Title A→Z</option>
-      <option value="sku">SKU A→Z</option>
-      <option value="price-asc">Price ↑</option>
-      <option value="price-desc">Price ↓</option>
-      <option value="light">Light → Dark</option>
-      <option value="dark">Dark → Light</option>
-    </select>
-  </label>
-  <label>Density <input id="density" type="range" min="2" max="8" value="4"></label>
-  <span class="spacer"></span><span class="count" id="count"></span>
+  <h1><span class="a">MODEL</span> <span class="b">ARENA</span></h1>
+  <span class="tag">"Don't marry one AI model — trust real-world testing over leaderboard screenshots."</span>
+  <nav>
+    <button id="nav-challenges" class="on">Challenges</button>
+    <button id="nav-new">+ New Battle</button>
+    <button id="nav-board">Leaderboard</button>
+  </nav>
 </header>
-<div class="grid" id="grid"><div class="empty">Loading…</div></div>
+<main>
+  <section id="challenges" class="view on">
+    <div class="controls">
+      <label>Sort
+        <select id="sort">
+          <option value="newest">Newest</option>
+          <option value="oldest">Oldest</option>
+          <option value="title">Title A→Z</option>
+          <option value="models">Most models</option>
+          <option value="winner">Decided first</option>
+        </select>
+      </label>
+      <label>Density <input type="range" id="density" min="1" max="6" step="1"></label>
+      <span id="count" class="tag"></span>
+    </div>
+    <div id="list" class="grid"></div>
+  </section>
+
+  <section id="new" class="view">
+    <div class="form">
+      <h2>New Battle</h2>
+      <input type="text" id="f-title" placeholder="Title (e.g. Neon City Driving)">
+      <div class="presets" id="presets"></div>
+      <textarea id="f-prompt" placeholder="The real-world build challenge, e.g. 'Build a playable neon-city driving game in a single HTML file…'"></textarea>
+      <div class="models" id="model-picks"></div>
+      <div class="note">Local models are $0 and pre-checked. Metered models are opt-in per battle — estimated cost shown, actuals logged to the cost ledger.</div>
+      <div class="note" id="est"></div>
+      <button class="btn" id="f-go">⚔ Fight</button>
+    </div>
+  </section>
+
+  <section id="detail" class="view">
+    <span class="back" id="back">← back to challenges</span>
+    <h2 id="d-title"></h2>
+    <div class="meta" id="d-meta"></div>
+    <div class="arena" id="d-arena"></div>
+  </section>
+
+  <section id="board" class="view">
+    <h2>Real-World Leaderboard</h2>
+    <div class="note" id="b-note"></div>
+    <table>
+      <thead><tr><th>#</th><th>Model</th><th>Kind</th><th>Battles judged</th><th>Wins</th><th>Win rate</th><th>Errors</th><th>Avg gen time</th><th>Spend</th></tr></thead>
+      <tbody id="b-rows"></tbody>
+    </table>
+  </section>
+</main>
 <script>
-// House rule: sort + density both persist in localStorage so the choice survives reloads.
-const $=s=>document.querySelector(s), esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
-const sortSel=$('#sort'), dens=$('#density');
-sortSel.value=localStorage.getItem('sort')||'newest'; dens.value=localStorage.getItem('cols')||'4';
-function applyCols(){document.documentElement.style.setProperty('--cols',dens.value);localStorage.setItem('cols',dens.value);}
-applyCols();
-async function load(){
-  const r=await fetch('/api/products?sort='+encodeURIComponent(sortSel.value)); const d=await r.json();
-  $('#count').textContent=d.count+' items';
-  $('#grid').innerHTML = d.products.length ? d.products.map(p=>`
-    <div class="card">
-      <img src="${esc(p.image||'')}" alt="${esc(p.title||'')}" loading="lazy">
-      <div class="meta"><p class="t">${esc(p.title||'Untitled')}</p>
-        <div class="s">${esc(p.sku||p.handle||'')}</div>
-        ${p.price?`<div class="p">$${Number(p.price).toFixed(2)}</div>`:''}
-      </div>
-    </div>`).join('') : '<div class="empty">No products yet — fill data/products.json.</div>';
-}
-sortSel.onchange=()=>{localStorage.setItem('sort',sortSel.value);load();};
-dens.oninput=applyCols;
-load();
-</script></body></html>
+const $ = s => document.querySelector(s);
+const API = location.origin;
+let models = [], current = null, pollTimer = null;
+
+const PRESETS = [
+  { t:'Neon City Driving', p:'Build a playable neon-city night driving game: a road with moving lane markers, neon buildings scrolling past, a car the player steers left/right with arrow keys, obstacles to dodge, and a score counter. Rich neon atmosphere matters.' },
+  { t:'Dragon Realm', p:'Build an animated dragon realm scene: a dragon flying over mountains with snowfall, dynamic lighting, and at least one interactive element (click to make the dragon breathe fire). Visual quality matters most.' },
+  { t:'Open World Explorer', p:'Build a tiny Skyrim-style top-down open world: procedurally scattered trees/rocks/water, a character moved with WASD, day-night lighting cycle, and a minimap.' },
+  { t:'Particle Fireworks', p:'Build an interactive fireworks simulator: click anywhere to launch a rocket that explodes into physics-based particles with trails, gravity, and glow. Include an auto-show mode.' },
+  { t:'Synthwave Visualizer', p:'Build a synthwave music visualizer using WebAudio oscillators (no external audio files): a retro sun over an animated perspective grid that pulses to generated music, with play/stop controls.' },
+];
+
+function fmtWhen(iso){ return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }
+function mLabel(id){ const m = models.find(x=>x.id===id); return m ? m.label : id; }
+
+// ---- views ----
+function show(view){
+  document.querySelectorAll('.view').forEach(v=>v.classList.remove('on'));
+  document.querySelectorAll('nav button').forEach(b=>b.classList.remove('on'));
+  $('#'+view).classList.add('on');
+  const nb = $('#nav-'+view); if (nb) nb.classList.add('on');
+  if (view!=='detail' && pollTimer){ clearInterval(pollTimer); pollTimer=null; }
+}
+$('#nav-challenges').onclick = ()=>{ show('challenges'); loadList(); };
+$('#nav-new').onclick = ()=>show('new');
+$('#nav-board').onclick = ()=>{ show('board'); loadBoard(); };
+$('#back').onclick = ()=>{ show('challenges'); loadList(); };
+
+// ---- controls persistence (house rule) ----
+$('#sort').value = localStorage.getItem('ma-sort') || 'newest';
+$('#density').value = localStorage.getItem('ma-cols') || 3;
+document.documentElement.style.setProperty('--cols', $('#density').value);
+$('#sort').onchange = ()=>{ localStorage.setItem('ma-sort', $('#sort').value); loadList(); };
+$('#density').oninput = ()=>{ localStorage.setItem('ma-cols', $('#density').value);
+  document.documentElement.style.setProperty('--cols', $('#density').value); };
+
+// ---- challenge list ----
+async function loadList(){
+  const r = await fetch(API+'/api/challenges?sort='+$('#sort').value);
+  const d = await r.json();
+  $('#count').textContent = d.count + ' battle' + (d.count===1?'':'s');
+  const el = $('#list'); el.innerHTML = '';
+  if (!d.count){ el.innerHTML = '<div class="empty">No battles yet — hit <b>+ New Battle</b> and let the models fight.</div>'; return; }
+  for (const c of d.challenges){
+    const div = document.createElement('div'); div.className='card';
+    const chips = c.runs.map(r=>{
+      const win = c.winner===r.model ? ' winner' : '';
+      return `<span class="chip ${r.status}${win}">${mLabel(r.model)}${c.winner===r.model?' 👑':''}${r.status==='error'?' ✗':''}</span>`;
+    }).join('');
+    div.innerHTML = `<h3>${esc(c.title)}</h3>
+      <div class="when" title="${c.created_at}">🕓 ${fmtWhen(c.created_at)}</div>
+      <div class="chips">${chips}</div>`;
+    div.onclick = ()=>openDetail(c.id);
+    el.appendChild(div);
+  }
+}
+function esc(s){ return String(s).replace(/[&<>"]/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
+
+// ---- new battle ----
+async function loadModels(){
+  models = await (await fetch(API+'/api/models')).json();
+  const el = $('#model-picks'); el.innerHTML='';
+  for (const m of models){
+    const row = document.createElement('label');
+    row.className = 'mrow' + (m.available? '' : ' off');
+    row.innerHTML = `<input type="checkbox" value="${m.id}" ${m.kind==='local'?'checked':''} ${m.available?'':'disabled'}>
+      ${m.label}
+      <span class="cost ${m.kind==='local'?'free':'paid'}">${m.kind==='local'?'$0 local':(m.available?('~$'+m.estCost.toFixed(2)):'no key')}</span>`;
+    el.appendChild(row);
+  }
+  el.addEventListener('change', updateEst); updateEst();
+  const pr = $('#presets'); pr.innerHTML='';
+  for (const p of PRESETS){
+    const b = document.createElement('span'); b.className='preset'; b.textContent=p.t;
+    b.onclick = ()=>{ $('#f-title').value=p.t; $('#f-prompt').value=p.p; };
+    pr.appendChild(b);
+  }
+}
+function picked(){ return [...document.querySelectorAll('#model-picks input:checked')].map(i=>i.value); }
+function updateEst(){
+  const est = picked().reduce((s,id)=>{ const m=models.find(x=>x.id===id); return s+(m?m.estCost:0); },0);
+  $('#est').textContent = 'Estimated battle cost: ' + (est ? '~$'+est.toFixed(2)+' (metered models selected)' : '$0 (all local)');
+}
+$('#f-go').onclick = async ()=>{
+  const title=$('#f-title').value.trim(), prompt=$('#f-prompt').value.trim(), ids=picked();
+  if (!title||!prompt||!ids.length) return alert('Need a title, a challenge prompt, and at least one model.');
+  $('#f-go').disabled = true;
+  const r = await fetch(API+'/api/challenges',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title,prompt,models:ids})});
+  $('#f-go').disabled = false;
+  if (!r.ok) return alert('Error: '+(await r.text()));
+  const c = await r.json();
+  $('#f-title').value=''; $('#f-prompt').value='';
+  openDetail(c.id);
+};
+
+// ---- detail ----
+async function openDetail(id){
+  show('detail');
+  await renderDetail(id);
+  if (pollTimer) clearInterval(pollTimer);
+  pollTimer = setInterval(async ()=>{
+    const c = await renderDetail(id, true);
+    if (c && !c.runs.some(r=>r.status==='running'||r.status==='queued')){ clearInterval(pollTimer); pollTimer=null; renderDetail(id); }
+  }, 4000);
+}
+async function renderDetail(id, statusOnly){
+  const r = await fetch(API+'/api/challenges/'+id);
+  if (!r.ok) return null;
+  const c = await r.json(); current = c;
+  $('#d-title').textContent = c.title + (c.winner ? '  —  👑 ' + mLabel(c.winner) : '');
+  $('#d-meta').textContent = '🕓 ' + fmtWhen(c.created_at) + '\n' + c.prompt;
+  const arena = $('#d-arena');
+  const sig = c.runs.map(r=>r.model+':'+r.status).join('|') + '|' + c.winner;
+  if (statusOnly && arena.dataset.sig === sig) return c;
+  arena.dataset.sig = sig;
+  arena.innerHTML = '';
+  for (const run of c.runs){
+    const pane = document.createElement('div');
+    pane.className = 'pane' + (c.winner===run.model?' winner':'');
+    const stats = [];
+    if (run.seconds!=null) stats.push(run.seconds+'s');
+    if (run.bytes) stats.push(Math.round(run.bytes/1024)+' KB');
+    stats.push(run.cost ? '$'+run.cost.toFixed(3) : '$0');
+    let bodyHtml;
+    if (run.status==='done')
+      bodyHtml = `<iframe sandbox="allow-scripts" src="${API}/artifact/${c.id}/${run.model}" title="${mLabel(run.model)}"></iframe>`;
+    else if (run.status==='error')
+      bodyHtml = `<div class="msg">✗ ${esc(run.error||'failed')}</div>`;
+    else
+      bodyHtml = `<div class="msg">⏳ ${run.status}… local models render one at a time</div>`;
+    pane.innerHTML = `<div class="bar">
+        <span class="name">${mLabel(run.model)}</span>
+        <span class="stat">${run.status==='done'?stats.join(' · '):run.status}</span>
+        <span class="right">
+          ${run.status==='done'&&c.winner!==run.model?`<button class="btn" data-win="${run.model}">👑 Crown</button>`:''}
+          ${c.winner===run.model?'<span class="chip winner">WINNER</span>':''}
+          ${(run.status==='error'||run.status==='done')?`<button class="btn pink" data-retry="${run.model}">↻</button>`:''}
+        </span>
+      </div>${bodyHtml}`;
+    arena.appendChild(pane);
+  }
+  arena.querySelectorAll('[data-win]').forEach(b=>b.onclick=async e=>{
+    await fetch(API+'/api/challenges/'+c.id+'/vote',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({winner:e.target.dataset.win})});
+    renderDetail(c.id);
+  });
+  arena.querySelectorAll('[data-retry]').forEach(b=>b.onclick=async e=>{
+    await fetch(API+'/api/challenges/'+c.id+'/retry/'+e.target.dataset.retry,{method:'POST'});
+    openDetail(c.id);
+  });
+  return c;
+}
+
+// ---- leaderboard ----
+async function loadBoard(){
+  const d = await (await fetch(API+'/api/ledger')).json();
+  $('#b-note').textContent = d.votes + ' battle' + (d.votes===1?'':'s') + ' judged. Win rate = wins / battles the model completed an artifact in AND a winner was crowned.';
+  const tb = $('#b-rows'); tb.innerHTML='';
+  d.models.forEach((s,i)=>{
+    const tr = document.createElement('tr');
+    if (i===0 && s.winRate!=null) tr.className='first';
+    tr.innerHTML = `<td>${i+1}</td><td>${s.label}</td><td>${s.kind}</td><td>${s.battles}</td><td>${s.wins}</td>
+      <td>${s.winRate==null?'—':s.winRate+'%'}</td><td>${s.errors}</td>
+      <td>${s.avgSeconds==null?'—':s.avgSeconds+'s'}</td><td>${s.spend?'$'+s.spend.toFixed(2):'$0'}</td>`;
+    tb.appendChild(tr);
+  });
+}
+
+loadModels().then(loadList);
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
index 57f5df5..db279a7 100644
--- a/server.js
+++ b/server.js
@@ -1,41 +1,302 @@
-// Storefront starter — zero-dep Node http. Serves data/products.json with SERVER-SIDE sort
-// (house rule: every product grid gets sort + a density slider). Optional basic auth via BASIC_AUTH.
-const http = require('http'), fs = require('fs'), path = require('path');
-const PORT = parseInt(process.env.PORT || '3900', 10);
-const BASIC_AUTH = process.env.BASIC_AUTH || ''; // "user:pass" to gate; empty = open
+// Model Arena — fan one build challenge out to multiple AI models, render each
+// model's single-file HTML artifact side-by-side, crown a winner, keep a
+// real-world win-rate ledger. Zero-dep Node. $0 by default (local Ollama);
+// metered models are opt-in per-run and cost-logged.
+const http = require('http'), fs = require('fs'), path = require('path'), os = require('os');
+const crypto = require('crypto');
+
+const PORT = parseInt(process.env.PORT || '9758', 10);
+const BASIC_AUTH = process.env.BASIC_AUTH !== undefined ? process.env.BASIC_AUTH : 'admin:DW2024!';
 const DIR = __dirname;
+const DATA = path.join(DIR, 'data');
+const ART = path.join(DATA, 'artifacts');
+const CH_FILE = path.join(DATA, 'challenges.json');
+const VOTES_FILE = path.join(DATA, 'votes.jsonl');
+const COST_FILE = path.join(DATA, 'costlog.jsonl');
+const GLOBAL_LEDGER = path.join(os.homedir(), '.claude', 'cost-ledger.jsonl');
+
+fs.mkdirSync(ART, { recursive: true });
+
+// ---------- model roster ----------
+// Local ollama models are free and enabled by default. Metered models require
+// their env key AND explicit per-run selection in the UI (never pre-checked).
+const MODELS = [
+  { id: 'qwen3-14b',  label: 'Qwen3 14B',    kind: 'local', host: 'http://localhost:11434',      model: 'qwen3:14b',  estCost: 0 },
+  { id: 'gemma3-12b', label: 'Gemma3 12B',   kind: 'local', host: 'http://localhost:11434',      model: 'gemma3:12b', estCost: 0 },
+  { id: 'hermes3-8b', label: 'Hermes3 8B',   kind: 'local', host: 'http://localhost:11434',      model: 'hermes3:8b', estCost: 0 },
+  { id: 'qwen25-7b',  label: 'Qwen2.5 7B (Mac1)', kind: 'local', host: 'http://192.168.1.133:11434', model: 'qwen2.5:7b', estCost: 0 },
+  { id: 'claude',     label: 'Claude Fable 5', kind: 'metered', provider: 'anthropic', model: 'claude-fable-5',   envKey: 'ANTHROPIC_API_KEY', estCost: 0.15 },
+  { id: 'kimi',       label: 'Kimi K2.5',      kind: 'metered', provider: 'moonshot',  model: 'kimi-k2.5',        envKey: 'MOONSHOT_API_KEY',  estCost: 0.03 },
+  { id: 'gpt',        label: 'GPT-5.3',        kind: 'metered', provider: 'openai',    model: 'gpt-5.3',          envKey: 'OPENAI_API_KEY',    estCost: 0.10 },
+  { id: 'grok',       label: 'Grok 4.5',       kind: 'metered', provider: 'xai',       model: process.env.GROK_MODEL || 'grok-4.5', envKey: 'XAI_API_KEY', estCost: 0.10 },
+];
+
+// ---------- storage ----------
+function loadChallenges() { try { return JSON.parse(fs.readFileSync(CH_FILE, 'utf8')); } catch { return []; } }
+function saveChallenges(list) {
+  const tmp = CH_FILE + '.tmp';
+  fs.writeFileSync(tmp, JSON.stringify(list, null, 2));
+  fs.renameSync(tmp, CH_FILE);
+}
+let challenges = loadChallenges();
+// anything left "running" from a previous process died with it
+for (const c of challenges) for (const r of c.runs) if (r.status === 'running' || r.status === 'queued') { r.status = 'error'; r.error = 'server restarted mid-run'; }
+saveChallenges(challenges);
+
+function appendJsonl(file, obj) { try { fs.appendFileSync(file, JSON.stringify(obj) + '\n'); } catch {} }
+
+// ---------- generation ----------
+const ARENA_PROMPT = (challenge) => `You are competing in a model arena against other AI models. Challenge:
+
+${challenge}
+
+Rules:
+- Return ONE complete, self-contained single-file HTML document (inline CSS + JS, no external resources, no network requests).
+- It must run instantly when opened in a browser iframe.
+- Output ONLY the HTML document starting with <!DOCTYPE html>. No markdown fences, no commentary before or after.`;
+
+function extractHtml(text) {
+  if (!text) return null;
+  let t = String(text).replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
+  const fence = t.match(/```(?:html)?\s*([\s\S]*?)```/i);
+  if (fence && /<html[\s>]/i.test(fence[1])) t = fence[1].trim();
+  const start = t.search(/<!doctype html|<html[\s>]/i);
+  if (start === -1) return null;
+  const endIdx = t.toLowerCase().lastIndexOf('</html>');
+  return endIdx === -1 ? t.slice(start) : t.slice(start, endIdx + 7);
+}
+
+function httpJson(url, opts, body, timeoutMs) {
+  return new Promise((resolve, reject) => {
+    const u = new URL(url);
+    const mod = u.protocol === 'https:' ? require('https') : require('http');
+    const req = mod.request(u, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) } }, res => {
+      let buf = '';
+      res.on('data', d => buf += d);
+      res.on('end', () => {
+        try { resolve({ status: res.statusCode, json: JSON.parse(buf) }); }
+        catch { resolve({ status: res.statusCode, json: null, raw: buf.slice(0, 500) }); }
+      });
+    });
+    req.setTimeout(timeoutMs, () => req.destroy(new Error('timeout after ' + timeoutMs / 1000 + 's')));
+    req.on('error', reject);
+    req.end(JSON.stringify(body));
+  });
+}
+
+// serialize local generations per host so ollama doesn't thrash model loads
+const hostQueues = {};
+function onHost(host, fn) {
+  const prev = hostQueues[host] || Promise.resolve();
+  const next = prev.then(fn, fn);
+  hostQueues[host] = next.catch(() => {});
+  return next;
+}
+
+async function generateLocal(m, prompt) {
+  const r = await httpJson(m.host + '/api/chat', {}, {
+    model: m.model,
+    stream: false,
+    messages: [{ role: 'user', content: prompt }],
+    options: { temperature: 0.7, num_predict: 6144 },
+  }, 600000);
+  if (!r.json || !r.json.message) throw new Error('ollama bad response: ' + (r.raw || r.status));
+  return { text: r.json.message.content, cost: 0, tokens: { out: r.json.eval_count || 0 } };
+}
+
+// metered pricing per 1M tokens [in, out] — rough, for the cost line
+const PRICING = {
+  anthropic: [5, 25], moonshot: [0.6, 2.5], openai: [1.75, 14], xai: [3, 15],
+};
+function meteredCost(provider, inTok, outTok) {
+  const [pi, po] = PRICING[provider] || [0, 0];
+  return (inTok * pi + outTok * po) / 1e6;
+}
+
+async function generateMetered(m, prompt) {
+  const key = process.env[m.envKey];
+  if (!key) throw new Error(m.envKey + ' not set — metered model unavailable');
+  let text, inTok = 0, outTok = 0;
+  if (m.provider === 'anthropic') {
+    const r = await httpJson('https://api.anthropic.com/v1/messages',
+      { headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01' } },
+      { model: m.model, max_tokens: 16000, messages: [{ role: 'user', content: prompt }] }, 300000);
+    if (!r.json || !r.json.content) throw new Error('anthropic error: ' + JSON.stringify(r.json && r.json.error || r.raw).slice(0, 200));
+    text = r.json.content.map(b => b.text || '').join('');
+    inTok = (r.json.usage && r.json.usage.input_tokens) || 0; outTok = (r.json.usage && r.json.usage.output_tokens) || 0;
+  } else {
+    const base = { moonshot: 'https://api.moonshot.ai/v1', openai: 'https://api.openai.com/v1', xai: 'https://api.x.ai/v1' }[m.provider];
+    const r = await httpJson(base + '/chat/completions', { headers: { Authorization: 'Bearer ' + key } },
+      { model: m.model, temperature: m.provider === 'moonshot' ? 1 : 0.7, max_tokens: 16000, messages: [{ role: 'user', content: prompt }] }, 300000);
+    const ch = r.json && r.json.choices && r.json.choices[0];
+    if (!ch) throw new Error(m.provider + ' error: ' + JSON.stringify(r.json && r.json.error || r.raw).slice(0, 200));
+    text = ch.message.content || ch.message.reasoning_content || '';
+    inTok = (r.json.usage && r.json.usage.prompt_tokens) || 0; outTok = (r.json.usage && r.json.usage.completion_tokens) || 0;
+  }
+  const cost = meteredCost(m.provider, inTok, outTok);
+  const entry = { ts: new Date().toISOString(), provider: m.provider, model: m.model, task: 'model-arena', input_tokens: inTok, output_tokens: outTok, cost_usd: +cost.toFixed(6) };
+  appendJsonl(COST_FILE, entry);
+  appendJsonl(GLOBAL_LEDGER, entry);
+  return { text, cost, tokens: { in: inTok, out: outTok } };
+}
+
+function runModel(challenge, modelId) {
+  const m = MODELS.find(x => x.id === modelId);
+  const run = challenge.runs.find(r => r.model === modelId);
+  if (!m || !run) return;
+  run.status = 'queued'; run.error = null; saveChallenges(challenges);
+  const prompt = ARENA_PROMPT(challenge.prompt);
+  const exec = async () => {
+    run.status = 'running'; run.started_at = new Date().toISOString(); saveChallenges(challenges);
+    const t0 = Date.now();
+    try {
+      const out = m.kind === 'local' ? await generateLocal(m, prompt) : await generateMetered(m, prompt);
+      const html = extractHtml(out.text);
+      if (!html) throw new Error('no HTML document in model output (' + String(out.text || '').length + ' chars)');
+      const dir = path.join(ART, challenge.id);
+      fs.mkdirSync(dir, { recursive: true });
+      fs.writeFileSync(path.join(dir, modelId + '.html'), html);
+      run.status = 'done'; run.seconds = Math.round((Date.now() - t0) / 1000);
+      run.cost = +(out.cost || 0).toFixed(4); run.bytes = Buffer.byteLength(html);
+    } catch (e) {
+      run.status = 'error'; run.error = String(e.message || e).slice(0, 300);
+      run.seconds = Math.round((Date.now() - t0) / 1000);
+    }
+    run.finished_at = new Date().toISOString();
+    saveChallenges(challenges);
+  };
+  if (m.kind === 'local') onHost(m.host, exec); else exec();
+}
 
-function load() { try { return JSON.parse(fs.readFileSync(path.join(DIR, 'data', 'products.json'), 'utf8')); } catch { return []; } }
+// ---------- ledger ----------
+function buildLedger() {
+  const stats = {};
+  for (const m of MODELS) stats[m.id] = { id: m.id, label: m.label, kind: m.kind, battles: 0, wins: 0, errors: 0, totalSeconds: 0, doneRuns: 0, spend: 0 };
+  for (const c of challenges) {
+    for (const r of c.runs) {
+      const s = stats[r.model]; if (!s) continue;
+      if (r.status === 'done') { s.doneRuns++; s.totalSeconds += r.seconds || 0; s.spend += r.cost || 0; }
+      if (r.status === 'error') s.errors++;
+      if (c.winner && r.status === 'done') { s.battles++; if (c.winner === r.model) s.wins++; }
+    }
+  }
+  return Object.values(stats).map(s => ({
+    ...s,
+    winRate: s.battles ? +(100 * s.wins / s.battles).toFixed(1) : null,
+    avgSeconds: s.doneRuns ? Math.round(s.totalSeconds / s.doneRuns) : null,
+    spend: +s.spend.toFixed(4),
+  })).sort((a, b) => (b.winRate || 0) - (a.winRate || 0) || b.wins - a.wins);
+}
+
+// ---------- http ----------
 function authed(req) {
   if (!BASIC_AUTH) return true;
   const m = (req.headers.authorization || '').match(/^Basic\s+(.+)$/i);
-  if (!m) return false; try { return Buffer.from(m[1], 'base64').toString() === BASIC_AUTH; } catch { return false; }
-}
-// Sort modes mirror the DW sort-skill: newest, title/sku A→Z, price ↑↓, light→dark (by dominant hex).
-function luminance(hex) { if (!/^#?[0-9a-f]{6}$/i.test(hex || '')) return 999; const h = hex.replace('#', '');
-  const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
-  return 0.2126 * r + 0.7152 * g + 0.0722 * b; }
-function sortProducts(items, mode) {
-  const a = items.slice();
-  switch (mode) {
-    case 'title': return a.sort((x, y) => (x.title || '').localeCompare(y.title || ''));
-    case 'sku': return a.sort((x, y) => (x.sku || x.handle || '').localeCompare(y.sku || y.handle || ''));
-    case 'price-asc': return a.sort((x, y) => (x.price || 0) - (y.price || 0));
-    case 'price-desc': return a.sort((x, y) => (y.price || 0) - (x.price || 0));
-    case 'light': return a.sort((x, y) => luminance(y.hex) - luminance(x.hex));
-    case 'dark': return a.sort((x, y) => luminance(x.hex) - luminance(y.hex));
-    default: return a; // 'newest' = natural order
-  }
-}
-http.createServer((req, res) => {
-  if (!authed(req)) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="preview"' }); return res.end('auth required'); }
+  if (!m) return false;
+  try { return Buffer.from(m[1], 'base64').toString() === BASIC_AUTH; } catch { return false; }
+}
+function send(res, code, obj) { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); }
+function readBody(req) { return new Promise(r => { let b = ''; req.on('data', d => b += d); req.on('end', () => { try { r(JSON.parse(b || '{}')); } catch { r({}); } }); }); }
+
+const server = http.createServer(async (req, res) => {
+  if (!authed(req)) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="model-arena"' }); return res.end('auth required'); }
   const u = new URL(req.url, 'http://x');
-  if (u.pathname === '/api/products') {
-    const out = sortProducts(load(), u.searchParams.get('sort') || 'newest');
-    res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ count: out.length, products: out }));
+  const p = u.pathname;
+
+  if (p === '/api/models') {
+    return send(res, 200, MODELS.map(m => ({
+      id: m.id, label: m.label, kind: m.kind, model: m.model,
+      available: m.kind === 'local' ? true : !!process.env[m.envKey],
+      estCost: m.estCost,
+    })));
+  }
+
+  if (p === '/api/challenges' && req.method === 'GET') {
+    const sort = u.searchParams.get('sort') || 'newest';
+    let list = challenges.slice();
+    if (sort === 'oldest') { /* natural order */ }
+    else if (sort === 'title') list.sort((a, b) => a.title.localeCompare(b.title));
+    else if (sort === 'models') list.sort((a, b) => b.runs.length - a.runs.length);
+    else if (sort === 'winner') list.sort((a, b) => (b.winner ? 1 : 0) - (a.winner ? 1 : 0));
+    else list.reverse(); // newest first
+    return send(res, 200, { count: list.length, challenges: list });
+  }
+
+  if (p === '/api/challenges' && req.method === 'POST') {
+    const body = await readBody(req);
+    const title = String(body.title || '').trim().slice(0, 120);
+    const prompt = String(body.prompt || '').trim().slice(0, 4000);
+    const ids = Array.isArray(body.models) ? body.models.filter(id => MODELS.some(m => m.id === id)) : [];
+    if (!title || !prompt || !ids.length) return send(res, 400, { error: 'title, prompt, and at least one model required' });
+    const c = {
+      id: crypto.randomBytes(6).toString('hex'),
+      title, prompt,
+      created_at: new Date().toISOString(),
+      winner: null,
+      runs: ids.map(id => ({ model: id, status: 'queued', error: null, seconds: null, cost: null })),
+    };
+    challenges.push(c); saveChallenges(challenges);
+    for (const id of ids) runModel(c, id);
+    return send(res, 201, c);
+  }
+
+  let m;
+  if ((m = p.match(/^\/api\/challenges\/([a-f0-9]+)$/)) && req.method === 'GET') {
+    const c = challenges.find(x => x.id === m[1]);
+    return c ? send(res, 200, c) : send(res, 404, { error: 'not found' });
   }
-  const f = u.pathname === '/' ? 'index.html' : u.pathname.replace(/^\//, '');
+
+  if ((m = p.match(/^\/api\/challenges\/([a-f0-9]+)\/vote$/)) && req.method === 'POST') {
+    const c = challenges.find(x => x.id === m[1]);
+    if (!c) return send(res, 404, { error: 'not found' });
+    const body = await readBody(req);
+    const winner = String(body.winner || '');
+    if (!c.runs.some(r => r.model === winner && r.status === 'done')) return send(res, 400, { error: 'winner must be a model with a done artifact' });
+    c.winner = winner; c.voted_at = new Date().toISOString(); saveChallenges(challenges);
+    appendJsonl(VOTES_FILE, { ts: c.voted_at, challenge: c.id, title: c.title, winner, field: c.runs.filter(r => r.status === 'done').map(r => r.model) });
+    return send(res, 200, c);
+  }
+
+  if ((m = p.match(/^\/api\/challenges\/([a-f0-9]+)\/retry\/([\w-]+)$/)) && req.method === 'POST') {
+    const c = challenges.find(x => x.id === m[1]);
+    if (!c) return send(res, 404, { error: 'not found' });
+    if (!c.runs.some(r => r.model === m[2])) c.runs.push({ model: m[2], status: 'queued', error: null, seconds: null, cost: null });
+    runModel(c, m[2]);
+    return send(res, 200, c);
+  }
+
+  if ((m = p.match(/^\/api\/challenges\/([a-f0-9]+)$/)) && req.method === 'DELETE') {
+    const idx = challenges.findIndex(x => x.id === m[1]);
+    if (idx === -1) return send(res, 404, { error: 'not found' });
+    challenges.splice(idx, 1); saveChallenges(challenges);
+    fs.rmSync(path.join(ART, m[1]), { recursive: true, force: true });
+    return send(res, 200, { ok: true });
+  }
+
+  if (p === '/api/ledger') return send(res, 200, { models: buildLedger(), votes: challenges.filter(c => c.winner).length });
+
+  if ((m = p.match(/^\/artifact\/([a-f0-9]+)\/([\w-]+)$/))) {
+    const fp = path.join(ART, m[1], m[2] + '.html');
+    if (!fs.existsSync(fp)) { res.writeHead(404); return res.end('no artifact'); }
+    res.writeHead(200, {
+      'Content-Type': 'text/html; charset=utf-8',
+      // artifacts are untrusted model output — no network, no top-level nav
+      'Content-Security-Policy': "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; media-src data:; font-src data:",
+      'X-Frame-Options': 'SAMEORIGIN',
+    });
+    return res.end(fs.readFileSync(fp));
+  }
+
+  const f = p === '/' ? 'index.html' : p.replace(/^\//, '');
   const fp = path.join(DIR, 'public', path.basename(f));
-  if (fs.existsSync(fp)) { res.writeHead(200, { 'Content-Type': f.endsWith('.html') ? 'text/html' : 'text/plain' }); return res.end(fs.readFileSync(fp)); }
+  if (fs.existsSync(fp) && fs.statSync(fp).isFile()) {
+    const type = f.endsWith('.html') ? 'text/html' : f.endsWith('.js') ? 'text/javascript' : f.endsWith('.css') ? 'text/css' : 'text/plain';
+    res.writeHead(200, { 'Content-Type': type });
+    return res.end(fs.readFileSync(fp));
+  }
   res.writeHead(404); res.end('not found');
-}).listen(PORT, function () { console.log('[' + path.basename(DIR) + '] http://localhost:' + this.address().port); });
+});
+
+server.listen(PORT, function () {
+  console.log('[model-arena] http://localhost:' + this.address().port + (BASIC_AUTH ? '  (basic auth on)' : ''));
+});

← 0db8665 initial scaffold (model-arena) via web-dev accelerator  ·  back to Model Arena  ·  model-arena: README + first real battle (hermes3 13s vs gemm 304fb75 →