← back to Model Arena
yolo idea B: export a battle as a self-contained standalone HTML (artifacts inlined via sandboxed srcdoc + scoreboard), downloadable, renders offline
2a631a54c9a85a540a583fdef789a475c39c7bb5 · 2026-07-23 00:24:37 -0700 · Steve
Files touched
M public/index.htmlM server.jsM yolo/IDEAS.md
Diff
commit 2a631a54c9a85a540a583fdef789a475c39c7bb5
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Jul 23 00:24:37 2026 -0700
yolo idea B: export a battle as a self-contained standalone HTML (artifacts inlined via sandboxed srcdoc + scoreboard), downloadable, renders offline
---
public/index.html | 4 +++-
server.js | 25 +++++++++++++++++++++++++
yolo/IDEAS.md | 1 +
3 files changed, 29 insertions(+), 1 deletion(-)
diff --git a/public/index.html b/public/index.html
index 6958a43..5c8521e 100644
--- a/public/index.html
+++ b/public/index.html
@@ -158,6 +158,7 @@ tr.first td{color:var(--gold)}
<section id="detail" class="view">
<span class="back" id="back">← back to challenges</span>
+ <a class="back" id="export" style="margin-left:16px" download>⬇ export battle as standalone HTML</a>
<label class="blindtog"><input type="checkbox" id="blind"> 🕶 Blind judging — hide model names until a winner is crowned (kills brand bias)</label>
<h2 id="d-title"></h2>
<div class="meta" id="d-meta"></div>
@@ -310,7 +311,8 @@ async function renderDetail(id, statusOnly){
const c = await r.json(); current = c;
$('#d-title').textContent = c.title + (c.winner ? ' — 👑 ' + mLabel(c.winner) : '');
const remixNote = c.remixOf ? '🧬 remixed from ' + mLabel(c.remixSource) + "'s artifact\n" : '';
- $('#d-meta').textContent = '🕓 ' + fmtWhen(c.created_at) + '\n' + remixNote + c.prompt;
+ $('#d-meta').textContent = '🕓 ' + fmtWhen(c.created_at) + (c.category?' · '+c.category:'') + '\n' + remixNote + c.prompt;
+ $('#export').href = API+'/export/'+c.id;
// AI referee status bar
const anyArt = c.runs.some(r=>r.status==='done'&&r.thumb);
const ai = $('#d-ai');
diff --git a/server.js b/server.js
index a16fe33..14fd79a 100644
--- a/server.js
+++ b/server.js
@@ -499,6 +499,31 @@ const server = http.createServer(async (req, res) => {
return send(res, 200, { ...l, votes: pool.filter(c => c.winner).length, categories: cats, category: cat });
}
+ if ((m = p.match(/^\/export\/([a-f0-9]+)$/))) {
+ const c = challenges.find(x => x.id === m[1]);
+ if (!c) { res.writeHead(404); return res.end('not found'); }
+ const esc = s => String(s).replace(/[&<>"]/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[ch]));
+ const cards = c.runs.filter(r => r.status === 'done').map(r => {
+ let html = ''; try { html = fs.readFileSync(path.join(ART, c.id, r.model + '.html'), 'utf8'); } catch {}
+ const badges = [c.winner === r.model ? '👑 WINNER' : '', c.aiPick === r.model ? '🤖 AI PICK' : '', typeof r.aiScore === 'number' ? '🤖 ' + r.aiScore : ''].filter(Boolean).join(' · ');
+ return `<div class="c${c.winner === r.model ? ' win' : ''}"><div class="h"><b>${esc(mLabelServer(r.model))}</b> <span>${r.seconds || 0}s · ${r.cost ? '$' + r.cost.toFixed(3) : '$0'} · ${esc(badges)}</span></div>
+ <iframe sandbox="allow-scripts" srcdoc="${esc(html)}"></iframe></div>`;
+ }).join('\n');
+ const page = `<!doctype html><meta charset=utf-8><title>Model Arena — ${esc(c.title)}</title>
+<style>body{background:#07080f;color:#dfe4ff;font:14px/1.5 ui-monospace,Menlo,monospace;margin:0;padding:22px}
+h1{font-size:18px;letter-spacing:2px}h1 b{color:#00e5ff}.p{color:#7d84ad;white-space:pre-wrap;margin:8px 0 18px;font-size:12px}
+.g{display:grid;grid-template-columns:repeat(auto-fit,minmax(420px,1fr));gap:16px}
+.c{border:1px solid #1e2342;background:#0d0f1c}.c.win{border-color:#ffc72e;grid-column:1/-1}
+.h{display:flex;justify-content:space-between;padding:8px 12px;border-bottom:1px solid #1e2342;font-size:12px}.h span{color:#7d84ad}
+iframe{width:100%;height:460px;border:0;background:#000}.c.win iframe{height:560px}
+.f{color:#7d84ad;font-size:11px;margin-top:20px}</style>
+<h1><b>MODEL</b> ARENA — ${esc(c.title)}</h1><div class=p>🕓 ${esc(c.created_at)} · ${esc(c.category || '')}\n${esc(c.prompt)}</div>
+<div class=g>${cards}</div>
+<div class=f>Exported from Model Arena · artifacts are sandboxed. Winner (human 👑) ${c.winner ? esc(mLabelServer(c.winner)) : '—'} · AI referee pick ${c.aiPick ? esc(mLabelServer(c.aiPick)) : '—'}.</div>`;
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Disposition': `attachment; filename="arena-${c.id}.html"` });
+ return res.end(page);
+ }
+
if ((m = p.match(/^\/thumb\/([a-f0-9]+)\/([\w-]+)$/))) {
const fp = path.join(ART, m[1], m[2] + '.png');
if (!fs.existsSync(fp)) { res.writeHead(404); return res.end('no thumb'); }
diff --git a/yolo/IDEAS.md b/yolo/IDEAS.md
index 29b8bc9..2373b49 100644
--- a/yolo/IDEAS.md
+++ b/yolo/IDEAS.md
@@ -33,3 +33,4 @@ Answering the /contrarian critique ("one builder vote = theater", "toy demos not
## Status @ ~27min: 8 features shipped + committed, 7/7 six-way clean, 1 gated draft (daily launchd). Live: modelarena.agentabrams.com
- [DONE] #9 Multi-vision CONSENSUS referee — a PANEL of local vision models (qwen2.5vl:7b + minicpm-v) scores each artifact; consensus = mean, with per-panelist breakdown (hover) + a ⚡ disagreement flag on high spread. On the Smoke Test the 2-model consensus pick (kimi) AGREES with the human crown where the single model didn't — more robust. VISION_MODELS env-configurable. $0 local.
- [DONE] #A Per-category leaderboards — challenges auto-tagged Games/Real Work/Custom (inferCategory + backfill of existing); ledger accepts ?category, leaderboard has a category filter. Answers 'which model is best at REAL WORK vs games'. Games: Grok #1, 100% AI/human agreement.
+- [DONE] #B Export/share — GET /export/:id returns a self-contained standalone HTML of a battle (all artifacts inlined via sandboxed iframe srcdoc, scoreboard, winner/AI-pick), downloadable; verified it renders offline from file://. '⬇ export' button on each battle.
← dfbd21a yolo idea A: per-category leaderboards — auto-tag challenges
·
back to Model Arena
·
yolo idea C: model profile page — clickable leaderboard rows 8264d56 →