← back to HERMES
demo/render.mjs
97 lines
#!/usr/bin/env node
// Render a side-by-side HTML page from headtohead output JSON.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const OUT = path.join(__dirname, 'output');
const hermes = JSON.parse(fs.readFileSync(path.join(OUT, 'hermes3_8b.json'), 'utf8'));
const qwen = JSON.parse(fs.readFileSync(path.join(OUT, 'qwen3_14b.json'), 'utf8'));
const card = (c) => `
<article class="concept" style="--hero:${c.hero_hex || '#888'}">
<div class="hero" style="background:${c.hero_hex || '#888'}">
<div class="palette">
${(c.palette || []).map(h => `<span style="background:${h}" title="${h}"></span>`).join('')}
</div>
<div class="hex">${c.hero_hex || '—'}</div>
</div>
<div class="meta">
<h3>${c.name || '—'} <span class="tier">${c.tier || ''}</span></h3>
<p class="tag">"${c.tagline || ''}"</p>
<p class="motif">${c.motif || ''}</p>
<p class="room"><span>Best for:</span> ${c.room || ''}</p>
</div>
</article>`;
const column = (label, run) => {
const concepts = run.data?.concepts || [];
return `
<section class="col">
<header class="col-head">
<h2>${label}</h2>
<div class="stats">
<span class="stat"><b>${run.latency_ms}</b>ms</span>
<span class="stat"><b>${run.tokens_out ?? '—'}</b> tok out</span>
<span class="stat"><b>${run.valid_concepts}/${run.total_concepts}</b> strict-valid</span>
<span class="stat ${run.parsed_ok ? 'ok' : 'bad'}">${run.parsed_ok ? 'JSON ✓' : 'JSON ✗'}</span>
</div>
</header>
<div class="grid">${concepts.map(card).join('')}</div>
</section>`;
};
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>HERMES vs QWEN3 — Costa Brava brief</title>
<style>
:root { --bg:#0e0e10; --panel:#15151a; --ink:#f5f0e8; --mute:#9b9591; --rule:#272528; --gold:#c9a96a; }
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 -apple-system,'SF Pro Text',sans-serif;padding:32px}
h1{font:300 36px/1.1 'SF Pro Display',serif;letter-spacing:.02em;margin:0 0 4px}
h1 b{color:var(--gold);font-weight:500}
.lead{color:var(--mute);margin:0 0 28px;max-width:800px}
.lead code{background:#1c1c20;padding:2px 6px;border-radius:3px;color:var(--gold);font-size:12px}
.cols{display:grid;grid-template-columns:1fr 1fr;gap:24px}
.col{background:var(--panel);border:1px solid var(--rule);border-radius:6px;padding:20px}
.col-head{display:flex;justify-content:space-between;align-items:baseline;border-bottom:1px solid var(--rule);padding-bottom:14px;margin-bottom:18px;gap:12px;flex-wrap:wrap}
.col-head h2{margin:0;font:500 20px 'SF Pro Display',sans-serif;letter-spacing:.04em}
.stats{display:flex;gap:14px;font-size:11px;color:var(--mute);text-transform:uppercase;letter-spacing:.08em}
.stat b{color:var(--ink);font-weight:600;font-size:13px;letter-spacing:0;text-transform:none}
.stat.ok b{color:#7ad27a}
.stat.bad b{color:#e26d6d}
.grid{display:flex;flex-direction:column;gap:14px}
.concept{display:grid;grid-template-columns:160px 1fr;border:1px solid var(--rule);border-radius:4px;overflow:hidden}
.hero{position:relative;aspect-ratio:1;display:flex;flex-direction:column;justify-content:flex-end;padding:10px}
.palette{display:flex;gap:0;margin-bottom:auto;padding-top:6px}
.palette span{flex:1;height:18px;display:block;border:1px solid rgba(0,0,0,.15)}
.hex{font:600 11px/1 'SF Mono',monospace;letter-spacing:.05em;color:rgba(0,0,0,.65);background:rgba(255,255,255,.85);padding:3px 6px;border-radius:2px;align-self:flex-start}
.meta{padding:14px 16px;display:flex;flex-direction:column;gap:6px}
.meta h3{margin:0;font:500 16px 'SF Pro Display',sans-serif;letter-spacing:.02em}
.tier{display:inline-block;font:500 9px/1 'SF Pro Text';letter-spacing:.12em;text-transform:uppercase;color:var(--gold);border:1px solid var(--gold);padding:3px 6px;border-radius:2px;margin-left:6px;vertical-align:1px}
.tag{margin:2px 0 0;font-style:italic;color:var(--ink)}
.motif{margin:4px 0 0;color:var(--mute);font-size:13px}
.room{margin:2px 0 0;font-size:11px;color:var(--mute);text-transform:uppercase;letter-spacing:.08em}
.room span{color:var(--gold)}
footer{margin-top:28px;padding-top:18px;border-top:1px solid var(--rule);color:var(--mute);font-size:12px}
</style>
</head>
<body>
<h1>Costa Brava — <b>HERMES vs QWEN3</b></h1>
<p class="lead">Same brief, same JSON schema, both models running locally on Mac2 Ollama. Strict-valid means the concept passed the entire schema gate (hex format, palette length, tagline ≤8 words, tier in {Studio, Atelier, Bespoke}). Run with <code>node demo/headtohead.mjs && node demo/render.mjs</code>.</p>
<div class="cols">
${column('Hermes-3-Llama-3.1-8B', hermes)}
${column('Qwen3:14b', qwen)}
</div>
<footer>Generated ${new Date().toISOString()} · ~/Projects/HERMES/demo · prompt + raw outputs in demo/output/</footer>
</body>
</html>`;
const outPath = path.join(OUT, 'index.html');
fs.writeFileSync(outPath, html);
console.log(`✓ wrote ${outPath}`);