From af02a30073cbbb409b43f91e650f3c28b5d65a46 Mon Sep 17 00:00:00 2001 From: Jonas Brami Date: Fri, 29 May 2026 18:54:30 +0400 Subject: [PATCH] feat(quiz): immediate per-question feedback + exam-mode toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Practice mode (new default): the moment you commit a deterministic question (mcq/tf/mermaid) it reveals — correct/incorrect, the right answer, and the explanation — and locks. The verdict is computed client-side (the answer already ships in /state), reusing the results card, so there's no server round-trip. Open questions are unchanged (no client-side answer to reveal; they grade at submit). A sidebar "Mode" toggle ("Reveal answers as I go") flips between practice and the classic batch/exam flow; the choice is sticky per browser via localStorage. In exam mode the answering view behaves exactly as before, and selections are preserved when toggling. The submit-bar message reflects the active mode. Production-over-recognition: seeing why you were wrong right at the point of the mistake beats a scorecard at the end. Frontend only — no backend or schema change. Verified in a real browser: correct and incorrect reveals (with explanation) for mcq/tf, open question stays interactive, and the exam-mode toggle round-trips without losing answers. Co-Authored-By: Claude Opus 4.8 --- src/cognit/mcp/assets/quiz_mcp.js | 52 +++++++++++++++++++++++++++++-- src/cognit/mcp/assets/styles.css | 7 +++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/cognit/mcp/assets/quiz_mcp.js b/src/cognit/mcp/assets/quiz_mcp.js index 19fc8c8..8224f5f 100644 --- a/src/cognit/mcp/assets/quiz_mcp.js +++ b/src/cognit/mcp/assets/quiz_mcp.js @@ -39,6 +39,11 @@ let suppressResults = false; // local "Discard" → show answering even though r let grading = false; // grading in flight → pause polling re-renders let renderedSig = null; // signature of the rendered question structure let view = null; // waiting | answering | results | published +// Practice mode (default): deterministic questions reveal the answer + explanation the +// moment you commit. Exam mode: the classic batch flow (answer all, then submit). Sticky +// per browser via localStorage. (Open questions always grade at submit — there's no +// client-side answer to reveal.) +let examMode = localStorage.getItem("cognit.examMode") === "1"; // ── small DOM helper ──────────────────────────────────────────── function el(tag, attrs = {}, children = []) { @@ -121,6 +126,7 @@ async function flushAnswers() { // ── question renderers ────────────────────────────────────────── function selectMCQOption(q, opts, idx) { postAnswer(q.id, q.options[idx]); + if (!examMode) { renderQuestions(); return; } // practice: re-render to reveal + lock opts.forEach((o, j) => { o.classList.toggle("selected", j === idx); o.setAttribute("aria-checked", j === idx ? "true" : "false"); @@ -169,6 +175,7 @@ function renderMermaid(q) { tabindex: i === 0 ? "0" : "-1", onclick: () => { postAnswer(q.id, label); + if (!examMode) { renderQuestions(); return; } // practice: re-render to reveal + lock cards.forEach((c, j) => { c.classList.toggle("selected", j === i); c.setAttribute("aria-checked", j === i ? "true" : "false"); @@ -217,6 +224,7 @@ function renderTF(q) { text: v.charAt(0).toUpperCase() + v.slice(1), onclick: () => { postAnswer(q.id, v); + if (!examMode) { renderQuestions(); return; } // practice: re-render to reveal + lock cells.forEach((c, j) => { c.classList.toggle("sel", j === i); c.setAttribute("aria-checked", j === i ? "true" : "false"); @@ -351,7 +359,26 @@ function renderAnchor(q) { return details; } +const DETERMINISTIC = new Set(["mcq", "tf", "mermaid"]); +function isDeterministic(q) { return DETERMINISTIC.has(q.type); } + +// Correctness we can compute on the client (the answer ships in /state). tf compares +// against the stringified boolean; mcq is full option text; mermaid is the option key. +function isCorrectLocal(q) { + const v = answers[q.id]; + return q.type === "tf" ? v === String(q.answer) : v === q.answer; +} + +// Practice mode: a committed deterministic question reveals immediately and locks. +function shouldReveal(q) { return !examMode && isDeterministic(q) && isAnswered(q); } + function renderQuestion(q, i) { + if (shouldReveal(q)) { + // Reuse the results card (correct/your-pick rows + explanation), with a locally + // computed verdict — no server round-trip, the answer is already in /state. + const correct = isCorrectLocal(q); + return renderResultCard(q, { question_id: q.id, correct, score: correct ? 100 : 0, feedback: "" }, i); + } const inputsByType = { mcq: renderMCQ, mermaid: renderMermaid, open: renderOpen, tf: renderTF }; const inputs = inputsByType[q.type](q); return el("article", { class: "file" }, [ @@ -368,8 +395,25 @@ function renderQuestion(q, i) { } // ── sidebar (questions state) ─────────────────────────────────── +function setExamMode(on) { + examMode = on; + localStorage.setItem("cognit.examMode", on ? "1" : "0"); + renderQuestions(); +} + function renderSidebar() { sidebarRoot.innerHTML = ""; + sidebarRoot.appendChild(el("div", { class: "side-block" }, [ + el("div", { class: "side-title", text: "Mode" }), + el("label", { class: "mode-toggle" }, [ + el("input", { + type: "checkbox", + checked: !examMode, // checked = practice (reveal as you go) + onchange: (e) => setExamMode(!e.target.checked), + }), + el("span", { text: "Reveal answers as I go" }), + ]), + ])); sidebarRoot.appendChild(el("div", { class: "side-block" }, [ el("div", { class: "side-title", text: "Progress" }), el("div", { class: "progress" }, @@ -459,9 +503,11 @@ function renderReviewbarSubmit() { reviewbar.className = "reviewbar is-submit"; reviewbar.innerHTML = ""; const hasOpen = quiz.questions.some(q => q.type === "open"); - reviewbar.appendChild(el("div", { class: "reviewbar__msg" }, [ - hasOpen ? "Open question grades after submit." : "Answers stay private until you submit.", - ])); + const msg = !examMode + ? (hasOpen ? "Answers reveal as you go · the open question grades at submit." + : "Answers reveal as you go · private until you submit.") + : (hasOpen ? "Open question grades after submit." : "Answers stay private until you submit."); + reviewbar.appendChild(el("div", { class: "reviewbar__msg" }, [msg])); reviewbar.appendChild(el("div", { class: "reviewbar__spacer" })); const btn = el("button", { class: "btn btn--primary", type: "button", text: "Submit quiz", onclick: submitQuiz, diff --git a/src/cognit/mcp/assets/styles.css b/src/cognit/mcp/assets/styles.css index 92beb26..84c6906 100644 --- a/src/cognit/mcp/assets/styles.css +++ b/src/cognit/mcp/assets/styles.css @@ -869,6 +869,13 @@ textarea.open:focus { border-color: var(--blue); outline: none; box-shadow: 0 0 .diff-line.anchor-hit { box-shadow: inset 3px 0 0 var(--blue); } .codepanel__note { padding: 10px 12px; font-size: 12px; color: var(--fg-mute); background: var(--bg-canvas); border-radius: 0 0 6px 6px; } +/* practice/exam mode toggle (sidebar) ────────────────────────────── */ +.mode-toggle { + display: flex; align-items: center; gap: 8px; + font-size: 13px; color: var(--fg); cursor: pointer; +} +.mode-toggle input { accent-color: var(--blue); width: 15px; height: 15px; cursor: pointer; flex: 0 0 auto; } + /* diff coverage map (sidebar) ────────────────────────────────────── */ .sidelist .ic.empty { background: transparent; color: var(--fg-faint); box-shadow: inset 0 0 0 1.5px var(--border); } .sidelist.coverage .coverage__file {