LMS — Quiz / Assessment
A self-contained quiz runner for e-learning platforms that shows one question at a time with a live progress bar and countdown timer. It supports multiple-choice, multi-select, and true/false questions, plus flag-for-review, previous and next navigation, and a question navigator. Submitting reveals an animated score ring, pass or fail verdict, point totals, and a full per-question review with correct answers and plain-language explanations for every item answered.
MCP
Code
:root {
--brand: #5b5bd6;
--brand-d: #4444c2;
--brand-50: #eeeefc;
--accent: #13b981;
--amber: #f59e0b;
--ink: #1a1a2e;
--ink-2: #44465f;
--muted: #6b6e87;
--bg: #f7f7fb;
--surface: #ffffff;
--line: rgba(26, 26, 46, 0.1);
--ok: #13b981;
--danger: #e05656;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--sh-sm: 0 1px 2px rgba(26, 26, 46, 0.06);
--sh-md: 0 6px 20px rgba(26, 26, 46, 0.08);
--sh-lg: 0 18px 50px rgba(26, 26, 46, 0.12);
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
color: var(--ink);
background:
radial-gradient(1100px 500px at 88% -8%, rgba(91, 91, 214, 0.1), transparent 60%),
radial-gradient(900px 480px at -5% 5%, rgba(19, 185, 129, 0.08), transparent 55%),
var(--bg);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
padding: 28px 16px 96px;
}
.shell {
max-width: 760px;
margin: 0 auto;
}
/* ===== Header ===== */
.quiz-head {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-md);
padding: 18px 20px;
margin-bottom: 16px;
}
.head-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
flex-wrap: wrap;
}
.course { display: flex; align-items: center; gap: 12px; }
.course-badge {
width: 44px; height: 44px;
border-radius: var(--r-md);
display: grid; place-items: center;
font-weight: 800; font-size: 16px; letter-spacing: .5px;
color: #fff;
background: linear-gradient(135deg, var(--brand), var(--brand-d));
box-shadow: 0 4px 12px rgba(91, 91, 214, 0.35);
flex: none;
}
.course-name { margin: 0; font-weight: 700; font-size: 16px; }
.course-sub { margin: 1px 0 0; font-size: 12.5px; color: var(--muted); }
.head-pills { display: flex; align-items: center; gap: 8px; }
.pill {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12.5px; font-weight: 600;
padding: 6px 11px;
border-radius: 999px;
border: 1px solid var(--line);
white-space: nowrap;
}
.pill-level { background: var(--brand-50); color: var(--brand-d); border-color: transparent; }
.pill-level .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--amber); }
.pill-timer {
background: #fff; color: var(--ink-2);
font-variant-numeric: tabular-nums;
}
.pill-timer.warn { color: var(--danger); border-color: rgba(224, 86, 86, 0.4); background: rgba(224, 86, 86, 0.07); }
.pill-timer.warn svg { animation: tick 1s steps(2) infinite; }
@keyframes tick { 50% { opacity: .4; } }
.progress-row {
display: flex; align-items: center; gap: 12px;
margin-top: 16px;
}
.progress-track {
flex: 1;
height: 9px;
border-radius: 999px;
background: var(--brand-50);
overflow: hidden;
}
.progress-fill {
height: 100%;
width: 0%;
border-radius: 999px;
background: linear-gradient(90deg, var(--brand), var(--accent));
transition: width .35s cubic-bezier(.4, 0, .2, 1);
}
.progress-label {
font-size: 12.5px; font-weight: 600; color: var(--muted);
white-space: nowrap; font-variant-numeric: tabular-nums;
}
/* ===== Navigator ===== */
.qnav {
display: flex; flex-wrap: wrap; gap: 8px;
margin-bottom: 14px;
}
.qdot {
width: 34px; height: 34px;
border-radius: 10px;
border: 1.5px solid var(--line);
background: var(--surface);
font-weight: 700; font-size: 13px;
color: var(--ink-2);
cursor: pointer;
position: relative;
transition: transform .12s ease, border-color .15s, background .15s, box-shadow .15s;
}
.qdot:hover { transform: translateY(-1px); border-color: var(--brand); }
.qdot:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
.qdot.answered { background: var(--brand-50); border-color: transparent; color: var(--brand-d); }
.qdot.current { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-50); }
.qdot.flagged::after {
content: ""; position: absolute; top: -3px; right: -3px;
width: 11px; height: 11px; border-radius: 50%;
background: var(--amber); border: 2px solid var(--surface);
}
/* ===== Question card ===== */
.question-card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-md);
padding: 24px;
animation: slideIn .3s ease;
}
@keyframes slideIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
.q-meta { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
.q-index { font-size: 12.5px; font-weight: 700; color: var(--brand-d); letter-spacing: .3px; }
.q-type {
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .5px;
padding: 3px 8px; border-radius: 999px;
background: #f0f1f6; color: var(--muted);
}
.q-points { font-size: 12px; color: var(--muted); margin-left: auto; }
.q-text {
margin: 0 0 18px;
font-size: 19px; font-weight: 700; line-height: 1.4;
}
.options { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
.option {
display: flex; align-items: flex-start; gap: 12px;
padding: 14px 15px;
border: 1.5px solid var(--line);
border-radius: var(--r-md);
cursor: pointer;
transition: border-color .15s, background .15s, box-shadow .15s, transform .1s;
background: var(--surface);
}
.option:hover { border-color: var(--brand); background: var(--brand-50); }
.option:active { transform: scale(.995); }
.option:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
.option.selected {
border-color: var(--brand);
background: var(--brand-50);
box-shadow: inset 0 0 0 1px var(--brand);
}
.opt-mark {
flex: none;
width: 22px; height: 22px;
border: 2px solid var(--line);
display: grid; place-items: center;
color: #fff;
transition: background .15s, border-color .15s;
}
.opt-mark.radio { border-radius: 50%; }
.opt-mark.check { border-radius: 6px; }
.opt-mark svg { width: 13px; height: 13px; opacity: 0; transition: opacity .12s; }
.option.selected .opt-mark { background: var(--brand); border-color: var(--brand); }
.option.selected .opt-mark svg { opacity: 1; }
.opt-text { font-size: 15px; font-weight: 500; color: var(--ink-2); padding-top: 1px; }
.option.selected .opt-text { color: var(--ink); }
.multi-hint { font-size: 12.5px; color: var(--muted); margin: 0 0 12px; font-weight: 500; }
/* ===== Footer controls ===== */
.runner-foot {
display: flex; align-items: center; gap: 10px;
margin-top: 16px;
}
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
font-family: inherit; font-size: 14px; font-weight: 600;
padding: 11px 16px;
border-radius: var(--r-md);
border: 1.5px solid transparent;
cursor: pointer;
transition: background .15s, border-color .15s, transform .1s, box-shadow .15s, color .15s;
white-space: nowrap;
}
.btn:active { transform: translateY(1px); }
.btn:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.btn-primary { background: var(--brand); color: #fff; box-shadow: 0 4px 12px rgba(91, 91, 214, 0.3); }
.btn-primary:hover:not(:disabled) { background: var(--brand-d); }
.btn-ghost { background: var(--surface); color: var(--ink-2); border-color: var(--line); }
.btn-ghost:hover:not(:disabled) { background: #f0f1f6; }
.btn-flag { background: var(--surface); color: var(--muted); border-color: var(--line); margin-left: auto; }
.btn-flag[aria-pressed="true"] {
color: #fff; background: var(--amber); border-color: var(--amber);
}
.btn-flag:hover { border-color: var(--amber); color: var(--amber); }
.btn-flag[aria-pressed="true"]:hover { color: #fff; }
/* ===== Submit bar ===== */
.submit-bar {
position: sticky; bottom: 0;
margin-top: 16px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-md);
padding: 12px 16px;
display: flex; align-items: center; justify-content: space-between; gap: 12px;
}
.submit-bar span { font-size: 13px; color: var(--muted); font-weight: 500; }
.btn-submit { background: var(--accent); color: #fff; box-shadow: 0 4px 12px rgba(19, 185, 129, 0.3); }
.btn-submit:hover { filter: brightness(.95); }
/* ===== Score screen ===== */
.score-screen {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-md);
padding: 26px 24px;
animation: slideIn .35s ease;
}
.score-hero {
display: flex; align-items: center; gap: 26px;
padding-bottom: 22px;
border-bottom: 1px solid var(--line);
flex-wrap: wrap;
}
.ring-wrap { position: relative; width: 132px; height: 132px; flex: none; }
.score-ring { width: 132px; height: 132px; transform: rotate(-90deg); }
.ring-bg { fill: none; stroke: var(--brand-50); stroke-width: 10; }
.ring-fg {
fill: none; stroke: var(--accent); stroke-width: 10; stroke-linecap: round;
stroke-dasharray: 326.7; stroke-dashoffset: 326.7;
transition: stroke-dashoffset 1s cubic-bezier(.4, 0, .2, 1), stroke .4s;
}
.ring-center {
position: absolute; inset: 0;
display: grid; place-content: center; text-align: center;
}
.ring-pct { font-size: 30px; font-weight: 800; line-height: 1; }
.ring-sub { font-size: 12px; color: var(--muted); font-weight: 600; }
.score-summary { flex: 1; min-width: 200px; }
.score-verdict { margin: 0 0 4px; font-size: 22px; font-weight: 800; }
.score-line { margin: 0 0 14px; font-size: 14px; color: var(--muted); }
.score-stats { display: flex; gap: 10px; flex-wrap: wrap; }
.stat {
display: flex; flex-direction: column;
padding: 8px 13px; border-radius: var(--r-md);
border: 1px solid var(--line); min-width: 78px;
}
.stat b { font-size: 18px; font-weight: 800; }
.stat span { font-size: 11.5px; color: var(--muted); font-weight: 600; }
.stat.good b { color: var(--ok); }
.stat.bad b { color: var(--danger); }
.stat.skip b { color: var(--amber); }
.review-title { margin: 22px 0 14px; font-size: 16px; font-weight: 700; }
.review-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; }
.review-item {
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 14px 16px;
border-left: 4px solid var(--line);
}
.review-item.correct { border-left-color: var(--ok); }
.review-item.wrong { border-left-color: var(--danger); }
.review-item.unanswered { border-left-color: var(--amber); }
.ri-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.ri-status {
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .5px;
padding: 3px 8px; border-radius: 999px;
}
.ri-status.correct { background: rgba(19, 185, 129, .12); color: var(--ok); }
.ri-status.wrong { background: rgba(224, 86, 86, .12); color: var(--danger); }
.ri-status.unanswered { background: rgba(245, 158, 11, .14); color: #b97608; }
.ri-q { font-size: 12.5px; font-weight: 700; color: var(--muted); }
.ri-text { font-size: 15px; font-weight: 600; margin: 0 0 10px; }
.ri-answers { display: grid; gap: 5px; }
.ri-row { display: flex; align-items: center; gap: 8px; font-size: 13.5px; }
.ri-row .tag {
font-size: 10.5px; font-weight: 700; text-transform: uppercase;
padding: 2px 7px; border-radius: 999px; flex: none;
}
.ri-row.your .tag { background: #f0f1f6; color: var(--muted); }
.ri-row.right .tag { background: rgba(19, 185, 129, .12); color: var(--ok); }
.ri-row.your.is-wrong { color: var(--danger); }
.ri-explain {
margin: 10px 0 0; font-size: 13px; color: var(--ink-2);
background: var(--brand-50); padding: 9px 12px; border-radius: var(--r-sm);
line-height: 1.45;
}
.score-actions { display: flex; gap: 10px; margin-top: 22px; justify-content: flex-end; }
/* ===== Modal ===== */
.modal-overlay {
position: fixed; inset: 0;
background: rgba(26, 26, 46, 0.5);
display: grid; place-items: center;
padding: 16px; z-index: 50;
animation: fade .15s ease;
}
@keyframes fade { from { opacity: 0; } }
.modal {
background: var(--surface);
border-radius: var(--r-lg);
box-shadow: var(--sh-lg);
padding: 24px; max-width: 400px; width: 100%;
animation: pop .2s ease;
}
@keyframes pop { from { transform: scale(.94); opacity: 0; } }
.modal h3 { margin: 0 0 8px; font-size: 18px; font-weight: 800; }
.modal p { margin: 0 0 18px; font-size: 14px; color: var(--muted); }
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; }
/* ===== Toast ===== */
.toast-wrap {
position: fixed; bottom: 18px; left: 50%; transform: translateX(-50%);
display: flex; flex-direction: column; gap: 8px; z-index: 80;
width: max-content; max-width: 90vw;
}
.toast {
background: var(--ink); color: #fff;
padding: 10px 16px; border-radius: var(--r-md);
font-size: 13.5px; font-weight: 600;
box-shadow: var(--sh-lg);
animation: toastIn .25s ease;
}
.toast.ok { background: var(--accent); }
.toast.warn { background: var(--amber); }
@keyframes toastIn { from { opacity: 0; transform: translateY(10px); } }
/* ===== Responsive ===== */
@media (max-width: 520px) {
body { padding: 16px 12px 90px; }
.quiz-head { padding: 14px; border-radius: var(--r-md); }
.head-top { gap: 10px; }
.course-badge { width: 38px; height: 38px; }
.head-pills { width: 100%; }
.question-card { padding: 18px; border-radius: var(--r-md); }
.q-text { font-size: 17px; }
.runner-foot { flex-wrap: wrap; }
.btn-flag { order: 3; width: 100%; margin-left: 0; }
.runner-foot .btn-ghost, .runner-foot .btn-primary { flex: 1; }
.score-hero { gap: 18px; }
.ring-wrap, .score-ring { width: 110px; height: 110px; }
.score-actions { flex-direction: column-reverse; }
.score-actions .btn { width: 100%; }
.qdot { width: 30px; height: 30px; }
}(function () {
"use strict";
/* ===== Quiz data (fictional) ===== */
const QUESTIONS = [
{
type: "single",
text: "Which keyword declares a variable that cannot be reassigned?",
points: 1,
options: ["var", "let", "const", "static"],
correct: [2],
explain: "`const` creates a binding that cannot be reassigned. Note its value can still be mutated if it's an object.",
},
{
type: "multi",
text: "Select all values that are falsy in JavaScript.",
points: 2,
options: ["0", '"0"', "null", "NaN", "[]"],
correct: [0, 2, 3],
explain: 'The falsy values include `0`, `null`, and `NaN`. The string "0" and an empty array `[]` are both truthy.',
},
{
type: "boolean",
text: "`typeof null` returns the string \"object\".",
points: 1,
options: ["True", "False"],
correct: [0],
explain: "Correct — this is a long-standing quirk of JavaScript. `typeof null` evaluates to \"object\".",
},
{
type: "single",
text: "What does Array.prototype.map() return?",
points: 1,
options: [
"The original array, mutated in place",
"A new array of transformed values",
"The number of elements processed",
"undefined",
],
correct: [1],
explain: "`map()` returns a brand new array containing the results of calling the callback on every element. It never mutates the source.",
},
{
type: "multi",
text: "Which of these are valid ways to create a Promise that resolves immediately?",
points: 2,
options: [
"Promise.resolve(42)",
"new Promise(r => r(42))",
"Promise.now(42)",
"async () => 42",
],
correct: [0, 1],
explain: "`Promise.resolve()` and `new Promise(executor)` both produce promises. `Promise.now` is not a real method, and an async arrow is a function, not a promise.",
},
];
const TOTAL = QUESTIONS.length;
const TIME_LIMIT = 10 * 60; // seconds
/* ===== State ===== */
const state = {
current: 0,
answers: QUESTIONS.map(() => []), // arrays of selected indices
flagged: QUESTIONS.map(() => false),
submitted: false,
timeLeft: TIME_LIMIT,
};
/* ===== Elements ===== */
const el = {
card: document.getElementById("questionCard"),
qnav: document.getElementById("qnav"),
progressFill: document.getElementById("progressFill"),
progressBar: document.getElementById("progressBar"),
progressLabel: document.getElementById("progressLabel"),
prevBtn: document.getElementById("prevBtn"),
nextBtn: document.getElementById("nextBtn"),
flagBtn: document.getElementById("flagBtn"),
flagLabel: document.getElementById("flagLabel"),
timerText: document.getElementById("timerText"),
timerPill: document.getElementById("timerPill"),
submitBar: document.getElementById("submitBar"),
submitBtn: document.getElementById("submitBtn"),
submitHint: document.getElementById("submitHint"),
scoreScreen: document.getElementById("scoreScreen"),
quizBody: document.querySelector(".quiz-body"),
quizHead: document.querySelector(".quiz-head"),
modalOverlay: document.getElementById("modalOverlay"),
modalBody: document.getElementById("modalBody"),
modalCancel: document.getElementById("modalCancel"),
modalConfirm: document.getElementById("modalConfirm"),
toastWrap: document.getElementById("toastWrap"),
};
const CHECK_SVG =
'<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 13l4 4L19 7" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const TYPE_LABEL = {
single: "Multiple choice",
multi: "Multi-select",
boolean: "True / false",
};
/* ===== Toast helper ===== */
let toastTimer;
function toast(msg, kind) {
const t = document.createElement("div");
t.className = "toast" + (kind ? " " + kind : "");
t.textContent = msg;
el.toastWrap.appendChild(t);
setTimeout(() => {
t.style.transition = "opacity .3s, transform .3s";
t.style.opacity = "0";
t.style.transform = "translateY(8px)";
setTimeout(() => t.remove(), 300);
}, 2200);
}
/* ===== Render navigator ===== */
function buildNav() {
el.qnav.innerHTML = "";
QUESTIONS.forEach((_, i) => {
const b = document.createElement("button");
b.className = "qdot";
b.type = "button";
b.textContent = i + 1;
b.setAttribute("aria-label", "Go to question " + (i + 1));
b.addEventListener("click", () => goTo(i));
el.qnav.appendChild(b);
});
}
function refreshNav() {
[...el.qnav.children].forEach((b, i) => {
b.classList.toggle("current", i === state.current);
b.classList.toggle("answered", state.answers[i].length > 0);
b.classList.toggle("flagged", state.flagged[i]);
});
}
/* ===== Render question ===== */
function renderQuestion() {
const i = state.current;
const q = QUESTIONS[i];
const sel = state.answers[i];
const multi = q.type === "multi";
let html = "";
html += '<div class="q-meta">';
html += '<span class="q-index">Question ' + (i + 1) + " of " + TOTAL + "</span>";
html += '<span class="q-type">' + TYPE_LABEL[q.type] + "</span>";
html += '<span class="q-points">' + q.points + (q.points > 1 ? " points" : " point") + "</span>";
html += "</div>";
html += '<p class="q-text">' + escapeHtml(q.text) + "</p>";
if (multi) html += '<p class="multi-hint">Select all that apply.</p>';
html += '<ul class="options" role="' + (multi ? "group" : "radiogroup") + '">';
q.options.forEach((opt, oi) => {
const isSel = sel.indexOf(oi) !== -1;
const markCls = multi ? "check" : "radio";
html +=
'<li class="option' + (isSel ? " selected" : "") +
'" tabindex="0" role="' + (multi ? "checkbox" : "radio") +
'" aria-checked="' + isSel + '" data-opt="' + oi + '">' +
'<span class="opt-mark ' + markCls + '">' + CHECK_SVG + "</span>" +
'<span class="opt-text">' + escapeHtml(opt) + "</span>" +
"</li>";
});
html += "</ul>";
el.card.innerHTML = html;
el.card.querySelectorAll(".option").forEach((node) => {
node.addEventListener("click", () => selectOption(parseInt(node.dataset.opt, 10)));
node.addEventListener("keydown", (e) => {
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
selectOption(parseInt(node.dataset.opt, 10));
}
});
});
// controls
el.prevBtn.disabled = i === 0;
el.nextBtn.disabled = i === TOTAL - 1;
el.flagBtn.setAttribute("aria-pressed", String(state.flagged[i]));
el.flagLabel.textContent = state.flagged[i] ? "Flagged" : "Flag for review";
refreshNav();
refreshProgress();
}
function selectOption(oi) {
if (state.submitted) return;
const q = QUESTIONS[state.current];
const sel = state.answers[state.current];
if (q.type === "multi") {
const idx = sel.indexOf(oi);
if (idx === -1) sel.push(oi);
else sel.splice(idx, 1);
} else {
state.answers[state.current] = [oi];
}
renderQuestion();
}
/* ===== Progress ===== */
function answeredCount() {
return state.answers.filter((a) => a.length > 0).length;
}
function refreshProgress() {
const done = answeredCount();
const pct = Math.round((done / TOTAL) * 100);
el.progressFill.style.width = pct + "%";
el.progressBar.setAttribute("aria-valuenow", String(pct));
el.progressLabel.textContent = done + " of " + TOTAL + " answered";
if (done === TOTAL) {
el.submitHint.textContent = "All questions answered — ready to submit.";
} else {
el.submitHint.textContent = TOTAL - done + " question" + (TOTAL - done > 1 ? "s" : "") + " left.";
}
}
/* ===== Navigation ===== */
function goTo(i) {
if (state.submitted) return;
state.current = Math.max(0, Math.min(TOTAL - 1, i));
renderQuestion();
}
el.prevBtn.addEventListener("click", () => goTo(state.current - 1));
el.nextBtn.addEventListener("click", () => goTo(state.current + 1));
el.flagBtn.addEventListener("click", () => {
if (state.submitted) return;
state.flagged[state.current] = !state.flagged[state.current];
const on = state.flagged[state.current];
el.flagBtn.setAttribute("aria-pressed", String(on));
el.flagLabel.textContent = on ? "Flagged" : "Flag for review";
refreshNav();
toast(on ? "Flagged for review" : "Flag removed", on ? "warn" : null);
});
// keyboard arrows
document.addEventListener("keydown", (e) => {
if (state.submitted || el.modalOverlay.hidden === false) return;
if (e.key === "ArrowRight") goTo(state.current + 1);
if (e.key === "ArrowLeft") goTo(state.current - 1);
});
/* ===== Timer ===== */
let timerId;
function startTimer() {
updateTimer();
timerId = setInterval(() => {
state.timeLeft--;
updateTimer();
if (state.timeLeft <= 0) {
clearInterval(timerId);
toast("Time is up — submitting your answers", "warn");
doSubmit();
} else if (state.timeLeft === 60) {
toast("1 minute remaining", "warn");
}
}, 1000);
}
function updateTimer() {
const m = Math.floor(state.timeLeft / 60);
const s = state.timeLeft % 60;
el.timerText.textContent = m + ":" + String(s).padStart(2, "0");
el.timerPill.classList.toggle("warn", state.timeLeft <= 60);
}
/* ===== Submit flow ===== */
el.submitBtn.addEventListener("click", () => {
const unanswered = TOTAL - answeredCount();
if (unanswered > 0) {
el.modalBody.textContent =
"You have " + unanswered + " unanswered question" + (unanswered > 1 ? "s" : "") +
". You can review them before submitting.";
openModal();
} else {
el.modalBody.textContent = "All questions are answered. Submit your final score?";
openModal();
}
});
el.modalCancel.addEventListener("click", closeModal);
el.modalConfirm.addEventListener("click", () => {
closeModal();
doSubmit();
});
el.modalOverlay.addEventListener("click", (e) => {
if (e.target === el.modalOverlay) closeModal();
});
function openModal() {
el.modalOverlay.hidden = false;
el.modalConfirm.focus();
}
function closeModal() {
el.modalOverlay.hidden = true;
}
function arraysEqualAsSets(a, b) {
if (a.length !== b.length) return false;
const sa = [...a].sort();
const sb = [...b].sort();
return sa.every((v, i) => v === sb[i]);
}
function doSubmit() {
if (state.submitted) return;
state.submitted = true;
clearInterval(timerId);
let correctCount = 0,
wrongCount = 0,
skipCount = 0,
earned = 0,
totalPoints = 0;
QUESTIONS.forEach((q, i) => {
totalPoints += q.points;
const ans = state.answers[i];
if (ans.length === 0) {
skipCount++;
} else if (arraysEqualAsSets(ans, q.correct)) {
correctCount++;
earned += q.points;
} else {
wrongCount++;
}
});
const pct = Math.round((earned / totalPoints) * 100);
showScore({ pct, earned, totalPoints, correctCount, wrongCount, skipCount });
}
/* ===== Score screen ===== */
function showScore(r) {
el.quizHead.style.display = "none";
el.quizBody.style.display = "none";
el.submitBar.style.display = "none";
el.scoreScreen.hidden = false;
window.scrollTo({ top: 0, behavior: "smooth" });
const pass = r.pct >= 70;
const ringFg = document.getElementById("ringFg");
const circ = 2 * Math.PI * 52;
document.getElementById("ringPct").textContent = r.pct + "%";
document.getElementById("scoreVerdict").textContent = pass ? "Assessment passed" : "Almost there";
document.getElementById("scoreLine").textContent = pass
? "Nice work, Priya — you've cleared the Module 4 checkpoint."
: "You need 70% to pass. Review the explanations and retake the quiz.";
ringFg.style.stroke = pass ? "var(--accent)" : "var(--amber)";
requestAnimationFrame(() => {
ringFg.style.strokeDashoffset = String(circ * (1 - r.pct / 100));
});
document.getElementById("scoreStats").innerHTML =
stat("good", r.correctCount, "Correct") +
stat("bad", r.wrongCount, "Incorrect") +
stat("skip", r.skipCount, "Skipped") +
stat("", r.earned + "/" + r.totalPoints, "Points");
buildReview();
toast(pass ? "Passed with " + r.pct + "%" : "Scored " + r.pct + "%", pass ? "ok" : "warn");
}
function stat(cls, value, label) {
return '<div class="stat ' + cls + '"><b>' + value + "</b><span>" + label + "</span></div>";
}
function buildReview() {
const list = document.getElementById("reviewList");
list.innerHTML = "";
QUESTIONS.forEach((q, i) => {
const ans = state.answers[i];
let status;
if (ans.length === 0) status = "unanswered";
else if (arraysEqualAsSets(ans, q.correct)) status = "correct";
else status = "wrong";
const li = document.createElement("li");
li.className = "review-item " + status;
const statusLabel = { correct: "Correct", wrong: "Incorrect", unanswered: "Skipped" }[status];
const yourText =
ans.length === 0
? "No answer"
: ans.map((oi) => q.options[oi]).join(", ");
const rightText = q.correct.map((oi) => q.options[oi]).join(", ");
let inner = "";
inner += '<div class="ri-head">';
inner += '<span class="ri-q">Q' + (i + 1) + "</span>";
inner += '<span class="ri-status ' + status + '">' + statusLabel + "</span>";
inner += "</div>";
inner += '<p class="ri-text">' + escapeHtml(q.text) + "</p>";
inner += '<div class="ri-answers">';
inner +=
'<div class="ri-row your' + (status === "wrong" ? " is-wrong" : "") + '">' +
'<span class="tag">Your answer</span><span>' + escapeHtml(yourText) + "</span></div>";
if (status !== "correct") {
inner +=
'<div class="ri-row right"><span class="tag">Correct</span><span>' +
escapeHtml(rightText) + "</span></div>";
}
inner += "</div>";
inner += '<p class="ri-explain">' + escapeHtml(q.explain) + "</p>";
li.innerHTML = inner;
list.appendChild(li);
});
}
/* ===== Restart / continue ===== */
document.getElementById("restartBtn").addEventListener("click", () => {
state.current = 0;
state.answers = QUESTIONS.map(() => []);
state.flagged = QUESTIONS.map(() => false);
state.submitted = false;
state.timeLeft = TIME_LIMIT;
el.scoreScreen.hidden = true;
el.quizHead.style.display = "";
el.quizBody.style.display = "";
el.submitBar.style.display = "";
document.getElementById("ringFg").style.strokeDashoffset = String(2 * Math.PI * 52);
window.scrollTo({ top: 0, behavior: "smooth" });
renderQuestion();
startTimer();
toast("Quiz reset — good luck!");
});
document.getElementById("continueBtn").addEventListener("click", () => {
toast("Returning to course…", "ok");
});
/* ===== Utils ===== */
function escapeHtml(str) {
return String(str).replace(/[&<>"']/g, (c) => ({
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
}[c]));
}
/* ===== Init ===== */
buildNav();
renderQuestion();
startTimer();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>LMS — Quiz / Assessment</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="shell">
<!-- ===== Quiz header ===== -->
<header class="quiz-head" aria-label="Quiz progress">
<div class="head-top">
<div class="course">
<span class="course-badge" aria-hidden="true">JS</span>
<div class="course-meta">
<p class="course-name">JavaScript Foundations</p>
<p class="course-sub">Module 4 · Checkpoint Assessment</p>
</div>
</div>
<div class="head-pills">
<span class="pill pill-level" title="Difficulty">
<span class="dot"></span> Intermediate
</span>
<span class="pill pill-timer" id="timerPill" aria-live="polite">
<svg viewBox="0 0 24 24" aria-hidden="true" width="15" height="15"><path d="M12 7v5l3 2M12 21a8 8 0 1 0 0-16 8 8 0 0 0 0 16Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span id="timerText">10:00</span>
</span>
</div>
</div>
<div class="progress-row">
<div class="progress-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" id="progressBar">
<div class="progress-fill" id="progressFill"></div>
</div>
<span class="progress-label" id="progressLabel">0 of 5 answered</span>
</div>
</header>
<main class="quiz-body">
<!-- ===== Question dots / navigator ===== -->
<nav class="qnav" id="qnav" aria-label="Question navigator"></nav>
<!-- ===== Question runner ===== -->
<section class="question-card" id="questionCard" aria-live="polite">
<!-- injected by JS -->
</section>
<!-- ===== Footer controls ===== -->
<div class="runner-foot">
<button class="btn btn-ghost" id="prevBtn">
<svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true"><path d="M15 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
Previous
</button>
<button class="btn btn-flag" id="flagBtn" aria-pressed="false">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M5 21V4m0 0h11l-2 4 2 4H5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span id="flagLabel">Flag for review</span>
</button>
<button class="btn btn-primary" id="nextBtn">
Next
<svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true"><path d="M9 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
</main>
<!-- ===== Score screen (hidden until submit) ===== -->
<section class="score-screen" id="scoreScreen" hidden>
<div class="score-hero">
<div class="ring-wrap">
<svg class="score-ring" viewBox="0 0 120 120" aria-hidden="true">
<circle class="ring-bg" cx="60" cy="60" r="52" />
<circle class="ring-fg" id="ringFg" cx="60" cy="60" r="52" />
</svg>
<div class="ring-center">
<span class="ring-pct" id="ringPct">0%</span>
<span class="ring-sub" id="ringSub">score</span>
</div>
</div>
<div class="score-summary">
<p class="score-verdict" id="scoreVerdict">—</p>
<p class="score-line" id="scoreLine">—</p>
<div class="score-stats" id="scoreStats"></div>
</div>
</div>
<h2 class="review-title">Question review</h2>
<ul class="review-list" id="reviewList"></ul>
<div class="score-actions">
<button class="btn btn-ghost" id="restartBtn">Retake quiz</button>
<button class="btn btn-primary" id="continueBtn">Continue course</button>
</div>
</section>
<!-- ===== Submit confirm bar ===== -->
<div class="submit-bar" id="submitBar">
<span id="submitHint">You can submit at any time.</span>
<button class="btn btn-submit" id="submitBtn">Submit assessment</button>
</div>
</div>
<!-- ===== Confirm modal ===== -->
<div class="modal-overlay" id="modalOverlay" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<h3 id="modalTitle">Submit your assessment?</h3>
<p id="modalBody">You still have unanswered questions. You can review them before submitting.</p>
<div class="modal-actions">
<button class="btn btn-ghost" id="modalCancel">Keep working</button>
<button class="btn btn-submit" id="modalConfirm">Submit now</button>
</div>
</div>
</div>
<div class="toast-wrap" id="toastWrap" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Quiz / Assessment
A focused quiz runner for an LMS module checkpoint. A sticky header pairs the course badge with a difficulty pill, a tabular countdown timer that turns red in the final minute, and a gradient progress bar tracking how many questions are answered. Below it, a navigator strip of numbered dots lets learners jump to any question, with subtle markers for answered and flagged items.
Each question renders one at a time and adapts to its type: single-select shows radio marks, multi-select shows checkboxes with a “select all that apply” hint, and true/false collapses to two options. Learners can move with Previous and Next buttons or the arrow keys, and flag any item for review. A submit bar stays pinned to the bottom and opens a confirmation modal that warns about unanswered questions.
On submit, the runner scores answers as sets — multi-select needs an exact match — then swaps in a results screen with an animated SVG score ring, a pass or fail verdict, point totals, and a color-coded review list. Every question is replayed with the learner’s choice, the correct answer, and an explanation. Retake resets timer, answers, and flags. Toasts confirm flagging, time warnings, and the final result.
Illustrative UI only — fictional courses, not a real learning platform.