cka-sim/web/index.html
vumast3r 86bf245c19 style: redesign exam web UI with a dark developer theme
The UI was light everywhere except the embedded terminal, which felt
disjointed. Switch to a cohesive dark palette with layered surfaces,
tinted state colors for pass/fail/warn/flag, and polish on buttons,
cards, overlays, and the question nav (hover/active states, shadows,
custom scrollbars).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 10:24:38 -06:00

491 lines
33 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Exam simulator</title>
<style>
:root {
--bg: #0a0c10; --bg-alt: #10131a; --pane: #14171f; --pane-2: #1a1e28;
--ink: #e8ebf1; --muted: #8891a3; --line: #232833; --line-soft: #1c212b;
--accent: #5b8cff; --accent-hover: #7aa2ff; --accent-ink: #ffffff;
--warn: #eab34a; --danger: #f2555f; --ok: #35d48f; --flag: #eab34a;
--warn-soft: rgba(234,179,74,.14); --danger-soft: rgba(242,85,95,.14);
--ok-soft: rgba(53,212,143,.14); --accent-soft: rgba(91,140,255,.14); --flag-soft: rgba(234,179,74,.16);
--term: #0a0c10; --shadow: 0 12px 32px rgba(0,0,0,.45);
--sans: "IBM Plex Sans", "Segoe UI", system-ui, -apple-system, sans-serif;
--mono: "IBM Plex Mono", "JetBrains Mono", ui-monospace, Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body { font: 14px/1.55 var(--sans); color: var(--ink); background: var(--bg); }
button { font: inherit; cursor: pointer; }
a { color: var(--accent); }
code { font-family: var(--mono); font-size: .92em; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
::selection { background: var(--accent-soft); color: var(--ink); }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--line); border-radius: 6px; border: 2px solid transparent; background-clip: padding-box; }
::-webkit-scrollbar-thumb:hover { background: #2c3341; background-clip: padding-box; }
* { scrollbar-color: var(--line) transparent; scrollbar-width: thin; }
/* ---- top bar: exam name left, clock right, controls far right ---- */
header { height: 52px; display: flex; align-items: center; gap: 16px; padding: 0 16px;
background: var(--bg-alt); color: var(--ink); border-bottom: 1px solid var(--line); }
header .name { font-weight: 600; letter-spacing: .01em; }
header .mode { color: var(--muted); font-size: 13px; }
header .spacer { flex: 1; }
#clock { font-family: var(--mono); font-size: 20px; letter-spacing: .04em; font-variant-numeric: tabular-nums; color: var(--ink); }
#clock.warn { color: var(--warn); } #clock.danger { color: var(--danger); animation: pulse 1.4s ease-in-out infinite; } #clock.paused { color: var(--muted); }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .55; } }
.btn { border: 1px solid var(--line); background: var(--pane-2); color: var(--ink); border-radius: 6px; padding: 7px 14px;
transition: border-color .15s, background .15s, transform .1s; }
.btn:hover:not(:disabled) { border-color: #333a48; background: #1f2430; }
.btn:active:not(:disabled) { transform: translateY(1px); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); font-weight: 600; }
.btn.primary:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn.danger { background: var(--danger); border-color: var(--danger); color: #fff; }
.btn.danger:hover:not(:disabled) { filter: brightness(1.1); }
.btn.ghost { background: transparent; color: var(--ink); border-color: var(--line); }
.btn.ghost:hover:not(:disabled) { background: var(--pane-2); }
.btn:disabled { opacity: .4; cursor: default; }
/* ---- main split ---- */
main { height: calc(100% - 52px); display: grid; grid-template-columns: 56px 440px 6px 1fr; }
#qnav { background: var(--bg-alt); border-right: 1px solid var(--line); overflow-y: auto; padding: 10px 0; }
#qnav button { display: block; width: 38px; height: 32px; margin: 4px auto; border: 1px solid var(--line); border-radius: 6px;
background: var(--pane); color: var(--ink); position: relative; font-variant-numeric: tabular-nums;
transition: border-color .12s, transform .08s, background .12s; }
#qnav button:hover { border-color: var(--accent); transform: translateY(-1px); }
#qnav button.current { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent), 0 0 12px var(--accent-soft); font-weight: 600; color: var(--accent-hover); }
#qnav button.flagged::after { content: ""; position: absolute; top: -4px; right: -4px; width: 10px; height: 10px;
background: var(--flag); border-radius: 50%; border: 2px solid var(--bg-alt); }
#qnav button.pass { background: var(--ok-soft); border-color: var(--ok); color: var(--ok); }
#qnav button.partial { background: var(--warn-soft); border-color: var(--warn); color: var(--warn); }
#qnav button.fail { background: var(--danger-soft); border-color: var(--danger); color: var(--danger); }
#task { background: var(--pane); display: flex; flex-direction: column; min-height: 0; }
#task .head { padding: 16px 20px 12px; border-bottom: 1px solid var(--line); }
#task .head .sub { color: var(--muted); font-size: 12.5px; text-transform: uppercase; letter-spacing: .05em; }
#task h2 { margin: 4px 0 0; font-size: 18px; font-weight: 600; letter-spacing: -.01em; }
#task .body { flex: 1; overflow-y: auto; padding: 16px 20px; }
.prose p { margin: 0 0 12px; white-space: pre-wrap; }
.prose code { background: var(--pane-2); padding: 1px 6px; border-radius: 4px; cursor: copy; border: 1px solid var(--line); color: #c9d4f2; }
.prose code:hover { background: var(--accent-soft); border-color: var(--accent); }
.prose code.copied { background: var(--ok-soft); border-color: var(--ok); color: var(--ok); }
#task .foot { border-top: 1px solid var(--line); padding: 12px 20px; display: flex; gap: 8px; align-items: center; }
#task .foot .spacer { flex: 1; }
.btn.flag.on { background: var(--flag-soft); border-color: var(--flag); color: var(--flag); }
#checks { margin-top: 14px; background: var(--pane-2); border: 1px solid var(--line); border-radius: 6px; padding: 12px 14px; font-size: 13px; }
#checks div { padding: 3px 0; }
#solution { margin-top: 14px; border: 1px solid var(--line); border-radius: 6px; padding: 10px 14px; font-size: 13px; background: var(--pane-2); }
#solution summary { cursor: pointer; font-weight: 600; color: var(--ink); }
#solution pre { margin: 8px 0 0; white-space: pre-wrap; font: 12.5px/1.55 var(--mono); background: var(--bg-alt); border: 1px solid var(--line); padding: 12px; border-radius: 6px; color: #c9d4f2; }
#checks .ok::before { content: "✓ "; color: var(--ok); } #checks .no::before { content: "✗ "; color: var(--danger); }
#notes { width: 100%; height: 120px; border: 0; border-top: 1px solid var(--line); resize: none; padding: 12px 20px;
font: 13px/1.5 var(--mono); background: var(--bg-alt); color: var(--ink); }
#notes::placeholder { color: var(--muted); }
#handle { background: var(--line); cursor: col-resize; transition: background .15s; }
#handle:hover { background: var(--accent); }
#termwrap { background: var(--term); position: relative; min-width: 0; }
#term { width: 100%; height: 100%; border: 0; display: block; }
#noterm { color: var(--muted); padding: 32px; font-family: var(--mono); font-size: 13px; line-height: 1.7; max-width: 70ch; }
#noterm code { color: var(--warn); }
/* ---- overlays: start, confirm, results ---- */
.overlay { position: fixed; inset: 0; background: rgba(4,5,8,.7); backdrop-filter: blur(2px); display: flex; align-items: center; justify-content: center; }
.card { position: relative; background: var(--pane); border: 1px solid var(--line); box-shadow: var(--shadow); border-radius: 10px;
padding: 28px 30px; width: min(640px, 92vw); max-height: 88vh; overflow-y: auto; }
.card h1 { margin: 0 0 4px; font-size: 20px; letter-spacing: -.01em; }
.card .sub { color: var(--muted); margin-bottom: 18px; }
.card .row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; margin-top: 16px; }
.opts { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.opt { border: 1px solid var(--line); border-radius: 8px; padding: 15px; text-align: left; background: var(--pane-2);
transition: border-color .15s, transform .12s, box-shadow .15s; }
.opt:hover { border-color: var(--accent); transform: translateY(-1px); box-shadow: 0 6px 18px rgba(0,0,0,.35); }
.opt b { display: block; margin-bottom: 4px; color: var(--ink); }
.opt span { color: var(--muted); font-size: 13px; }
.picker { columns: 2; margin: 10px 0 0; font-size: 13px; }
.picker label { display: block; padding: 3px 0; }
pre.log { background: var(--bg-alt); border: 1px solid var(--line); padding: 12px; font: 12px/1.5 var(--mono); max-height: 240px; overflow: auto; border-radius: 6px; color: var(--muted); }
.score { font-size: 36px; font-family: var(--mono); font-weight: 600; }
.score small { font-size: 14px; color: var(--muted); font-family: var(--sans); font-weight: 400; }
.verdict { display: inline-block; padding: 3px 12px; border-radius: 100px; color: #fff; margin-left: 10px; font-size: 13px; font-weight: 600; vertical-align: middle; }
.verdict.pass { background: var(--ok); } .verdict.fail { background: var(--danger); }
.bar { display: grid; grid-template-columns: 70px 1fr 60px; gap: 10px; align-items: center; margin: 8px 0; font-size: 13px; }
.bar .track { height: 8px; background: var(--pane-2); border: 1px solid var(--line); border-radius: 5px; overflow: hidden; }
.bar .fill { height: 100%; background: var(--accent); border-radius: 5px; }
.qres { margin-top: 16px; }
.qres details { border-top: 1px solid var(--line); padding: 8px 0; }
.qres summary { cursor: pointer; }
.qres summary .pts { float: right; font-family: var(--mono); }
.qres .t-pass summary .pts { color: var(--ok); } .qres .t-fail summary .pts { color: var(--danger); } .qres .t-partial summary .pts { color: var(--warn); }
.hidden { display: none !important; }
.pill { font-size:12px; padding:3px 10px; border-radius:100px; border:1px solid var(--line); color:var(--muted); background: var(--pane-2); transition: border-color .12s, color .12s; }
.pill.sel { background:var(--accent); border-color:var(--accent); color:#fff; }
.pill.pick { cursor:pointer; }
.pill.pick:hover { border-color:var(--accent); color:var(--accent-hover); }
.pill.done.pick:hover { color:var(--ok); }
.pill.done { background:var(--ok-soft); border-color:var(--ok); color:var(--ok); }
.hardtag { font-size:11px; background:var(--danger); color:#fff; border-radius:3px; padding:1px 5px; margin-left:6px; vertical-align:middle; }
@media (prefers-reduced-motion: no-preference) { .prose code { transition: background .15s, border-color .15s; } }
@media (max-width: 900px) { main { grid-template-columns: 56px 1fr; } #handle, #termwrap { display: none; } }
</style>
</head>
<body>
<header>
<span class="name" id="trackName">Exam simulator</span>
<span class="mode" id="modeLabel"></span>
<span class="spacer"></span>
<span id="clock">--:--:--</span>
<button class="btn ghost" id="btnPause">Pause</button>
<button class="btn ghost" id="btnHistory">History</button>
<button class="btn danger" id="btnEnd">Submit exam</button>
</header>
<main>
<nav id="qnav" aria-label="Questions"></nav>
<section id="task">
<div class="head">
<div class="sub" id="qMeta"></div>
<h2 id="qTitle"></h2>
</div>
<div class="body">
<div class="prose" id="qBody"></div>
<div id="checks" class="hidden"></div>
<details id="solution" class="hidden"><summary>Reference solution</summary><pre id="solutionBody"></pre></details>
</div>
<div class="foot">
<button class="btn" id="btnPrev">Previous</button>
<button class="btn" id="btnNext">Next</button>
<span class="spacer"></span>
<button class="btn" id="btnCheck">Check this task</button>
<button class="btn flag" id="btnFlag">Flag for review</button>
</div>
<textarea id="notes" placeholder="Notepad — kept with the attempt, not graded."></textarea>
</section>
<div id="handle" role="separator" aria-orientation="vertical"></div>
<section id="termwrap"></section>
</main>
<!-- start / lobby -->
<div class="overlay" id="lobby">
<div class="card">
<h1 id="lobbyTitle">Exam simulator</h1>
<div class="sub" id="lobbySub"></div>
<div id="ver" style="position:absolute;right:28px;top:30px;color:var(--muted);font-size:12px"></div>
<div id="trackPick" style="display:flex;gap:6px;margin:0 0 14px"></div>
<div style="color:var(--muted);font-size:12px;margin:-8px 0 4px">Drill a domain (✓ = drilled at least once):</div>
<div id="coverage" style="display:flex;gap:6px;margin:0 0 14px;flex-wrap:wrap"></div>
<label style="display:block;margin-bottom:14px"><input type="checkbox" id="hardMode"> <b>Hard mode</b>
<span style="color:var(--muted)"> — killer.sh-style: more tasks in the same time, no hints, traps, exact-output tasks, compound failures, collateral checks, exam contexts.</span></label>
<div class="opts" id="lobbyOpts">
<button class="opt" id="optStudy"><b>Study mode</b><span>5 tasks, no clock, reference solutions visible from the start. Nothing scored or recorded.</span></button>
<button class="opt" id="optNext"><b>Drill next domain</b><span id="nextDesc"></span></button>
<button class="opt" id="optAll"><b>Drill the whole bank</b><span id="allDesc"></span></button>
<button class="opt" id="optDrill"><b>Drill: weak / pick</b><span>Tasks you're weak on, or choose your own. Feedback per task while you work.</span></button>
<button class="opt" id="optSimA"><b>Simulator Set A</b><span id="simADesc"></span></button>
<button class="opt" id="optSimB"><b>Simulator Set B</b><span id="simBDesc"></span></button>
<button class="opt" id="optExam" style="grid-column:1/3"><b>Full exam</b><span id="examDesc"></span></button>
</div>
<div id="gate" class="hidden" style="color:var(--warn);margin-top:10px;font-size:13px"></div>
<div id="drillPick" class="hidden">
<div class="sub" style="margin-top:14px">Pick tasks to drill. Pre-selected: weak or never attempted.</div>
<div class="picker" id="picker"></div>
<div class="row"><button class="btn primary" id="btnStartDrill">Start drill</button><button class="btn" id="btnBackDrill">Back</button></div>
</div>
<div id="seeding" class="hidden">
<div class="sub">Seeding scenarios on the cluster…</div>
<pre class="log" id="seedLog"></pre>
</div>
<div id="lobbyErr" class="hidden" style="color:var(--danger);margin-top:12px;white-space:pre-wrap"></div>
</div>
</div>
<!-- confirm submit -->
<div class="overlay hidden" id="confirm">
<div class="card">
<h1>Submit and end the session?</h1>
<div class="sub">Every task is graded against the cluster as it is right now. You can't return after submitting.</div>
<div id="confirmFlags"></div>
<div class="row"><button class="btn danger" id="btnConfirmEnd">Submit</button><button class="btn" id="btnCancelEnd">Keep working</button></div>
</div>
</div>
<!-- results -->
<div class="overlay hidden" id="results">
<div class="card">
<h1>Results</h1>
<div class="sub" id="resSub"></div>
<div><span class="score" id="resScore"></span><span class="verdict" id="resVerdict"></span></div>
<div id="resDomains" style="margin-top:14px"></div>
<div class="qres" id="resQuestions"></div>
<div class="row">
<button class="btn primary" id="btnDrillMissed">Drill what I missed</button>
<button class="btn" id="btnNewExam">New full exam</button>
<button class="btn" id="btnCloseRes">Review tasks</button>
</div>
</div>
</div>
<!-- history -->
<div class="overlay hidden" id="histOv">
<div class="card">
<h1>Attempt history</h1>
<div class="sub" id="histSub"></div>
<div id="histBody"></div>
<div class="row"><button class="btn" id="btnCloseHist">Close</button></div>
</div>
</div>
<script>
(() => {
const $ = s => document.querySelector(s);
let S = null, cur = null, skew = 0, clockT = null, autoSubmitted = false;
const api = (p, body) => fetch(p, body ? {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)} : {})
.then(async r => { const j = await r.json(); if (!r.ok) throw new Error(j.error || r.statusText); return j; });
const fmt = s => { s = Math.max(0, Math.floor(s)); return [s/3600, s%3600/60, s%60].map(n => String(Math.floor(n)).padStart(2,'0')).join(':'); };
const esc = s => s.replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
// minimal markdown: paragraphs, **bold**, `code` (click to copy). Lists stay as literal lines.
function render(md) {
return md.split(/\n\s*\n/).map(p => '<p>' + esc(p).replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
.replace(/`([^`]+)`/g, (m, c) => `<code title="click to copy">${c}</code>`) + '</p>').join('');
}
function qResultClass(q) {
const r = S.session && S.session.results[q.id];
if (!r) return '';
return r.pass ? 'pass' : (r.earned ? 'partial' : 'fail');
}
function paint() {
const sess = S.session, live = sess && !sess.ended_at;
$('#trackName').textContent = S.track.name;
$('#modeLabel').textContent = sess ? (sess.untimed ? 'Study' : (sess.mode === 'drill' ? 'Drill' : 'Exam')) + (sess.hard ? ' · HARD' : '') : '';
$('#btnEnd').classList.toggle('hidden', !live);
if (live && sess.untimed) $('#btnEnd').textContent = 'End study session';
$('#btnPause').classList.toggle('hidden', !live || sess.untimed);
if (live) $('#btnPause').textContent = sess.paused ? 'Resume' : 'Pause';
$('#btnCheck').classList.toggle('hidden', !(live && sess.mode === 'drill'));
$('#lobby').classList.toggle('hidden', !!live || (sess && sess.ended_at && !$('#results').classList.contains('hidden')));
// question nav
const nav = $('#qnav'); nav.innerHTML = '';
S.questions.forEach(q => {
const b = document.createElement('button'); b.textContent = q.id;
b.title = `${S.questions.indexOf(q) + 1} of ${S.questions.length} — Q${q.id}: ${q.title}`;
b.className = [q.id === cur ? 'current' : '', sess && sess.flags.includes(q.id) ? 'flagged' : '',
(sess && (sess.mode === 'drill' || sess.ended_at)) ? qResultClass(q) : ''].join(' ');
b.onclick = () => { cur = q.id; paint(); };
nav.appendChild(b);
});
if (!S.questions.find(q => q.id === cur)) cur = S.questions[0] && S.questions[0].id;
const q = S.questions.find(q => q.id === cur);
if (q) {
const pos = S.questions.indexOf(q) + 1;
$('#qMeta').textContent = `Question ${pos} of ${S.questions.length} · Q${q.id} · ${S.track.domains[q.domain]} · ${q.weight} points`;
$('#qTitle').innerHTML = esc(q.title) + (q.difficulty === 'hard' ? '<span class="hardtag">hard</span>' : '');
$('#qBody').innerHTML = render(q.task);
$('#qBody').querySelectorAll('code').forEach(c => c.onclick = () => {
navigator.clipboard.writeText(c.textContent).then(() => { c.classList.add('copied'); setTimeout(() => c.classList.remove('copied'), 700); });
});
$('#btnFlag').classList.toggle('on', !!(sess && sess.flags.includes(q.id)));
$('#btnFlag').textContent = sess && sess.flags.includes(q.id) ? 'Flagged' : 'Flag for review';
const r = sess && sess.results[q.id], show = r && (sess.mode === 'drill' || sess.ended_at);
$('#checks').classList.toggle('hidden', !show);
$('#solution').classList.toggle('hidden', !q.solution);
if (q.solution) $('#solutionBody').textContent = q.solution;
if (show) $('#checks').innerHTML = `<b>${r.earned}/${r.total} points</b>` +
r.checks.map(c => `<div class="${c.pass ? 'ok' : 'no'}">${esc(c.name)} <span style="color:var(--muted)">(${c.pts})</span></div>`).join('');
const i = S.questions.indexOf(q);
$('#btnPrev').disabled = i === 0; $('#btnNext').disabled = i === S.questions.length - 1;
}
if (sess && document.activeElement !== $('#notes')) $('#notes').value = sess.notes || '';
// terminal
const tw = $('#termwrap');
if (!tw.dataset.ready) {
tw.dataset.ready = 1;
tw.innerHTML = S.ttyd ? `<iframe id="term" src="http://127.0.0.1:${S.ttyd_port}/" title="terminal"></iframe>` :
`<div id="noterm">The terminal pane needs <code>ttyd</code>, which isn't installed.<br><br>
Install it (<code>dnf install ttyd</code>, <code>pacman -S ttyd</code>, <code>brew install ttyd</code>, or a release from github.com/tsl0922/ttyd)
and restart <code>./exam web</code>.<br><br>Until then, work in any terminal with kubectl pointed at the cluster.
Task files go under <code>${S.workspace}/</code>, relative to the cka-sim directory.</div>`;
}
tickClock();
}
function tickClock() {
clearInterval(clockT);
const sess = S.session, el = $('#clock');
if (!sess || sess.ended_at) { el.textContent = sess ? 'ended' : '--:--:--'; el.className = ''; return; }
if (sess.untimed) { el.textContent = 'study'; el.className = 'paused'; return; }
const step = () => {
const now = Date.now()/1000 - skew;
const pausedTotal = sess.paused_total + (sess.paused ? (now - sess.server_time) : 0);
const left = sess.ends_at + pausedTotal - now;
el.textContent = (sess.paused ? '⏸ ' : '') + fmt(left);
el.className = sess.paused ? 'paused' : (left < 5*60 ? 'danger' : (left < 15*60 ? 'warn' : ''));
if (sess.paused) return;
if (left <= 0 && !autoSubmitted) { autoSubmitted = true; clearInterval(clockT); submit(); }
};
step(); clockT = setInterval(step, 1000);
}
async function load() {
S = await api('/api/session');
if (S.session) skew = Date.now()/1000 - S.session.server_time;
const sess = S.session;
const hard = $('#hardMode').checked, qn = hard ? (S.track.quota_hard || {}) : (S.track.quota || {});
const total = Object.values(qn).reduce((a, b) => a + b, 0) || S.bank.length;
$('#examDesc').textContent = `${total} tasks sampled across the 5 domains, ${S.track.minutes} minutes, pass mark ${S.track.pass_pct}%. No feedback until you submit.`;
const drilled = S.drilled || {}, undone = Object.keys(S.track.domains).filter(d => !drilled[d]);
$('#coverage').innerHTML = Object.keys(S.track.domains).map(d =>
`<span class="pill pick ${drilled[d] ? 'done' : ''}" data-domain="${d}" title="Drill ${S.track.domains[d]} — 5 tasks">${d} ${drilled[d] ? '✓' : '·'}</span>`).join('');
$('#coverage').querySelectorAll('[data-domain]').forEach(el => el.onclick = () => start('drill', null, false, false, el.dataset.domain));
const nxt = Object.keys(S.track.domains).sort((a, b) => (drilled[a] || 0) - (drilled[b] || 0))[0];
const eligible = S.bank.filter(q => hard || q.difficulty !== 'hard'), seenN = eligible.filter(q => (S.seen || []).includes(q.id)).length;
$('#allDesc').textContent = `${seenN}/${eligible.length} tasks attempted. 5 unseen tasks per session, round-robin across domains${hard ? ', hard first' : ''}; then whatever you failed.`;
$('#nextDesc').textContent = `${S.track.domains[nxt]} — 5 tasks, ${hard ? 5 : 6} min each. Drills rotate through all domains before the exam.`;
for (const s of ['a', 'b']) {
const info = (S.sets || {})[s];
const el = $('#sim' + s.toUpperCase() + 'Desc');
if (el) el.textContent = info ? `${info.count} fixed tasks, ${S.track.minutes} min, exam rules. Hardest set in the bank — closest to a real sitting.` : '';
}
$('#gate').classList.toggle('hidden', !undone.length);
$('#gate').innerHTML = undone.length ? `Drill every domain once before the full exam. Not yet drilled: ${undone.join(', ')}. <a href="#" id="forceExam">Start the exam anyway</a>` : '';
$('#optExam').disabled = !!undone.length; $('#optExam').style.opacity = undone.length ? .5 : 1;
const fe = $('#forceExam'); if (fe) fe.onclick = e => { e.preventDefault(); start('exam'); };
$('#lobbyTitle').textContent = S.track.name;
$('#trackPick').innerHTML = (S.tracks || []).map(t =>
`<span class="pill pick ${t === S.track_id ? 'sel' : ''}" data-track="${t}">${t.toUpperCase()}</span>`).join('');
$('#trackPick').querySelectorAll('[data-track]').forEach(el => el.onclick = async () => {
if (el.dataset.track === S.track_id) return;
S = await api('/api/track', {track: el.dataset.track}); cur = null; load();
}); $('#ver').textContent = 'exam ' + (S.version || '?');
$('#lobbySub').textContent = sess && sess.ended_at ? `Last session ended with ${sess.score.pct}%. Start another, or review it under History.` : 'Choose a session. Seeding the cluster takes about a minute.';
if (sess && sess.ended_at && !$('#results').dataset.shown) showResults();
paint();
}
async function startSim(name) {
$('#lobbyOpts').classList.add('hidden'); $('#lobbyErr').classList.add('hidden');
$('#seeding').classList.remove('hidden'); $('#seedLog').textContent = 'starting…';
try {
await api('/api/start', {mode: 'exam', sim: name, hard: true});
for (;;) {
await new Promise(r => setTimeout(r, 1200));
const s = await api('/api/session');
$('#seedLog').textContent = ((s.setup_log || []).join('\n') || 'starting…').replace(/\x1b\[[0-9;]*m/g, '');
$('#seedLog').scrollTop = $('#seedLog').scrollHeight;
if (!s.seeding) { if (s.seed_error) throw new Error(s.seed_error); S = s; break; }
}
skew = Date.now()/1000 - S.session.server_time; autoSubmitted = false;
$('#results').dataset.shown = ''; $('#results').classList.add('hidden'); cur = S.questions[0].id; paint();
} catch (e) {
$('#lobbyErr').textContent = e.message; $('#lobbyErr').classList.remove('hidden'); $('#lobbyOpts').classList.remove('hidden');
} finally { $('#seeding').classList.add('hidden'); }
}
async function start(mode, ids, next, campaign, domain, study) {
$('#lobbyOpts').classList.add('hidden'); $('#drillPick').classList.add('hidden'); $('#lobbyErr').classList.add('hidden');
$('#seeding').classList.remove('hidden'); $('#seedLog').textContent = 'starting…';
try {
await api('/api/start', {mode, ids, next: !!next, campaign: !!campaign, domain: domain || null, study: !!study, hard: $('#hardMode').checked});
// seeding runs in the background; stream its log until it finishes
for (;;) {
await new Promise(r => setTimeout(r, 1200));
const s = await api('/api/session');
$('#seedLog').textContent = ((s.setup_log || []).join('\n') || 'starting…').replace(/\x1b\[[0-9;]*m/g, '');
$('#seedLog').scrollTop = $('#seedLog').scrollHeight;
if (!s.seeding) {
if (s.seed_error) throw new Error(s.seed_error);
S = s; break;
}
}
skew = Date.now()/1000 - S.session.server_time; autoSubmitted = false;
$('#results').dataset.shown = ''; $('#results').classList.add('hidden'); cur = S.questions[0].id; paint();
} catch (e) {
$('#lobbyErr').textContent = e.message === 'Failed to fetch' ? 'Lost contact with ./exam web — is it still running? Restart it and reload this page.' : e.message;
$('#lobbyErr').classList.remove('hidden'); $('#lobbyOpts').classList.remove('hidden');
} finally { $('#seeding').classList.add('hidden'); }
}
async function submit() {
$('#confirm').classList.add('hidden');
$('#btnEnd').disabled = true; $('#btnEnd').textContent = 'Grading…';
try { S = await api('/api/end', {}); } finally { $('#btnEnd').disabled = false; $('#btnEnd').textContent = 'Submit exam'; }
showResults(); paint();
}
function showResults() {
const s = S.session, sc = s.score;
$('#results').dataset.shown = 1;
$('#resSub').textContent = `${s.mode === 'drill' ? 'Drill' : 'Full exam'} · ${fmt(s.ended_at - s.started_at - s.paused_total)} used${s.over_time ? ' (over time)' : ''}${s.pauses ? ` · paused ${s.pauses}× (${fmt(s.paused_total)})` : ''}`;
$('#resScore').innerHTML = `${sc.pct}% <small>${sc.earned} / ${sc.total} points</small>`;
const v = $('#resVerdict'); v.textContent = sc.pct >= sc.pass_pct ? 'Pass' : 'Fail'; v.className = 'verdict ' + (sc.pct >= sc.pass_pct ? 'pass' : 'fail');
$('#resDomains').innerHTML = Object.entries(s.domains).map(([d, x]) =>
`<div class="bar"><span>${d}</span><div class="track"><div class="fill" style="width:${100*x.earned/x.total}%"></div></div><span>${x.earned}/${x.total}</span></div>`).join('');
$('#resQuestions').innerHTML = S.questions.map(q => { const r = s.results[q.id]; if (!r) return '';
const cls = r.pass ? 't-pass' : (r.earned ? 't-partial' : 't-fail');
return `<details class="${cls}"><summary>Q${q.id} ${esc(q.title)}<span class="pts">${r.earned}/${r.total}</span></summary>` +
r.checks.map(c => `<div class="${c.pass ? 'ok' : 'no'}" style="padding-left:14px">${c.pass ? '✓' : '✗'} ${esc(c.name)} (${c.pts})</div>`).join('') +
(q.solution ? `<details style="margin:6px 0 4px 14px"><summary style="font-size:13px">Reference solution</summary><pre style="white-space:pre-wrap;font:12px/1.5 var(--mono);background:var(--bg-alt);border:1px solid var(--line);color:#c9d4f2;padding:8px;border-radius:6px">${esc(q.solution)}</pre></details>` : '') + '</details>'; }).join('');
const missed = S.questions.filter(q => s.results[q.id] && !s.results[q.id].pass).map(q => q.id);
$('#btnDrillMissed').disabled = !missed.length; $('#btnDrillMissed').onclick = () => { $('#results').classList.add('hidden'); start('drill', missed); };
$('#results').classList.remove('hidden'); $('#lobby').classList.add('hidden');
}
async function showHistory() {
const h = await api('/api/history');
$('#histSub').textContent = h.history.length ? `${h.history.length} attempt(s) on this track` : 'No attempts yet.';
$('#histBody').innerHTML = h.history.slice().reverse().map(e =>
`<div class="bar"><span>${e.mode}${e.hard ? '*' : ''}</span><div><b>${e.score_pct}%</b> · ${e.at.replace('T',' ')} · ${fmt(e.time_used)} · ${e.ids.length === S.questions.length ? 'all tasks' : 'Q' + e.ids.join(', Q')}</div><span></span></div>`).join('') +
(h.weak.length ? `<div style="margin-top:12px;color:var(--muted)">Weak or unattempted: Q${h.weak.join(', Q')}</div>` : '');
$('#histOv').classList.remove('hidden');
}
// ---- wiring ----
$('#optExam').onclick = () => start('exam');
$('#optNext').onclick = () => start('drill', null, true);
$('#optStudy').onclick = () => start('drill', null, true, false, null, true);
$('#optAll').onclick = () => start('drill', null, false, true);
$('#optSimA').onclick = () => startSim('a');
$('#optSimB').onclick = () => startSim('b');
$('#hardMode').onchange = () => load();
$('#optDrill').onclick = async () => {
const h = await api('/api/history');
const hard = $('#hardMode').checked;
$('#picker').innerHTML = S.bank.filter(q => hard || q.difficulty !== 'hard').map(q => `<label><input type="checkbox" value="${q.id}" ${h.weak.includes(q.id) ? 'checked' : ''}> Q${q.id} ${q.domain} ${esc(q.title)}${q.difficulty === 'hard' ? '<span class="hardtag">hard</span>' : ''}</label>`).join('');
$('#lobbyOpts').classList.add('hidden'); $('#drillPick').classList.remove('hidden');
};
$('#btnBackDrill').onclick = () => { $('#drillPick').classList.add('hidden'); $('#lobbyOpts').classList.remove('hidden'); };
$('#btnStartDrill').onclick = () => { const ids = [...$('#picker').querySelectorAll('input:checked')].map(i => +i.value); if (ids.length) start('drill', ids); };
$('#btnPrev').onclick = () => { const i = S.questions.findIndex(q => q.id === cur); cur = S.questions[i-1].id; paint(); };
$('#btnNext').onclick = () => { const i = S.questions.findIndex(q => q.id === cur); cur = S.questions[i+1].id; paint(); };
$('#btnFlag').onclick = async () => { S = await api('/api/flag', {id: cur}); paint(); };
$('#btnCheck').onclick = async () => { $('#btnCheck').disabled = true; try { S = await api('/api/grade', {id: cur}); paint(); } catch (e) { alert(e.message); } finally { $('#btnCheck').disabled = false; } };
let nt; $('#notes').oninput = () => { clearTimeout(nt); nt = setTimeout(() => api('/api/notes', {notes: $('#notes').value}), 600); };
$('#btnEnd').onclick = () => {
const f = S.session.flags, un = S.questions.filter(q => !S.session.results[q.id]).length;
$('#confirmFlags').innerHTML = f.length ? `<div>Flagged for review: Q${f.join(', Q')}</div>` : '';
$('#confirm').classList.remove('hidden');
};
$('#btnCancelEnd').onclick = () => $('#confirm').classList.add('hidden');
$('#btnConfirmEnd').onclick = submit;
$('#btnCloseRes').onclick = () => { $('#results').classList.add('hidden'); $('#lobby').classList.add('hidden'); };
$('#btnNewExam').onclick = () => { $('#results').classList.add('hidden'); $('#lobby').classList.remove('hidden'); $('#lobbyOpts').classList.remove('hidden'); };
$('#btnHistory').onclick = showHistory;
$('#btnPause').onclick = async () => { S = await api('/api/pause', {paused: !S.session.paused}); skew = Date.now()/1000 - S.session.server_time; paint(); };
$('#btnCloseHist').onclick = () => $('#histOv').classList.add('hidden');
// draggable split
const handle = $('#handle'), main = document.querySelector('main');
handle.onmousedown = e => { e.preventDefault(); const mv = ev => { main.style.gridTemplateColumns = `56px ${Math.max(320, ev.clientX - 56)}px 6px 1fr`; };
const up = () => { removeEventListener('mousemove', mv); removeEventListener('mouseup', up); }; addEventListener('mousemove', mv); addEventListener('mouseup', up); };
load();
})();
</script>
</body>
</html>