Model Comparison Panel
Send one prompt to two or three simulated language models at once and watch their answers stream side by side. Each column carries live badges for latency, token count and estimated cost, a pick-winner vote control, and a synced-scroll toggle so long completions line up as you read. Includes model swap menus, a regenerate action, and a running tally of which model you crowned most often across rounds.
MCP
Code
:root {
--bg: #0c0d10;
--surface: #15161b;
--surface-2: #1d1f27;
--ink: #e9eaf0;
--muted: #9a9cab;
--accent: #8b5cf6;
--accent-2: #22d3ee;
--ok: #34d399;
--warn: #fbbf24;
--danger: #f87171;
--line: rgba(255, 255, 255, 0.10);
--line-2: rgba(255, 255, 255, 0.18);
--r-sm: 8px;
--r-md: 12px;
--r-lg: 16px;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
background: var(--bg);
color: var(--ink);
}
body {
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
min-height: 100vh;
padding: 24px 16px 48px;
background:
radial-gradient(900px 500px at 12% -8%, rgba(139, 92, 246, 0.14), transparent 60%),
radial-gradient(760px 460px at 100% 0%, rgba(34, 211, 238, 0.10), transparent 55%),
var(--bg);
}
code { font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 0.85em; }
.app {
max-width: 1120px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 18px;
}
/* ---- Header ---- */
.app__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.app__brand { display: flex; gap: 14px; align-items: flex-start; }
.app__logo {
width: 40px; height: 40px;
display: grid; place-items: center;
border-radius: var(--r-md);
color: #fff;
background: linear-gradient(135deg, var(--accent), #6d28d9);
box-shadow: 0 6px 20px rgba(139, 92, 246, 0.35);
flex: 0 0 auto;
}
h1 { font-size: 20px; font-weight: 700; margin: 0; letter-spacing: -0.01em; }
.app__sub { margin: 3px 0 0; color: var(--muted); font-size: 13px; max-width: 46ch; }
.scoreboard {
display: flex; align-items: center; gap: 10px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 8px 12px;
}
.scoreboard__label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); }
.scoreboard__chips { display: flex; gap: 6px; flex-wrap: wrap; }
.score-chip {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12px; font-weight: 600;
padding: 3px 8px; border-radius: 999px;
border: 1px solid var(--line-2);
background: var(--surface-2);
}
.score-chip__dot { width: 8px; height: 8px; border-radius: 50%; }
.score-chip__n { font-family: "JetBrains Mono", monospace; color: var(--muted); }
/* ---- Prompt ---- */
.prompt { display: flex; flex-direction: column; gap: 10px; }
.prompt__label { font-size: 12px; font-weight: 600; color: var(--muted); }
.prompt__box {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 12px;
display: flex;
gap: 12px;
align-items: flex-end;
transition: border-color .15s ease, box-shadow .15s ease;
}
.prompt__box:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.22);
}
.prompt__input {
flex: 1;
background: transparent;
border: 0;
resize: vertical;
color: var(--ink);
font: inherit;
font-size: 15px;
min-height: 44px;
outline: none;
}
.prompt__input::placeholder { color: var(--muted); }
.prompt__actions { display: flex; gap: 8px; flex: 0 0 auto; }
.prompt__suggest { display: flex; gap: 8px; flex-wrap: wrap; }
.suggest {
font-size: 12.5px;
color: var(--muted);
background: var(--surface);
border: 1px solid var(--line);
padding: 5px 11px;
border-radius: 999px;
cursor: pointer;
transition: color .15s, border-color .15s, background .15s;
}
.suggest:hover { color: var(--ink); border-color: var(--line-2); background: var(--surface-2); }
.suggest:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* ---- Buttons ---- */
.btn {
display: inline-flex; align-items: center; gap: 7px;
font: inherit; font-size: 13.5px; font-weight: 600;
border-radius: var(--r-sm);
padding: 9px 14px;
cursor: pointer;
border: 1px solid var(--line-2);
background: var(--surface-2);
color: var(--ink);
transition: transform .08s ease, background .15s, border-color .15s, opacity .15s;
}
.btn:hover { background: #262832; }
.btn:active { transform: translateY(1px); }
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn--primary {
background: linear-gradient(135deg, var(--accent), #7c3aed);
border-color: transparent;
color: #fff;
box-shadow: 0 4px 16px rgba(139, 92, 246, 0.32);
}
.btn--primary:hover { background: linear-gradient(135deg, #9d6bff, #8b5cf6); }
.btn--ghost { background: transparent; }
.btn--sm { padding: 6px 10px; font-size: 12.5px; }
/* ---- Toolbar ---- */
.toolbar {
display: flex; align-items: center; justify-content: space-between;
gap: 12px; flex-wrap: wrap;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
padding: 10px 2px;
}
.toolbar__meta {
font-size: 12.5px; color: var(--muted);
font-family: "JetBrains Mono", monospace;
}
.toggle { display: inline-flex; align-items: center; gap: 9px; cursor: pointer; user-select: none; }
.toggle input { position: absolute; opacity: 0; width: 0; height: 0; }
.toggle__track {
width: 40px; height: 22px; border-radius: 999px;
background: var(--surface-2); border: 1px solid var(--line-2);
position: relative; transition: background .18s, border-color .18s;
}
.toggle__thumb {
position: absolute; top: 2px; left: 2px;
width: 16px; height: 16px; border-radius: 50%;
background: var(--muted); transition: transform .18s, background .18s;
}
.toggle input:checked + .toggle__track { background: rgba(139, 92, 246, 0.35); border-color: var(--accent); }
.toggle input:checked + .toggle__track .toggle__thumb { transform: translateX(18px); background: var(--accent); }
.toggle input:focus-visible + .toggle__track { outline: 2px solid var(--accent); outline-offset: 2px; }
.toggle__text { font-size: 13px; font-weight: 500; }
/* ---- Grid ---- */
.grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
@media (max-width: 900px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 620px) { .grid { grid-template-columns: 1fr; } }
.col {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
display: flex;
flex-direction: column;
min-height: 300px;
overflow: hidden;
transition: border-color .18s, box-shadow .18s;
}
.col.is-winner {
border-color: var(--ok);
box-shadow: 0 0 0 1px var(--ok), 0 8px 30px rgba(52, 211, 153, 0.14);
}
.col.is-streaming { border-color: rgba(34, 211, 238, 0.4); }
.col__head {
display: flex; align-items: center; gap: 10px;
padding: 12px; border-bottom: 1px solid var(--line);
}
.col__avatar {
width: 30px; height: 30px; flex: 0 0 auto;
border-radius: 9px; display: grid; place-items: center;
font-weight: 700; font-size: 13px; color: #0c0d10;
}
.col__pick { flex: 1; min-width: 0; }
.col__select {
width: 100%;
font: inherit; font-size: 13.5px; font-weight: 600;
color: var(--ink);
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-sm);
padding: 6px 8px;
cursor: pointer;
}
.col__select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.col__vendor { font-size: 11px; color: var(--muted); margin-top: 2px; padding-left: 2px; }
.status {
display: inline-flex; align-items: center; gap: 6px;
font-size: 11px; font-weight: 600;
font-family: "JetBrains Mono", monospace;
padding: 4px 8px; border-radius: 999px;
border: 1px solid var(--line-2);
color: var(--muted);
white-space: nowrap;
flex: 0 0 auto;
}
.status__dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); }
.status.is-live { color: var(--accent-2); border-color: rgba(34, 211, 238, 0.4); }
.status.is-live .status__dot { background: var(--accent-2); animation: pulse 1s infinite; }
.status.is-done { color: var(--ok); border-color: rgba(52, 211, 153, 0.4); }
.status.is-done .status__dot { background: var(--ok); animation: none; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .3; } }
.col__body {
flex: 1;
padding: 14px;
overflow-y: auto;
max-height: 320px;
font-size: 14px;
color: #d4d6e0;
scrollbar-width: thin;
scrollbar-color: var(--line-2) transparent;
}
.col__body::-webkit-scrollbar { width: 8px; }
.col__body::-webkit-scrollbar-thumb { background: var(--line-2); border-radius: 8px; }
.col__answer { white-space: pre-wrap; word-break: break-word; }
.col__answer.is-empty { color: var(--muted); font-style: italic; }
.caret {
display: inline-block; width: 7px; height: 1.05em;
background: var(--accent-2); margin-left: 1px;
vertical-align: text-bottom; border-radius: 1px;
animation: blink .9s steps(1) infinite;
}
@keyframes blink { 50% { opacity: 0; } }
.badges {
display: flex; gap: 6px; flex-wrap: wrap;
padding: 10px 12px; border-top: 1px solid var(--line);
background: rgba(0,0,0,0.15);
}
.badge {
display: inline-flex; align-items: center; gap: 5px;
font-size: 11.5px; font-weight: 600;
font-family: "JetBrains Mono", monospace;
padding: 4px 8px; border-radius: var(--r-sm);
background: var(--surface-2);
border: 1px solid var(--line);
color: var(--ink);
}
.badge svg { color: var(--muted); }
.badge b { color: var(--muted); font-weight: 500; }
.badge--latency { color: var(--accent-2); }
.badge--tokens { color: var(--ink); }
.badge--cost { color: var(--warn); }
.col__foot {
display: flex; gap: 8px; padding: 10px 12px;
border-top: 1px solid var(--line);
}
.col__foot .btn { flex: 1; justify-content: center; }
.btn--win.is-active { background: var(--ok); border-color: transparent; color: #072019; }
.app__foot { color: var(--muted); font-size: 12px; text-align: center; padding-top: 4px; }
.app__foot code { color: #c0b3ff; }
@media (max-width: 520px) {
body { padding: 16px 12px 40px; }
.prompt__box { flex-direction: column; align-items: stretch; }
.prompt__actions .btn { flex: 1; justify-content: center; }
.scoreboard { width: 100%; justify-content: space-between; }
h1 { font-size: 18px; }
}"use strict";
/* ------------------------------------------------------------------ *
* Model Comparison Panel — simulated multi-model streaming compare.
* Vanilla JS, no dependencies.
* ------------------------------------------------------------------ */
/** Roster of selectable models. cost = USD per 1K output tokens. */
const MODELS = [
{ id: "aurora-max", name: "Aurora Max", vendor: "Northwind", color: "#8b5cf6", cost: 0.015, speed: 1.0, quality: 0.95 },
{ id: "aurora-lite", name: "Aurora Lite", vendor: "Northwind", color: "#a78bfa", cost: 0.003, speed: 1.9, quality: 0.78 },
{ id: "meridian-4", name: "Meridian 4", vendor: "Solaris", color: "#22d3ee", cost: 0.010, speed: 1.3, quality: 0.90 },
{ id: "meridian-flash", name: "Meridian Flash", vendor: "Solaris", color: "#2dd4bf", cost: 0.0009, speed: 2.6, quality: 0.70 },
{ id: "atlas-pro", name: "Atlas Pro", vendor: "Cartograph", color: "#34d399", cost: 0.008, speed: 1.15, quality: 0.88 },
{ id: "corvus-r1", name: "Corvus R1", vendor: "Blackbird", color: "#fbbf24", cost: 0.006, speed: 0.8, quality: 0.92 },
];
/** Prompt suggestions. */
const SUGGESTIONS = [
"Explain vector databases to a backend engineer",
"Write a haiku about race conditions",
"Compare REST and GraphQL in three bullets",
"Refactor a callback into async/await",
];
/**
* Answer templates keyed loosely by intent. The panel is a UI demo, so
* responses are canned but written per-model to feel distinct.
*/
function buildAnswer(prompt, model) {
const p = (prompt || "your prompt").trim();
const short = p.length > 60 ? p.slice(0, 57) + "…" : p;
const tone = model.quality > 0.85 ? "thorough" : "concise";
const openers = {
"aurora-max": `Here's a structured take on "${short}".\n\n`,
"aurora-lite": `Quick answer on "${short}":\n\n`,
"meridian-4": `Let me work through "${short}" step by step.\n\n`,
"meridian-flash": `TL;DR for "${short}":\n\n`,
"atlas-pro": `Great question — "${short}".\n\n`,
"corvus-r1": `Reasoning about "${short}" carefully.\n\n`,
};
const bodies = {
thorough:
"1. Frame the problem precisely, noting the constraints that actually matter here.\n" +
"2. Sketch the trade-offs: latency vs. accuracy, memory vs. throughput, simplicity vs. flexibility.\n" +
"3. Recommend a concrete path and call out the one failure mode most teams miss.\n\n" +
"In short: start with the simplest design that satisfies the constraints, measure, then optimize the hot path only once real numbers point at it.",
concise:
"Keep it simple: pick the option with the fewest moving parts, verify it against one real example, and only add complexity when a measurement forces you to.\n\n" +
"The common mistake is optimizing before you have data — resist it.",
};
return (openers[model.id] || "") + (bodies[tone] || bodies.concise);
}
/* ---------------- Column state ---------------- */
const state = {
columns: [], // { modelId, node, els, run }
syncScroll: false,
scores: Object.create(null), // modelId -> count
syncGuard: false,
};
const DEFAULT_COLS = ["aurora-max", "meridian-4", "corvus-r1"];
/* ---------------- DOM refs ---------------- */
const $ = (sel, root = document) => root.querySelector(sel);
const grid = $("#grid");
const promptInput = $("#promptInput");
const compareBtn = $("#compareBtn");
const clearBtn = $("#clearBtn");
const syncScrollEl = $("#syncScroll");
const rollupEl = $("#rollup");
const suggestRow = $("#suggestRow");
const scoreChips = $("#scoreChips");
/* ---------------- Build UI ---------------- */
function modelById(id) {
return MODELS.find((m) => m.id === id) || MODELS[0];
}
function optionMarkup(selectedId) {
return MODELS.map(
(m) =>
`<option value="${m.id}" ${m.id === selectedId ? "selected" : ""}>${m.name} · ${m.vendor}</option>`
).join("");
}
function createColumn(modelId, index) {
const model = modelById(modelId);
const col = document.createElement("article");
col.className = "col";
col.setAttribute("role", "group");
col.setAttribute("aria-label", `${model.name} response`);
col.innerHTML = `
<div class="col__head">
<span class="col__avatar" aria-hidden="true"></span>
<div class="col__pick">
<select class="col__select" aria-label="Choose model for column ${index + 1}">
${optionMarkup(modelId)}
</select>
<div class="col__vendor"></div>
</div>
<span class="status" role="status">
<span class="status__dot"></span><span class="status__text">Ready</span>
</span>
</div>
<div class="col__body">
<div class="col__answer is-empty" aria-live="polite">Answer will stream here after you hit Compare.</div>
</div>
<div class="badges" hidden>
<span class="badge badge--latency" title="Wall-clock time to complete">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>
<b>latency</b><span class="badge__val" data-k="latency">—</span>
</span>
<span class="badge badge--tokens" title="Output tokens generated">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M4 12h16M4 17h10"/></svg>
<b>tokens</b><span class="badge__val" data-k="tokens">—</span>
</span>
<span class="badge badge--cost" title="Estimated output cost (USD)">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18M8 8a3 3 0 0 1 3-3h2a3 3 0 0 1 0 6h-2a3 3 0 0 0 0 6h2a3 3 0 0 0 3-3"/></svg>
<b>cost</b><span class="badge__val" data-k="cost">—</span>
</span>
</div>
<div class="col__foot">
<button type="button" class="btn btn--sm btn--win">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 21h8M12 17v4M7 4h10v5a5 5 0 0 1-10 0zM7 6H4v1a3 3 0 0 0 3 3M17 6h3v1a3 3 0 0 1-3 3"/></svg>
Pick winner
</button>
<button type="button" class="btn btn--sm btn--ghost btn--regen" title="Regenerate this column" aria-label="Regenerate this column" disabled>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7L21 8M21 3v5h-5"/></svg>
</button>
</div>
`;
const els = {
avatar: $(".col__avatar", col),
select: $(".col__select", col),
vendor: $(".col__vendor", col),
status: $(".status", col),
statusText: $(".status__text", col),
body: $(".col__body", col),
answer: $(".col__answer", col),
badges: $(".badges", col),
winBtn: $(".btn--win", col),
regenBtn: $(".btn--regen", col),
val: {
latency: $('[data-k="latency"]', col),
tokens: $('[data-k="tokens"]', col),
cost: $('[data-k="cost"]', col),
},
};
const entry = { modelId, node: col, els, run: null };
applyModel(entry);
els.select.addEventListener("change", () => {
entry.modelId = els.select.value;
applyModel(entry);
resetColumn(entry);
});
els.winBtn.addEventListener("click", () => pickWinner(entry));
els.regenBtn.addEventListener("click", () => {
const prompt = promptInput.value.trim();
if (prompt) streamColumn(entry, prompt);
});
els.body.addEventListener("scroll", () => onColumnScroll(entry));
state.columns.push(entry);
grid.appendChild(col);
return entry;
}
function applyModel(entry) {
const m = modelById(entry.modelId);
entry.els.avatar.style.background = m.color;
entry.els.avatar.textContent = m.name.slice(0, 1);
entry.els.vendor.textContent = `${m.vendor} · $${m.cost.toFixed(4)}/1K out`;
}
function setStatus(entry, kind, text) {
const s = entry.els.status;
s.classList.remove("is-live", "is-done");
if (kind) s.classList.add(kind);
entry.els.statusText.textContent = text;
}
function resetColumn(entry) {
if (entry.run) { cancelAnimationFrame(entry.run.raf); entry.run = null; }
entry.node.classList.remove("is-winner", "is-streaming");
entry.els.winBtn.classList.remove("is-active");
entry.els.winBtn.disabled = false;
entry.els.regenBtn.disabled = true;
entry.els.badges.hidden = true;
entry.els.answer.textContent = "Answer will stream here after you hit Compare.";
entry.els.answer.classList.add("is-empty");
setStatus(entry, "", "Ready");
}
/* ---------------- Streaming ---------------- */
function fmtLatency(ms) {
return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(2)}s`;
}
/** Rough token estimate: ~4 chars per token. */
function estTokens(text) {
return Math.max(1, Math.round(text.length / 4));
}
function streamColumn(entry, prompt) {
if (entry.run) cancelAnimationFrame(entry.run.raf);
const model = modelById(entry.modelId);
const full = buildAnswer(prompt, model);
const start = performance.now();
// per-model pacing: faster models emit larger chunks more often
const baseDelay = 46 / model.speed; // ms between emissions
const chunkSize = Math.max(2, Math.round(3 * model.speed));
// simulated "time to first token"
const ttft = 180 + Math.random() * 420 / model.speed;
entry.node.classList.add("is-streaming");
entry.node.classList.remove("is-winner");
entry.els.winBtn.classList.remove("is-active");
entry.els.badges.hidden = false;
entry.els.regenBtn.disabled = true;
entry.els.answer.classList.remove("is-empty");
entry.els.answer.textContent = "";
setStatus(entry, "is-live", "0ms");
const run = { raf: 0, i: 0, lastEmit: start, started: false, done: false };
entry.run = run;
function frame(now) {
if (entry.run !== run) return; // superseded
const elapsed = now - start;
// live latency tick
entry.els.statusText.textContent = fmtLatency(elapsed);
if (!run.started) {
if (elapsed < ttft) { run.raf = requestAnimationFrame(frame); return; }
run.started = true;
run.lastEmit = now;
}
if (now - run.lastEmit >= baseDelay && run.i < full.length) {
run.i = Math.min(full.length, run.i + chunkSize);
renderPartial(entry, full.slice(0, run.i));
run.lastEmit = now;
if (state.syncScroll) syncFromLeader(entry);
}
if (run.i >= full.length) {
finishColumn(entry, full, elapsed, model);
return;
}
run.raf = requestAnimationFrame(frame);
}
run.raf = requestAnimationFrame(frame);
}
function renderPartial(entry, text) {
entry.els.answer.textContent = text;
const caret = document.createElement("span");
caret.className = "caret";
caret.setAttribute("aria-hidden", "true");
entry.els.answer.appendChild(caret);
// autoscroll the streaming column to bottom unless user scrolled up
const b = entry.els.body;
if (!state.syncScroll) b.scrollTop = b.scrollHeight;
}
function finishColumn(entry, full, elapsed, model) {
entry.run = null;
entry.node.classList.remove("is-streaming");
entry.els.answer.textContent = full; // drop caret
entry.els.regenBtn.disabled = false;
const tokens = estTokens(full);
const cost = (tokens / 1000) * model.cost;
entry.els.val.latency.textContent = fmtLatency(elapsed);
entry.els.val.tokens.textContent = String(tokens);
entry.els.val.cost.textContent = "$" + cost.toFixed(4);
entry._metrics = { latency: elapsed, tokens, cost };
setStatus(entry, "is-done", "Done");
updateRollup();
}
/* ---------------- Compare / Clear ---------------- */
function runCompare() {
const prompt = promptInput.value.trim();
if (!prompt) {
promptInput.focus();
promptInput.classList.remove("shake");
void promptInput.offsetWidth;
return;
}
compareBtn.disabled = true;
state.columns.forEach((entry) => {
entry.node.classList.remove("is-winner");
entry.els.winBtn.classList.remove("is-active");
entry.els.winBtn.disabled = false;
streamColumn(entry, prompt);
});
// re-enable compare shortly (columns manage their own lifecycle)
setTimeout(() => { compareBtn.disabled = false; }, 400);
updateRollup();
}
function clearAll() {
promptInput.value = "";
state.columns.forEach(resetColumn);
rollupEl.textContent = "Idle · no run yet";
promptInput.focus();
}
/* ---------------- Winner / scoreboard ---------------- */
function pickWinner(winner) {
const anyDone = state.columns.some((c) => c._metrics);
if (!anyDone) return;
state.columns.forEach((c) => {
const isWin = c === winner;
c.node.classList.toggle("is-winner", isWin);
c.els.winBtn.classList.toggle("is-active", isWin);
c.els.winBtn.querySelector("svg") &&
(c.els.winBtn.lastChild.textContent = ""); // no-op guard
});
winner.els.winBtn.childNodes.forEach(() => {});
// update label text of the winning button
state.columns.forEach((c) => {
const label = c === winner ? "Winner" : "Pick winner";
// keep the icon, replace trailing text node
const btn = c.els.winBtn;
const textNode = [...btn.childNodes].find((n) => n.nodeType === 3 && n.textContent.trim());
if (textNode) textNode.textContent = " " + label;
});
const id = winner.modelId;
state.scores[id] = (state.scores[id] || 0) + 1;
renderScoreboard();
}
function renderScoreboard() {
const ids = Object.keys(state.scores).sort((a, b) => state.scores[b] - state.scores[a]);
if (!ids.length) {
scoreChips.innerHTML = '<span class="score-chip" style="color:var(--muted)">no votes yet</span>';
return;
}
scoreChips.innerHTML = ids
.map((id) => {
const m = modelById(id);
return `<span class="score-chip"><span class="score-chip__dot" style="background:${m.color}"></span>${m.name}<span class="score-chip__n">${state.scores[id]}</span></span>`;
})
.join("");
}
/* ---------------- Roll-up ---------------- */
function updateRollup() {
const done = state.columns.filter((c) => c._metrics);
if (!done.length) {
rollupEl.textContent = state.columns.some((c) => c.run)
? "Streaming…"
: "Idle · no run yet";
return;
}
const totalTokens = done.reduce((s, c) => s + c._metrics.tokens, 0);
const totalCost = done.reduce((s, c) => s + c._metrics.cost, 0);
const fastest = done.reduce((a, b) => (a._metrics.latency < b._metrics.latency ? a : b));
rollupEl.textContent =
`${done.length}/${state.columns.length} done · ${totalTokens} tok · $${totalCost.toFixed(4)} · fastest ${modelById(fastest.modelId).name}`;
}
/* ---------------- Sync scroll ---------------- */
function onColumnScroll(leader) {
if (!state.syncScroll || state.syncGuard) return;
syncFromLeader(leader);
}
function syncFromLeader(leader) {
const lb = leader.els.body;
const denom = lb.scrollHeight - lb.clientHeight;
const ratio = denom > 0 ? lb.scrollTop / denom : 0;
state.syncGuard = true;
for (const c of state.columns) {
if (c === leader) continue;
const b = c.els.body;
const d = b.scrollHeight - b.clientHeight;
b.scrollTop = ratio * d;
}
// release guard on next frame to avoid feedback loop
requestAnimationFrame(() => { state.syncGuard = false; });
}
/* ---------------- Suggestions ---------------- */
function renderSuggestions() {
suggestRow.innerHTML = "";
SUGGESTIONS.forEach((text) => {
const b = document.createElement("button");
b.type = "button";
b.className = "suggest";
b.textContent = text;
b.addEventListener("click", () => {
promptInput.value = text;
promptInput.focus();
runCompare();
});
suggestRow.appendChild(b);
});
}
/* ---------------- Init ---------------- */
function init() {
DEFAULT_COLS.forEach((id, i) => createColumn(id, i));
renderSuggestions();
renderScoreboard();
compareBtn.addEventListener("click", runCompare);
clearBtn.addEventListener("click", clearAll);
syncScrollEl.addEventListener("change", () => {
state.syncScroll = syncScrollEl.checked;
});
promptInput.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
runCompare();
}
});
promptInput.value = SUGGESTIONS[0];
}
init();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Model Comparison Panel</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="app" aria-labelledby="app-title">
<header class="app__head">
<div class="app__brand">
<span class="app__logo" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 3v18M15 3v18M3 9h18M3 15h18" />
</svg>
</span>
<div>
<h1 id="app-title">Model Comparison Panel</h1>
<p class="app__sub">One prompt, streamed across models. Compare latency, tokens & cost, then pick a winner.</p>
</div>
</div>
<div class="scoreboard" id="scoreboard" aria-live="polite" aria-label="Winner tally">
<span class="scoreboard__label">Winners</span>
<div class="scoreboard__chips" id="scoreChips"></div>
</div>
</header>
<section class="prompt" aria-label="Prompt">
<label class="prompt__label" for="promptInput">Prompt</label>
<div class="prompt__box">
<textarea id="promptInput" class="prompt__input" rows="2"
placeholder="Ask something once — every model answers it…"></textarea>
<div class="prompt__actions">
<button type="button" class="btn btn--primary" id="compareBtn">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
Compare
</button>
<button type="button" class="btn btn--ghost" id="clearBtn">Clear</button>
</div>
</div>
<div class="prompt__suggest" id="suggestRow" aria-label="Prompt suggestions"></div>
</section>
<section class="toolbar" aria-label="Comparison controls">
<label class="toggle">
<input type="checkbox" id="syncScroll" />
<span class="toggle__track" aria-hidden="true"><span class="toggle__thumb"></span></span>
<span class="toggle__text">Sync scroll</span>
</label>
<div class="toolbar__meta" id="rollup" aria-live="polite">Idle · no run yet</div>
</section>
<section class="grid" id="grid" aria-label="Model columns"></section>
<footer class="app__foot">
<span>Simulated streaming — no network calls. Timing via <code>performance.now()</code>, chunks via <code>requestAnimationFrame</code>.</span>
</footer>
</main>
<script src="script.js"></script>
</body>
</html>The Model Comparison Panel is an evaluation surface for putting language models head to head. Type a single prompt, hit Compare, and each model column streams its own answer token by token while a status pill counts up latency in real time. When a column finishes it settles into badges showing total latency, tokens produced, and an estimated cost derived from a per-model price, so you can weigh quality against speed and spend at a glance.
Every column exposes a model picker so you can swap between a curated roster of frontier and small models, a Pick winner button that records your vote and updates a persistent scoreboard, and a per-column regenerate control. A global Sync scroll toggle links the answer bodies together — scrolling one drives the others proportionally, which is invaluable when comparing two long completions line by line. Prompt suggestions, a clear-all reset, and a live token/cost roll-up round out the workflow.
Under the hood the streaming is simulated with requestAnimationFrame-driven chunk scheduling and performance.now() for accurate elapsed timing, while the synced scrolling demonstrates coordinated scroll event handling with a re-entrancy guard to avoid feedback loops. Everything is vanilla JavaScript and CSS with no dependencies, keyboard-operable controls, ARIA live regions for the streaming answers, and a responsive layout that collapses the columns into a single scrollable stack below 520px.