Skip to content

Commit 2cd4493

Browse files
committed
feat(practice): modern redesign — Geist typography + per-cert gradients + answer-reveal effects
Big design pass addressing user feedback during local testing: Typography fix — "ฟ้อนต์อ่านยากอ่ะ + monospace ไม่เข้ากัน": - Drop Instrument Serif / Bricolage Grotesque (display serifs that fought with inline code spans). - Switch to **Geist + Geist Mono** — Vercel's sans+mono designed together with matching x-height and proportions, so inline `code` spans now blend smoothly into question stems instead of looking glued on. Topic-bug fix — "topic คือ Question 2": - All 12 mock-exam banks have title = "Question N" placeholders (source markdown headings `## Question N *(Difficulty)*` carry no real title). - Audit confirmed: 6 practice banks have 0 fallback titles; 12 mock banks have 100 % fallback titles. - Fix: suppress the Topic line in the feedback panel when title matches /^Question \d+$/. The running head already shows the domain, which is the meaningful context for mock questions. Modern redesign — "อยากให้ดูทันสมัย น่าสนใจกว่านี้": - Per-cert accent gradients on cert cards (DE → coral, DE Pro → indigo, DA → emerald, ML → purple-pink, ML Pro → amber-red, GenAI → cyan-blue) — top edge gradient bar + blurred halo on hover. Each cert has a recognisable visual identity. - Big tabular-figures question count (2.5rem) as the dominant element on cert cards — confident, scannable. - Cards: subtle lift on hover, glow halo behind, top gradient strip. - Pill-shaped primary buttons with hover-shadow + accent-coloured glow. - Larger, more confident H2 (clamp 1.75-2.5rem, -0.03em tracking). Answer-reveal effects — "เพิ่ม effect ตอนตอบถูก/ผิด": - Correct answer: green pulse + 0-6px glow ring + brief card-bg flash to positive-soft + floating "+1" rising from the choice. - Incorrect answer: 6-step horizontal shake on the user's wrong choice + brief card-bg flash to negative-soft. - Feedback panel slides down from above when revealed (0.35s). - Streak toast pill appears at 3/5/7/10/+5 consecutive corrects ("3 in a row · keep going") — top of screen, 1.8s, accent-coloured number badge. Resets on first wrong answer. - All animations gated behind prefers-reduced-motion: no-preference. Other: - Cache version bumped to ?v=8 across CSS, JS, and JSON fetches. - Drop paper-grain texture (didn't suit the modern aesthetic). - Theme toggle: remove old emoji icon span (was overlapping with the new dot indicator), use pure CSS ::before dot.
1 parent 90069be commit 2cd4493

3 files changed

Lines changed: 1119 additions & 398 deletions

File tree

practice/app.js

Lines changed: 107 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,13 @@
2626

2727
// Bump on every deploy that changes app.js / data/*.json. Appended to
2828
// bank-JSON fetch URLs so browsers don't serve stale banks after a deploy.
29-
const APP_VERSION = "5";
29+
const APP_VERSION = "8";
30+
31+
// Title patterns that are placeholder fallbacks (mock-exam questions whose
32+
// source heading is `## Question N *(Difficulty)*` with no real title text).
33+
// We suppress these in the post-submit "Topic" line so it doesn't read
34+
// "Topic: Question 5" — useless context.
35+
const FALLBACK_TITLE_RE = /^Question \d+(\.\d+)?$/i;
3036

3137
// Bank groups render as labelled sections in the picker.
3238
const CERTS = [
@@ -66,6 +72,7 @@
6672
},
6773
sequentialIndex: 0,
6874
certBanks: null, // Map<certKey, items[]> from loadAllBankMetadata, cached on first load
75+
streak: 0, // consecutive-correct counter for the streak toast
6976
};
7077

7178
// --- DOM helpers ---------------------------------------------------------
@@ -467,12 +474,11 @@
467474
STATE.currentChoice = letter;
468475
$("#btn-submit").disabled = false;
469476
} });
470-
const choiceTextSpan = el("span");
477+
const choiceTextSpan = el("span", { className: "choice-text" });
471478
choiceTextSpan.appendChild(renderInlineToFragment(q.choices[letter]));
472479
const label = el("label", { dataset: { letter } },
473480
radio,
474-
el("span", { className: "choice-letter" }, letter + ")"),
475-
document.createTextNode(" "),
481+
el("span", { className: "choice-letter" }, letter),
476482
choiceTextSpan);
477483
choices.appendChild(label);
478484
}
@@ -499,35 +505,85 @@
499505
`Bank: ${attempted} / ${total} attempted · ${correctAll} currently correct on most-recent attempt`;
500506
}
501507

508+
function showStreakToast(n) {
509+
const existing = document.querySelector(".streak-toast");
510+
if (existing) existing.remove();
511+
const toast = el("div", { className: "streak-toast" },
512+
el("span", { className: "streak-num" }, String(n)),
513+
"in a row · keep going");
514+
document.body.appendChild(toast);
515+
// Force reflow then trigger animation
516+
void toast.offsetWidth;
517+
toast.classList.add("show");
518+
setTimeout(() => toast.remove(), 1800);
519+
}
520+
521+
function showFloatPlus(label) {
522+
const plus = el("span", { className: "float-plus" }, "+1");
523+
label.style.position = label.style.position || "relative";
524+
label.appendChild(plus);
525+
setTimeout(() => plus.remove(), 1200);
526+
}
527+
502528
function submitAnswer() {
503529
if (!STATE.currentChoice) return;
504530
const q = STATE.currentQ;
505531
const correct = STATE.currentChoice === q.correctAnswer;
506532
recordAttempt(q.id, correct);
507533
STATE.sessionTotal++;
508-
if (correct) STATE.sessionCorrect++;
534+
if (correct) {
535+
STATE.sessionCorrect++;
536+
STATE.streak++;
537+
} else {
538+
STATE.streak = 0;
539+
}
509540

541+
let correctLabel = null;
510542
for (const label of $("#quiz-choices").children) {
511543
const letter = label.dataset.letter;
512544
const radio = label.querySelector("input");
513545
radio.disabled = true;
514546
label.classList.add("disabled");
515-
if (letter === q.correctAnswer) label.classList.add("correct");
516-
else if (letter === STATE.currentChoice && !correct) label.classList.add("incorrect");
547+
if (letter === q.correctAnswer) {
548+
label.classList.add("correct");
549+
correctLabel = label;
550+
} else if (letter === STATE.currentChoice && !correct) {
551+
label.classList.add("incorrect");
552+
}
553+
}
554+
555+
// Card-level flash + optional rewards
556+
const card = document.querySelector(".question-card");
557+
if (card) {
558+
card.classList.remove("flash-correct", "flash-incorrect");
559+
void card.offsetWidth; // restart animation
560+
card.classList.add(correct ? "flash-correct" : "flash-incorrect");
561+
setTimeout(() => card.classList.remove("flash-correct", "flash-incorrect"), 700);
562+
}
563+
if (correct && correctLabel) {
564+
showFloatPlus(correctLabel);
565+
// Toast at 3, 5, 7, 10, every 5 thereafter
566+
if (STATE.streak === 3 || STATE.streak === 5 || STATE.streak === 7
567+
|| STATE.streak === 10 || (STATE.streak > 10 && STATE.streak % 5 === 0)) {
568+
showStreakToast(STATE.streak);
569+
}
517570
}
518571

519572
const fb = $("#quiz-feedback");
520573
fb.hidden = false;
521574
fb.className = correct ? "correct" : "incorrect";
522575
clear(fb);
523576
fb.appendChild(el("h4", {},
524-
correct ? "✓ Correct" : `✗ Incorrect — correct answer: ${q.correctAnswer}`));
525-
if (q.title) {
577+
correct ? "✓ Correct" : `✗ Incorrect · Correct answer: ${q.correctAnswer}`));
578+
// Suppress the Topic line when the title is a placeholder fallback
579+
// ("Question 5") — the running head already shows the domain, which is
580+
// the meaningful context for mock-exam questions.
581+
if (q.title && !FALLBACK_TITLE_RE.test(q.title)) {
526582
fb.appendChild(el("p", { className: "fb-topic" },
527-
el("strong", {}, "Topic: "), q.title));
583+
"Topic — ", q.title));
528584
}
529585
if (q.shortAnswer) {
530-
const p = el("p");
586+
const p = el("p", { className: "fb-short" });
531587
p.appendChild(renderInlineToFragment(q.shortAnswer));
532588
fb.appendChild(p);
533589
}
@@ -667,8 +723,10 @@
667723

668724
function applyTheme(theme) {
669725
document.documentElement.setAttribute("data-theme", theme);
670-
const iconNode = $("#btn-theme-icon");
671-
if (iconNode) iconNode.textContent = THEME_ICONS[theme];
726+
const labelNode = $("#btn-theme-label");
727+
if (labelNode) {
728+
labelNode.textContent = theme.charAt(0).toUpperCase() + theme.slice(1);
729+
}
672730
}
673731

674732
function cycleTheme() {
@@ -680,6 +738,40 @@
680738

681739
// --- Init ----------------------------------------------------------------
682740

741+
function handleKeydown(ev) {
742+
// Only react when the quiz section is visible
743+
if ($("#quiz").hidden) return;
744+
// Ignore when the user is typing inside an input (defensive — we don't
745+
// currently have any text inputs in the quiz, but cheap safety)
746+
if (ev.target.matches && ev.target.matches("input, textarea, select")) return;
747+
748+
const keyMap = { "1": "A", "2": "B", "3": "C", "4": "D",
749+
"a": "A", "b": "B", "c": "C", "d": "D" };
750+
const letter = keyMap[ev.key.toLowerCase()];
751+
if (letter && !$("#btn-next").hidden === false) {
752+
// After submit; ignore choice keys until next question
753+
return;
754+
}
755+
if (letter) {
756+
const radio = document.querySelector(
757+
`fieldset#quiz-choices input[value="${letter}"]`);
758+
if (radio && !radio.disabled) {
759+
radio.checked = true;
760+
radio.dispatchEvent(new Event("change", { bubbles: true }));
761+
ev.preventDefault();
762+
}
763+
return;
764+
}
765+
if (ev.key === "Enter") {
766+
if (!$("#btn-next").hidden) {
767+
$("#btn-next").click();
768+
} else if (!$("#btn-submit").disabled && !$("#btn-submit").hidden) {
769+
$("#btn-submit").click();
770+
}
771+
ev.preventDefault();
772+
}
773+
}
774+
683775
function init() {
684776
// Apply persisted theme before anything renders so there's no flash
685777
applyTheme(loadTheme());
@@ -701,6 +793,8 @@
701793
});
702794
$("#btn-theme").addEventListener("click", cycleTheme);
703795

796+
document.addEventListener("keydown", handleKeydown);
797+
704798
loadAllBankMetadata().then(certBanks => {
705799
STATE.certBanks = certBanks;
706800
renderCertPicker(certBanks);

0 commit comments

Comments
 (0)