/* global React */ // ============================================================== // screens.jsx — Landing, Exam, Results // ============================================================== var { useState, useEffect, useRef, useMemo, useCallback } = React; // ---------- LANDING ---------- const Landing = ({ onLoadExam, history, onResume, onClearHistory }) => { const [err, setErr] = useState(null); const [dragOver, setDragOver] = useState(false); const fileRef = useRef(null); const handleFile = async (file) => { setErr(null); try { const text = await file.text(); const data = JSON.parse(text); const msg = window.validateExam(data); if (msg) throw new Error(msg); onLoadExam(data); } catch (e) { setErr(`Could not load: ${e.message}`); } }; const loadSample = async (path) => { setErr(null); try { const r = await fetch(path); const data = await r.json(); onLoadExam(data); } catch (e) { setErr(`Could not load sample: ${e.message}`); } }; const samples = [ { path: 'samples/calculus-101.json', title: 'Calculus I — Midterm Practice', meta: '7 questions · 45 min · LaTeX math', mark: '∫' }, { path: 'samples/spanish-a2.json', title: 'Spanish A2 — Vocabulary & Grammar', meta: '6 questions · 20 min · Translation', mark: 'ñ' }, { path: 'samples/mixed-demo.json', title: 'Mixed Demo — Every Question Type', meta: '4 questions · Untimed · Media', mark: '◇' }, ]; const resumable = window.loadState(); return (
BrainWave · practice exam platform

Upload an exam. Practice anything.

Drop in a JSON file to run a practice exam — math with full LaTeX, language translation, multiple choice, matching, ordering, essays, and more. Progress saves automatically.

fileRef.current?.click()} onDragOver={(e) => { e.preventDefault(); setDragOver(true); }} onDragLeave={() => setDragOver(false)} onDrop={(e) => { e.preventDefault(); setDragOver(false); const f = e.dataTransfer.files?.[0]; if (f) handleFile(f); }} >

Drop your exam JSON here

or click to browse · max 5 MB

e.target.files?.[0] && handleFile(e.target.files[0])} />
{err &&
{err}
} {resumable && (
Resume in-progress exam
{resumable.exam.title} · {Object.keys(resumable.answers || {}).length}/{resumable.exam.questions.length} answered
)}
Supported types
multiple-choice · multiple-select · short-answer · translation · math-input (LaTeX) · essay · match · order

Try a sample

{samples.map((s) => (
loadSample(s.path)}>
{s.mark}
{s.title}
{s.meta}
→
))} View JSON schema →
{history.length > 0 && (

Past attempts

{history.map((h) => { const pct = h.totalPoints ? Math.round((h.scorePoints / h.totalPoints) * 100) : 0; const passed = pct >= (h.passingScore || 60); return (
{h.title}
{window.formatDate(h.completedAt)} · {h.correctCount}/{h.gradedCount} correct
{window.formatTime(h.elapsedMs)}
{pct}%
); })}
)}
); }; // ---------- EXAM (runner) ---------- const ExamRunner = ({ exam, state, setState, onSubmit, onExit }) => { const { currentIdx, answers, flags, startedAt, feedbackMode } = state; const q = exam.questions[currentIdx]; const total = exam.questions.length; const [revealed, setRevealed] = useState(() => !!state.revealed?.[q.id]); useEffect(() => { setRevealed(!!state.revealed?.[q.id]); }, [currentIdx, q.id, state.revealed]); const setAnswer = (val) => { setState((s) => ({ ...s, answers: { ...s.answers, [q.id]: val } })); }; const goTo = (i) => setState((s) => ({ ...s, currentIdx: Math.max(0, Math.min(total - 1, i)) })); const toggleFlag = () => setState((s) => ({ ...s, flags: { ...s.flags, [q.id]: !s.flags[q.id] } })); const checkAnswer = () => { setState((s) => ({ ...s, revealed: { ...(s.revealed || {}), [q.id]: true } })); setRevealed(true); }; // Timer const [now, setNow] = useState(Date.now()); useEffect(() => { const t = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(t); }, []); const elapsedMs = now - startedAt; const remainingMs = exam.timeLimitMinutes ? exam.timeLimitMinutes * 60_000 - elapsedMs : null; const timeUrgent = remainingMs != null && remainingMs < 60_000; useEffect(() => { if (remainingMs != null && remainingMs <= 0) onSubmit(); }, [remainingMs, onSubmit]); // keyboard shortcuts useEffect(() => { const handler = (e) => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; if (e.key === 'ArrowRight' || e.key === 'j') goTo(currentIdx + 1); if (e.key === 'ArrowLeft' || e.key === 'k') goTo(currentIdx - 1); if (e.key === 'f') toggleFlag(); if (/^[1-9]$/.test(e.key) && q.type === 'multiple-choice') { const idx = parseInt(e.key, 10) - 1; if (q.choices[idx]) setAnswer(q.choices[idx].id); } }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [currentIdx, q]); const graded = feedbackMode === 'immediate' && revealed; const answered = answers[q.id] != null && (!Array.isArray(answers[q.id]) || answers[q.id].length > 0); const result = graded ? window.gradeQuestion(q, answers[q.id]) : null; // Navigator status const navStatus = (qq) => { if (feedbackMode === 'immediate' && state.revealed?.[qq.id]) { const r = window.gradeQuestion(qq, answers[qq.id]); if (r.status === 'correct') return 'correct'; if (r.status === 'wrong') return 'wrong'; } if (flags[qq.id]) return 'flagged'; if (answers[qq.id] != null && (!Array.isArray(answers[qq.id]) || answers[qq.id].length > 0)) return 'answered'; return ''; }; const answeredCount = exam.questions.filter((qq) => navStatus(qq) === 'answered' || navStatus(qq) === 'correct' || navStatus(qq) === 'wrong').length; return (
{/* Sidebar */} {/* Main */}
Question {String(currentIdx + 1).padStart(2, '0')} / {String(total).padStart(2, '0')} {q.category && {q.category}} {q.points || 1} {q.points === 1 ? 'pt' : 'pts'}
{q.prompt} {graded && result && q.type !== 'essay' && (
{result.correct ? 'Correct' : 'Not quite'}
{!result.correct && q.answer && q.type !== 'match' && q.type !== 'order' && (

Answer: {Array.isArray(q.answer) ? q.answer.map(id => q.choices?.find(c => c.id === id)?.text || id).join(', ') : (q.choices?.find(c => c.id === q.answer)?.text || q.answer)}

)} {q.explanation &&

{q.explanation}

}
)}
{feedbackMode === 'immediate' && !revealed && q.type !== 'essay' && answered && ( )} {currentIdx < total - 1 ? ( ) : ( )}
← → navigate F flag {q.type === 'multiple-choice' && 1–{q.choices.length} select}
{/* Meta rail */}
); }; // ---------- RESULTS ---------- const Results = ({ exam, state, onRestart, onExit, reviewOnly }) => { const { answers, startedAt, completedAt } = state; const elapsedMs = (completedAt || Date.now()) - startedAt; const graded = exam.questions.map((q) => ({ q, userAnswer: answers[q.id], result: window.gradeQuestion(q, answers[q.id]), })); const totalPoints = exam.questions.reduce((a, q) => a + (q.points || 1), 0); const scorePoints = graded.reduce((a, g) => a + g.result.points, 0); const gradedCount = graded.filter((g) => g.result.status !== 'ungraded').length; const correctCount = graded.filter((g) => g.result.correct).length; const pct = totalPoints ? Math.round((scorePoints / totalPoints) * 100) : 0; const passing = exam.passingScore || 60; const passed = pct >= passing; // Categories breakdown const byCat = {}; graded.forEach((g) => { const cat = g.q.category || 'Uncategorized'; if (!byCat[cat]) byCat[cat] = { earned: 0, possible: 0, correct: 0, total: 0 }; byCat[cat].possible += g.q.points || 1; byCat[cat].earned += g.result.points; byCat[cat].total += 1; if (g.result.correct) byCat[cat].correct += 1; }); const [openIdx, setOpenIdx] = useState(null); return (
{reviewOnly ? 'Reviewing past attempt' : 'Exam complete'} · {exam.title}

You scored {pct}%
{passed ? `Above the ${passing}% passing mark.` : `Below the ${passing}% passing mark. Keep practicing.`}

Points
{scorePoints}/ {totalPoints}
Correct
{correctCount}/ {gradedCount}
Time
{window.formatTime(elapsedMs)}
{Object.keys(byCat).length > 1 && (
By category
{Object.entries(byCat).map(([cat, c]) => { const catPct = c.possible ? Math.round((c.earned / c.possible) * 100) : 0; return (
{cat}
{catPct}%
); })}
)}

Review

{graded.map((g, i) => (
setOpenIdx(openIdx === i ? null : i)}> {String(i + 1).padStart(2, '0')} {g.q.prompt.replace(/\$\$([^$]+)\$\$/g, '[math]').replace(/\$([^$]+)\$/g, '[math]').slice(0, 90)}{g.q.prompt.length > 90 ? '…' : ''} {g.result.status} {openIdx === i ? '−' : '+'}
{openIdx === i && (
{g.q.prompt}
{}} locked graded /> {g.q.explanation && (
Explanation
{g.q.explanation}
)} {g.q.rubric && (
Rubric (self-grade)
    {g.q.rubric.map((r, j) =>
  • {r}
  • )}
)}
)}
))}
{!reviewOnly && }
); }; Object.assign(window, { Landing, ExamRunner, Results });