← 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}`);