[object Object]

← back to Model Arena

yolo idea C: model profile page — clickable leaderboard rows open a model's artifact gallery across all battles + aggregate stats

8264d56299c9f02ad1205040b872e81b1e70799b · 2026-07-23 00:26:20 -0700 · Steve

Files touched

Diff

commit 8264d56299c9f02ad1205040b872e81b1e70799b
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jul 23 00:26:20 2026 -0700

    yolo idea C: model profile page — clickable leaderboard rows open a model's artifact gallery across all battles + aggregate stats
---
 public/index.html | 40 +++++++++++++++++++++++++++++++++++++++-
 server.js         | 16 ++++++++++++++++
 yolo/IDEAS.md     |  1 +
 3 files changed, 56 insertions(+), 1 deletion(-)

diff --git a/public/index.html b/public/index.html
index 5c8521e..36e280a 100644
--- a/public/index.html
+++ b/public/index.html
@@ -113,6 +113,14 @@ tr.first td{color:var(--gold)}
 .srcview{height:460px;overflow:auto;margin:0;padding:12px;background:#05060d;color:#8fb8ff;font-size:11px;line-height:1.5;white-space:pre-wrap;word-break:break-word}
 .pane.winner .srcview{height:560px}
 .view{display:none}.view.on{display:block}
+tr.clk{cursor:pointer}tr.clk:hover td{background:rgba(0,229,255,.06)}
+.pgal{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}
+.gcard{border:1px solid var(--line);background:var(--panel);cursor:pointer}
+.gcard:hover{border-color:var(--neon)}.gcard.won{border-color:var(--gold)}
+.gcard img{width:100%;height:130px;object-fit:cover;object-position:top center;display:block;background:#000}
+.gcard .noimg{height:130px;display:flex;align-items:center;justify-content:center;color:var(--dim);font-size:11px;background:var(--panel2)}
+.gcard .gm{padding:7px 9px;font-size:11px}.gcard .gm .gt{color:#fff;display:block;margin-bottom:3px}
+.gcard .gm .gs{color:var(--dim)}
 </style>
 </head>
 <body>
@@ -166,6 +174,13 @@ tr.first td{color:var(--gold)}
     <div class="arena" id="d-arena"></div>
   </section>
 
+  <section id="profile" class="view">
+    <span class="back" id="p-back">← back to leaderboard</span>
+    <h2 id="p-title"></h2>
+    <div class="note" id="p-stats"></div>
+    <div id="p-gallery" class="pgal"></div>
+  </section>
+
   <section id="board" class="view">
     <h2>Real-World Leaderboard</h2>
     <div class="controls"><label>Category <select id="b-cat"><option value="">All</option></select></label>
@@ -410,7 +425,8 @@ async function loadBoard(){
   const tb = $('#b-rows'); tb.innerHTML='';
   d.models.forEach((s,i)=>{
     const tr = document.createElement('tr');
-    if (i===0 && s.wins>0) tr.className='first';
+    tr.className='clk'+(i===0 && s.wins>0?' first':'');
+    tr.onclick=()=>openProfile(s.id);
     tr.innerHTML = `<td>${i+1}</td><td>${s.label}</td><td>${s.kind}</td><td><b>${s.elo}</b></td><td>${s.battles}</td><td>${s.wins}</td>
       <td>${s.winRate==null?'—':s.winRate+'%'}</td><td>${s.avgAiScore==null?'—':s.avgAiScore}</td>
       <td>${s.costPerWin==null?'—':(s.costPerWin?'$'+s.costPerWin.toFixed(3):'$0')}</td>
@@ -419,6 +435,28 @@ async function loadBoard(){
   });
 }
 
+// ---- model profile ----
+$('#p-back').onclick = ()=>{ show('board'); loadBoard(); };
+async function openProfile(mid){
+  show('profile');
+  const d = await (await fetch(API+'/api/models/'+mid+'/profile')).json();
+  const s = d.stats||{};
+  $('#p-title').textContent = d.model.label + ' — model profile';
+  $('#p-stats').textContent = `${d.model.kind} · ELO ${s.elo??'—'} · ${s.wins??0} wins / ${s.battles??0} judged (${s.winRate==null?'—':s.winRate+'%'}) · avg 🤖 ${s.avgAiScore??'—'} · ${s.errors??0} errors · avg ${s.avgSeconds==null?'—':s.avgSeconds+'s'} · spend $${(s.spend||0).toFixed(2)}`;
+  const g = $('#p-gallery'); g.innerHTML='';
+  if (!d.gallery.length){ g.innerHTML='<div class="empty">No artifacts yet.</div>'; return; }
+  for (const it of d.gallery){
+    const card=document.createElement('div'); card.className='gcard'+(it.won?' won':'');
+    const badge=[it.won?'👑':'',it.aiPick?'🤖':'',it.aiScore!=null?'🤖'+it.aiScore:''].filter(Boolean).join(' ');
+    const img = it.thumb ? `<img src="${API}/thumb/${it.id}/${mid}" alt="" loading="lazy">`
+      : `<div class="noimg">${it.status==='error'?'✗ '+esc((it.error||'failed').slice(0,40)):esc(it.status)}</div>`;
+    card.innerHTML = `${img}<div class="gm"><span class="gt">${esc(it.title)}</span>
+      <span class="gs">${it.category} · ${it.status==='done'?(it.seconds||0)+'s'+(it.cost?' · $'+it.cost.toFixed(3):''):it.status} ${badge}</span></div>`;
+    card.onclick=()=>openDetail(it.id);
+    g.appendChild(card);
+  }
+}
+
 loadModels().then(loadList);
 </script>
 </body>
diff --git a/server.js b/server.js
index 14fd79a..e6a76e0 100644
--- a/server.js
+++ b/server.js
@@ -491,6 +491,22 @@ const server = http.createServer(async (req, res) => {
     return send(res, 200, c);
   }
 
+  if ((m = p.match(/^\/api\/models\/([\w-]+)\/profile$/))) {
+    const mid = m[1]; const mo = MODELS.find(x => x.id === mid);
+    if (!mo) return send(res, 404, { error: 'unknown model' });
+    const full = buildLedger(null).models.find(s => s.id === mid) || {};
+    const gallery = [];
+    for (const c of challenges) {
+      const r = c.runs.find(x => x.model === mid);
+      if (!r) continue;
+      gallery.push({ id: c.id, title: c.title, category: c.category || 'Custom', created_at: c.created_at,
+        status: r.status, seconds: r.seconds, cost: r.cost || 0, thumb: !!r.thumb,
+        aiScore: typeof r.aiScore === 'number' ? r.aiScore : null, won: c.winner === mid, aiPick: c.aiPick === mid, error: r.error || null });
+    }
+    gallery.reverse();
+    return send(res, 200, { model: { id: mid, label: mo.label, kind: mo.kind }, stats: full, gallery });
+  }
+
   if (p === '/api/ledger') {
     const cat = u.searchParams.get('category') || '';
     const l = buildLedger(cat || null);
diff --git a/yolo/IDEAS.md b/yolo/IDEAS.md
index 2373b49..d95247f 100644
--- a/yolo/IDEAS.md
+++ b/yolo/IDEAS.md
@@ -34,3 +34,4 @@ Answering the /contrarian critique ("one builder vote = theater", "toy demos not
 - [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.
+- [DONE] #C Model profile page — click any leaderboard row → that model's full body of work (every artifact thumbnail across battles, won/AI-pick badges, per-battle stats) + aggregate stats. /api/models/:id/profile.

← 2a631a5 yolo idea B: export a battle as a self-contained standalone  ·  back to Model Arena  ·  yolo idea D: head-to-head record matrix — pairwise W-L (row 6fea94d →