From 7a44821e56b3fddd8bdae75772629951500656a6 Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Thu, 21 May 2026 21:09:29 +0700 Subject: [PATCH 01/33] feat(practice): mock exams + auto-rebuild on cert markdown edits (931 q / 18 banks) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 12 full-length mock-exam banks alongside the 6 practice banks. Source files are the existing certifications//resources/mock-exam{,-2}/questions.md already used for the mock-exam study material in this repo — no parallel content to maintain. Bank totals (was 364 / 6): Practice (6 banks) 364 q Mock Exam 1 (6 banks) 286 q Mock Exam 2 (6 banks) 281 q ──── 931 q across 18 banks Builder: - build_mock(cert, exam_n) parses mock-exam questions.md, pre-scanning for `## (Questions X-Y)` section headings to build a qnum→domain map, then reuses parse_questions() and overlays the per-question domain - --kind {practice,mock,all} flag for selective builds (default all) UI: - KNOWN_BANKS now declared via spread from a CERTS array (one source, three groups: practice, mock-1, mock-2) - Bank picker groups cards under labelled section headings ("Practice questions — drill by topic" / "Mock exams — full-length") - Two-column grid layout on screens ≥ 700 px (was single column) Auto-rebuild + auto-deploy from cert markdown edits: - deploy-practice.yml now triggers on changes to: - practice/** - certifications/**/practice-questions/** - certifications/**/mock-exam/** - certifications/**/mock-exam-2/** - Workflow runs `python3 practice/build.py` BEFORE uploading the Pages artifact, so the live JSON always reflects current cert markdown — no need to re-run build.py locally before pushing. ubuntu-latest already ships python3, so no setup-python step needed. Cache versioning bumped to ?v=4 on app.js, styles.css, and JSON fetches. Verification: - python3 practice/build.py --check → 18 banks / 931 q parsed cleanly - JSON shape validated across all 18 banks (correctAnswer in A-D, all 4 choices present, difficulty in {easy,medium,hard}) - node --check passes on app.js - YAML syntax valid on deploy-practice.yml - markdownlint passes on touched md --- .github/workflows/deploy-practice.yml | 12 + CHANGELOG.md | 49 + README.md | 4 +- practice/README.md | 49 +- practice/app.js | 61 +- practice/build.py | 141 ++- .../data/data-analyst-associate-mock-1.json | 762 ++++++++++++ .../data/data-analyst-associate-mock-2.json | 762 ++++++++++++ .../data/data-engineer-associate-mock-1.json | 762 ++++++++++++ .../data/data-engineer-associate-mock-2.json | 762 ++++++++++++ .../data-engineer-professional-mock-1.json | 1062 +++++++++++++++++ .../data-engineer-professional-mock-2.json | 1014 ++++++++++++++++ .../data/genai-engineer-associate-mock-1.json | 756 ++++++++++++ .../data/genai-engineer-associate-mock-2.json | 708 +++++++++++ practice/data/ml-associate-mock-1.json | 724 +++++++++++ practice/data/ml-associate-mock-2.json | 740 ++++++++++++ practice/data/ml-professional-mock-1.json | 756 ++++++++++++ practice/data/ml-professional-mock-2.json | 756 ++++++++++++ practice/index.html | 4 +- practice/styles.css | 20 +- 20 files changed, 9860 insertions(+), 44 deletions(-) create mode 100644 practice/data/data-analyst-associate-mock-1.json create mode 100644 practice/data/data-analyst-associate-mock-2.json create mode 100644 practice/data/data-engineer-associate-mock-1.json create mode 100644 practice/data/data-engineer-associate-mock-2.json create mode 100644 practice/data/data-engineer-professional-mock-1.json create mode 100644 practice/data/data-engineer-professional-mock-2.json create mode 100644 practice/data/genai-engineer-associate-mock-1.json create mode 100644 practice/data/genai-engineer-associate-mock-2.json create mode 100644 practice/data/ml-associate-mock-1.json create mode 100644 practice/data/ml-associate-mock-2.json create mode 100644 practice/data/ml-professional-mock-1.json create mode 100644 practice/data/ml-professional-mock-2.json diff --git a/.github/workflows/deploy-practice.yml b/.github/workflows/deploy-practice.yml index 2d89a95..636354a 100644 --- a/.github/workflows/deploy-practice.yml +++ b/.github/workflows/deploy-practice.yml @@ -5,6 +5,9 @@ on: branches: [main] paths: - 'practice/**' + - 'certifications/**/practice-questions/**' + - 'certifications/**/mock-exam/**' + - 'certifications/**/mock-exam-2/**' - '.github/workflows/deploy-practice.yml' workflow_dispatch: @@ -29,6 +32,15 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Rebuild question banks from current cert markdown + # Source of truth is certifications/**/{practice-questions,mock-exam,mock-exam-2}/*.md. + # We regenerate practice/data/*.json on every deploy so the live site + # always reflects the latest committed cert content, even if the + # author forgot to re-run build.py locally before pushing. + # build.py uses Python 3 stdlib only — no setup-python step needed + # because ubuntu-latest ships with python3 pre-installed. + run: python3 practice/build.py + - name: Setup Pages uses: actions/configure-pages@v5 diff --git a/CHANGELOG.md b/CHANGELOG.md index f68f6e6..9398cb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,55 @@ Notable changes to the Databricks Certification Study Guide. The format is loosely based on [Keep a Changelog](https://keepachangelog.com/). Dates use ISO 8601. Each section is grouped under the date the change shipped, with the Databricks exam-guide version each affected certification tracks. +## [2026.05.21-24] — Mock exams in practice quiz + auto-rebuild on source edits (931 q / 18 banks) + +### Added + +- **12 mock exam banks** in the practice quiz — each cert now has two full-length, exam-feel mock banks alongside its topic-organised practice bank: + + | Cert | Practice | Mock 1 | Mock 2 | + | :--- | :---: | :---: | :---: | + | Data Engineer Associate | 85 | 45 | 45 | + | Data Engineer Professional | 73 | 63 | 60 | + | Data Analyst Associate | 57 | 45 | 45 | + | ML Associate | 46 | 43 | 44 | + | ML Professional | 57 | 45 | 45 | + | GenAI Engineer Associate | 46 | 45 | 42 | + | **Total** | **364** | **286** | **281** | + + Grand total: **931 questions across 18 banks**. + +- **Grouped bank picker** in the quiz UI: practice and mock banks render under labelled section headings ("Practice questions — drill by topic" and "Mock exams — full-length, exam-feel sets") so the user can choose intent before picking a cert. Two-column grid on screens ≥ 700 px wide. + +### Changed — `practice/build.py` + +- New `build_mock(cert, exam_n)` function parses `certifications//resources/mock-exam{,-2}/questions.md`. Pre-scans the file linearly to map each `## Question N` heading to the most recent `## (Questions X-Y)` domain section, then reuses the existing `parse_questions()` and overlays the per-question domain. Output JSON includes `kind: "mock"` and `sourceCert: ` fields for downstream tooling. +- New `--kind {practice,mock,all}` flag (default `all`) lets you build just one kind. `--cert ` still filters to a single cert across both kinds. + +### Changed — auto-rebuild + auto-deploy from cert markdown edits + +`.github/workflows/deploy-practice.yml` now: + +- Triggers on push to `main` whenever ANY of these change: + - `practice/**` + - `certifications/**/practice-questions/**` + - `certifications/**/mock-exam/**` + - `certifications/**/mock-exam-2/**` + - the workflow file itself +- Runs `python3 practice/build.py` as a step before uploading the Pages artifact so the live JSON is always fresh from current cert markdown — no need to run `build.py` locally and re-commit before pushing. + +The committed `practice/data/*.json` files stay (for local dev convenience and quick PR diff review) but are no longer the source of truth at deploy time. Edit a question, push, the live site reflects it within ~1 min. + +### Changed — UI cache versioning + +`APP_VERSION` bumped to `"4"`. ` + diff --git a/practice/styles.css b/practice/styles.css index fef0bda..e5255cb 100644 --- a/practice/styles.css +++ b/practice/styles.css @@ -184,10 +184,28 @@ button.danger { button.danger:hover { background: var(--color-incorrect-bg); } /* Setup */ +#setup h3.bank-group-heading { + margin: 1.5rem 0 0.4rem 0; + font-size: 0.85rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-muted); +} +#setup h3.bank-group-heading:first-of-type { + margin-top: 1rem; +} + #setup .bank-list { display: grid; gap: 0.75rem; - margin-top: 1rem; + grid-template-columns: 1fr; +} + +@media (min-width: 700px) { + #setup .bank-list { + grid-template-columns: 1fr 1fr; + } } #setup .bank-card { From 90069beeb7f5f53089af600fbbe1da368ae56574 Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Thu, 21 May 2026 21:19:45 +0700 Subject: [PATCH 02/33] =?UTF-8?q?feat(practice):=20two-step=20picker=20(ce?= =?UTF-8?q?rt=20=E2=86=92=20bank)=20+=20Prism=20syntax=20highlight?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactors the bank picker from a flat 18-card grid into a two-step flow: Step 1: Pick a certification (6 cards, one per cert) Each card shows cert name + total questions across its banks + blueprint date + a red accent bar on the left Step 2: Pick a bank for that cert (Practice / Mock 1 / Mock 2 — 3 cards in a row on desktop) Includes a "← All certifications" back button This matches how candidates actually think about cert prep — they pick the cert first, then decide between topic drill or full mock. Quiz UI now also has syntax highlighting for code blocks via Prism.js (CDN, data-manual mode so we control when to highlight). Supported languages: python, sql, scala (covers everything in the existing question banks). Tokens override Prism's prism-tomorrow theme on light mode so colors don't glow against a light code-block background. Theme variables extended: - --color-code-bg (separate from --color-card-bg so code blocks have a distinctive subtle tint per theme) - Light-mode token colors hand-picked to read against soft backgrounds - Dark mode keeps Prism's bundled colors State changes: - STATE.certBanks caches the Map from loadAllBankMetadata so re-entering the cert picker from the quiz doesn't refetch - "Switch bank" in the quiz now returns to the cert picker (step 1), not the full page reload Other: - APP_VERSION bumped to 5 for cache invalidation - New cert-card / back-button / cert-subtitle / bank-purpose styles --- practice/app.js | 165 +++++++++++++++++++++++++++++++++----------- practice/index.html | 12 +++- practice/styles.css | 154 ++++++++++++++++++++++++++++++++++++----- 3 files changed, 273 insertions(+), 58 deletions(-) diff --git a/practice/app.js b/practice/app.js index b3306cf..c702a07 100644 --- a/practice/app.js +++ b/practice/app.js @@ -26,7 +26,7 @@ // Bump on every deploy that changes app.js / data/*.json. Appended to // bank-JSON fetch URLs so browsers don't serve stale banks after a deploy. - const APP_VERSION = "4"; + const APP_VERSION = "5"; // Bank groups render as labelled sections in the picker. const CERTS = [ @@ -65,6 +65,7 @@ difficulty: "", }, sequentialIndex: 0, + certBanks: null, // Map from loadAllBankMetadata, cached on first load }; // --- DOM helpers --------------------------------------------------------- @@ -133,15 +134,15 @@ const FENCE_OPEN_RE = /^\s*```(\w*)\s*$/; const FENCE_CLOSE_RE = /^\s*```\s*$/; + const PRISM_KNOWN_LANGS = new Set(["python", "sql", "scala", "javascript", "json", "bash"]); function renderMarkdown(s) { const frag = document.createDocumentFragment(); const lines = s.split("\n"); let buffer = []; + const pendingHighlights = []; // nodes to syntax-highlight after insertion const flushParagraphs = () => { - // `buffer` is a slab of lines; split it into paragraphs by blank lines - // and render each as a

with inline formatting +
for newlines. let para = []; const emit = () => { if (para.length === 0) return; @@ -166,17 +167,25 @@ const fence = lines[i].match(FENCE_OPEN_RE); if (fence) { flushParagraphs(); + const lang = (fence[1] || "").toLowerCase(); const codeLines = []; i++; while (i < lines.length && !FENCE_CLOSE_RE.test(lines[i])) { codeLines.push(lines[i]); i++; } - // Skip the closing fence (if present) - if (i < lines.length) i++; + if (i < lines.length) i++; // skip closing fence + const code = el("code"); code.textContent = codeLines.join("\n"); + if (lang && PRISM_KNOWN_LANGS.has(lang)) { + code.className = "language-" + lang; + pendingHighlights.push(code); + } const pre = el("pre"); + if (lang && PRISM_KNOWN_LANGS.has(lang)) { + pre.className = "language-" + lang; + } pre.appendChild(code); frag.appendChild(pre); continue; @@ -185,6 +194,16 @@ i++; } flushParagraphs(); + + // Run Prism.highlightElement after each code block is in the fragment. + // Prism reads textContent on the node, so the node needs to exist (it does, + // inside the fragment) but doesn't need to be in the live DOM yet. + if (window.Prism && pendingHighlights.length) { + for (const code of pendingHighlights) { + try { window.Prism.highlightElement(code); } + catch (_) { /* Prism failure shouldn't break the quiz */ } + } + } return frag; } @@ -200,15 +219,29 @@ return file + (file.includes("?") ? "&" : "?") + "v=" + APP_VERSION; } - async function probeBanks() { - const available = []; - for (const b of KNOWN_BANKS) { + async function loadAllBankMetadata() { + // Fetch every known bank's JSON in parallel; group by sourceCert. + // Returns Map preserving CERTS order. + const results = await Promise.all(KNOWN_BANKS.map(async (b) => { try { - const res = await fetch(bustedUrl(b.file), { method: "HEAD", cache: "no-cache" }); - if (res.ok) available.push(b); - } catch (_) { /* file missing — skip */ } + const res = await fetch(bustedUrl(b.file), { cache: "no-cache" }); + if (!res.ok) return null; + const data = await res.json(); + return { bank: b, data }; + } catch (_) { return null; } + })); + const byCert = new Map(); + for (const c of CERTS) byCert.set(c, []); + for (const r of results) { + if (!r) continue; + const sourceCert = r.data.sourceCert || r.data.cert; + if (byCert.has(sourceCert)) byCert.get(sourceCert).push(r); + } + // Drop certs with no available banks + for (const c of CERTS) { + if (byCert.get(c).length === 0) byCert.delete(c); } - return available; + return byCert; } async function loadBank(certInfo) { @@ -222,11 +255,28 @@ show("quiz"); } - function renderSetup(available) { + function bankTypeLabel(item) { + if (item.data.kind === "mock") { + const m = item.data.certTitle.match(/—\s*(Mock Exam \d+)/i); + return m ? m[1] : "Mock Exam"; + } + return "Practice questions"; + } + + function bankTypeSubtitle(item) { + return item.data.kind === "mock" + ? "full-length · all domains" + : "drill by topic · adaptive"; + } + + // --- Step 1: certification picker ---------------------------------------- + + function renderCertPicker(certBanks) { const setup = $("#setup"); clear(setup); - setup.appendChild(el("h2", {}, "Pick a question bank")); - if (available.length === 0) { + setup.appendChild(el("h2", {}, "Pick a certification")); + + if (certBanks.size === 0) { const p = el("p", {}, "No JSON banks found under practice/data/. Run "); p.appendChild(el("code", {}, "python3 practice/build.py")); p.appendChild(document.createTextNode(" first.")); @@ -234,31 +284,61 @@ return; } - // Group available banks by .group field while preserving insertion order - const groups = new Map(); - for (const b of available) { - const key = b.group || "practice"; - if (!groups.has(key)) groups.set(key, []); - groups.get(key).push(b); + const list = el("div", { className: "cert-list" }); + for (const [certKey, items] of certBanks) { + // Use the practice bank for cert metadata; fall back to first available + const practice = items.find(it => it.data.kind !== "mock") || items[0]; + const title = practice.data.certTitle.replace(/\s*—\s*Mock Exam \d+$/i, ""); + const blueprint = practice.data.blueprintVersion; + const totalQ = items.reduce((sum, it) => sum + it.data.questions.length, 0); + const bankCount = items.length; + + const card = el("button", { type: "button", className: "cert-card", + onclick: () => renderBankPicker(certKey, items) }); + card.appendChild(el("strong", {}, title)); + card.appendChild(el("div", { className: "cert-card-meta" }, + `${bankCount} bank${bankCount !== 1 ? "s" : ""} · ${totalQ} questions total`)); + card.appendChild(el("div", { className: "cert-card-blueprint" }, + `Blueprint ${blueprint}`)); + list.appendChild(card); } + setup.appendChild(list); + } - for (const [groupKey, banks] of groups) { - setup.appendChild(el("h3", { className: "bank-group-heading" }, - GROUP_LABELS[groupKey] || groupKey)); - const list = el("div", { className: "bank-list" }); - for (const b of banks) { - const button = el("button", { type: "button", className: "bank-card", - onclick: () => loadBank(b) }, b.cert); - fetch(bustedUrl(b.file), { cache: "no-cache" }).then(r => r.json()).then(d => { - clear(button); - button.appendChild(el("strong", {}, d.certTitle || d.cert)); - button.appendChild(el("div", { className: "bank-meta" }, - `${d.questions.length} questions · ${d.domains.length} domains · blueprint ${d.blueprintVersion}`)); - }).catch(() => { /* keep fallback text */ }); - list.appendChild(button); - } - setup.appendChild(list); + // --- Step 2: bank picker (per cert) -------------------------------------- + + function renderBankPicker(certKey, items) { + const setup = $("#setup"); + clear(setup); + + const practice = items.find(it => it.data.kind !== "mock") || items[0]; + const title = practice.data.certTitle.replace(/\s*—\s*Mock Exam \d+$/i, ""); + + setup.appendChild(el("button", { type: "button", className: "back-button", + onclick: () => renderCertPicker(STATE.certBanks) }, + "← All certifications")); + setup.appendChild(el("h2", {}, title)); + setup.appendChild(el("p", { className: "cert-subtitle" }, + `Blueprint ${practice.data.blueprintVersion}`)); + + // Sort items: practice first, then mock-1, then mock-2 + const sorted = [...items].sort((a, b) => { + const order = it => it.data.kind === "mock" ? (it.data.cert.endsWith("-mock-2") ? 2 : 1) : 0; + return order(a) - order(b); + }); + + const list = el("div", { className: "bank-list" }); + for (const it of sorted) { + const card = el("button", { type: "button", className: "bank-card", + onclick: () => loadBank(it.bank) }); + card.appendChild(el("strong", {}, bankTypeLabel(it))); + card.appendChild(el("div", { className: "bank-meta" }, + `${it.data.questions.length} questions · ${it.data.domains.length} domains`)); + card.appendChild(el("div", { className: "bank-purpose" }, + bankTypeSubtitle(it))); + list.appendChild(card); } + setup.appendChild(list); } // --- LocalStorage -------------------------------------------------------- @@ -614,10 +694,17 @@ $("#btn-settings").addEventListener("click", () => show("settings")); $("#btn-settings-cancel").addEventListener("click", () => show("quiz")); $("#btn-settings-apply").addEventListener("click", applySettings); - $("#btn-exit").addEventListener("click", () => location.reload()); + $("#btn-exit").addEventListener("click", () => { + if (STATE.certBanks) renderCertPicker(STATE.certBanks); + else location.reload(); + show("setup"); + }); $("#btn-theme").addEventListener("click", cycleTheme); - probeBanks().then(renderSetup); + loadAllBankMetadata().then(certBanks => { + STATE.certBanks = certBanks; + renderCertPicker(certBanks); + }); } if (document.readyState === "loading") { diff --git a/practice/index.html b/practice/index.html index 1352fdf..806c566 100644 --- a/practice/index.html +++ b/practice/index.html @@ -5,7 +5,8 @@ Databricks Certification Practice - + +

@@ -112,6 +113,13 @@

Settings

This is study material, not the official Databricks exam. Question banks are generated from the markdown practice-question files in this repo. Read the docs · Format spec

- + + + + + + diff --git a/practice/styles.css b/practice/styles.css index e5255cb..07508f5 100644 --- a/practice/styles.css +++ b/practice/styles.css @@ -183,17 +183,73 @@ button.danger { button.danger:hover { background: var(--color-incorrect-bg); } -/* Setup */ -#setup h3.bank-group-heading { - margin: 1.5rem 0 0.4rem 0; - font-size: 0.85rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; +/* Setup — Step 1: certification picker */ + +#setup .cert-list { + display: grid; + gap: 1rem; + grid-template-columns: 1fr; + margin-top: 1.25rem; +} + +@media (min-width: 700px) { + #setup .cert-list { grid-template-columns: 1fr 1fr; } +} + +#setup .cert-card { + text-align: left; + padding: 1.25rem 1.5rem; + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: var(--color-card-bg); + box-shadow: var(--shadow-card); + position: relative; + overflow: hidden; +} + +#setup .cert-card::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 4px; + background: var(--color-accent); +} + +#setup .cert-card strong { + display: block; + font-size: 1.15rem; + margin-bottom: 0.4rem; + line-height: 1.3; +} + +#setup .cert-card .cert-card-meta { + font-size: 0.92em; + color: var(--color-fg); + margin-bottom: 0.25rem; +} + +#setup .cert-card .cert-card-blueprint { + font-size: 0.85em; color: var(--color-muted); } -#setup h3.bank-group-heading:first-of-type { - margin-top: 1rem; + +/* Setup — Step 2: per-cert bank picker */ + +#setup .back-button { + font-size: 0.85em; + padding: 0.25rem 0.6rem; + border: none; + background: none; + color: var(--color-link); + margin-bottom: 0.5rem; +} + +#setup .back-button:hover { text-decoration: underline; } + +#setup .cert-subtitle { + margin: 0 0 1.25rem 0; + color: var(--color-muted); + font-size: 0.9em; } #setup .bank-list { @@ -203,9 +259,7 @@ button.danger:hover { background: var(--color-incorrect-bg); } } @media (min-width: 700px) { - #setup .bank-list { - grid-template-columns: 1fr 1fr; - } + #setup .bank-list { grid-template-columns: 1fr 1fr 1fr; } } #setup .bank-card { @@ -216,8 +270,23 @@ button.danger:hover { background: var(--color-incorrect-bg); } background: var(--color-card-bg); } -#setup .bank-card strong { display: block; font-size: 1.05rem; margin-bottom: 0.25rem; } -#setup .bank-card .bank-meta { font-size: 0.85em; color: var(--color-muted); } +#setup .bank-card strong { + display: block; + font-size: 1rem; + margin-bottom: 0.35rem; +} + +#setup .bank-card .bank-meta { + font-size: 0.85em; + color: var(--color-fg); + margin-bottom: 0.15rem; +} + +#setup .bank-card .bank-purpose { + font-size: 0.78em; + color: var(--color-muted); + font-style: italic; +} /* Quiz */ .quiz-header { @@ -424,8 +493,12 @@ code { border-radius: 3px; } -pre { - background: rgba(125,125,125,0.15); +/* Code blocks — base styling, then override Prism's heavy background. + Prism's prism-tomorrow theme paints `pre[class*=language-]` dark; we re-tint + it so light-theme users see a light code block, dark-theme users see dark. */ + +pre, pre[class*="language-"] { + background: var(--color-code-bg, rgba(125,125,125,0.12)); padding: 0.75rem 1rem; border-radius: var(--radius); overflow-x: auto; @@ -435,13 +508,60 @@ pre { line-height: 1.45; white-space: pre; border: 1px solid var(--color-border); + color: var(--color-fg); } -pre code { +pre code, pre[class*="language-"] code { background: none; padding: 0; font-size: inherit; border-radius: 0; + color: inherit; + text-shadow: none; +} + +/* Light theme: Prism's default token colors over a light background look fine, + but the bundled prism-tomorrow uses bright colors meant for dark backgrounds. + Tone token colors down on light mode so they don't glow against the soft bg. */ +:root:not([data-theme="dark"]) :not(pre) > code[class*="language-"] .token, +:root:not([data-theme="dark"]) pre[class*="language-"] .token { text-shadow: none; } + +:root[data-theme="light"] { + --color-code-bg: #f3f3f3; +} +:root[data-theme="light"] .token.comment, +:root[data-theme="light"] .token.prolog, +:root[data-theme="light"] .token.doctype, +:root[data-theme="light"] .token.cdata { color: #6a7480; } +:root[data-theme="light"] .token.punctuation { color: #4a4a4a; } +:root[data-theme="light"] .token.keyword, +:root[data-theme="light"] .token.boolean, +:root[data-theme="light"] .token.null { color: #a626a4; } +:root[data-theme="light"] .token.string, +:root[data-theme="light"] .token.char, +:root[data-theme="light"] .token.attr-value { color: #50a14f; } +:root[data-theme="light"] .token.number { color: #986801; } +:root[data-theme="light"] .token.function, +:root[data-theme="light"] .token.class-name { color: #4078f2; } +:root[data-theme="light"] .token.operator { color: #4078f2; } + +:root[data-theme="dark"] { --color-code-bg: #1a1a1a; } + +/* Auto theme: follow prefers-color-scheme */ +@media (prefers-color-scheme: light) { + :root[data-theme="auto"], :root:not([data-theme]) { + --color-code-bg: #f3f3f3; + } + :root[data-theme="auto"] .token.comment, + :root:not([data-theme]) .token.comment { color: #6a7480; } + :root[data-theme="auto"] .token.keyword, + :root:not([data-theme]) .token.keyword { color: #a626a4; } + :root[data-theme="auto"] .token.string, + :root:not([data-theme]) .token.string { color: #50a14f; } + :root[data-theme="auto"] .token.number, + :root:not([data-theme]) .token.number { color: #986801; } + :root[data-theme="auto"] .token.function, + :root:not([data-theme]) .token.function { color: #4078f2; } } #quiz-question pre, From 2cd4493307e1bc1ce79e16f39c2d0a6174febb81 Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Thu, 21 May 2026 21:34:06 +0700 Subject: [PATCH 03/33] =?UTF-8?q?feat(practice):=20modern=20redesign=20?= =?UTF-8?q?=E2=80=94=20Geist=20typography=20+=20per-cert=20gradients=20+?= =?UTF-8?q?=20answer-reveal=20effects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- practice/app.js | 120 ++++- practice/index.html | 167 +++--- practice/styles.css | 1230 ++++++++++++++++++++++++++++++++----------- 3 files changed, 1119 insertions(+), 398 deletions(-) diff --git a/practice/app.js b/practice/app.js index c702a07..fc332c3 100644 --- a/practice/app.js +++ b/practice/app.js @@ -26,7 +26,13 @@ // Bump on every deploy that changes app.js / data/*.json. Appended to // bank-JSON fetch URLs so browsers don't serve stale banks after a deploy. - const APP_VERSION = "5"; + const APP_VERSION = "8"; + + // Title patterns that are placeholder fallbacks (mock-exam questions whose + // source heading is `## Question N *(Difficulty)*` with no real title text). + // We suppress these in the post-submit "Topic" line so it doesn't read + // "Topic: Question 5" — useless context. + const FALLBACK_TITLE_RE = /^Question \d+(\.\d+)?$/i; // Bank groups render as labelled sections in the picker. const CERTS = [ @@ -66,6 +72,7 @@ }, sequentialIndex: 0, certBanks: null, // Map from loadAllBankMetadata, cached on first load + streak: 0, // consecutive-correct counter for the streak toast }; // --- DOM helpers --------------------------------------------------------- @@ -467,12 +474,11 @@ STATE.currentChoice = letter; $("#btn-submit").disabled = false; } }); - const choiceTextSpan = el("span"); + const choiceTextSpan = el("span", { className: "choice-text" }); choiceTextSpan.appendChild(renderInlineToFragment(q.choices[letter])); const label = el("label", { dataset: { letter } }, radio, - el("span", { className: "choice-letter" }, letter + ")"), - document.createTextNode(" "), + el("span", { className: "choice-letter" }, letter), choiceTextSpan); choices.appendChild(label); } @@ -499,21 +505,68 @@ `Bank: ${attempted} / ${total} attempted · ${correctAll} currently correct on most-recent attempt`; } + function showStreakToast(n) { + const existing = document.querySelector(".streak-toast"); + if (existing) existing.remove(); + const toast = el("div", { className: "streak-toast" }, + el("span", { className: "streak-num" }, String(n)), + "in a row · keep going"); + document.body.appendChild(toast); + // Force reflow then trigger animation + void toast.offsetWidth; + toast.classList.add("show"); + setTimeout(() => toast.remove(), 1800); + } + + function showFloatPlus(label) { + const plus = el("span", { className: "float-plus" }, "+1"); + label.style.position = label.style.position || "relative"; + label.appendChild(plus); + setTimeout(() => plus.remove(), 1200); + } + function submitAnswer() { if (!STATE.currentChoice) return; const q = STATE.currentQ; const correct = STATE.currentChoice === q.correctAnswer; recordAttempt(q.id, correct); STATE.sessionTotal++; - if (correct) STATE.sessionCorrect++; + if (correct) { + STATE.sessionCorrect++; + STATE.streak++; + } else { + STATE.streak = 0; + } + let correctLabel = null; for (const label of $("#quiz-choices").children) { const letter = label.dataset.letter; const radio = label.querySelector("input"); radio.disabled = true; label.classList.add("disabled"); - if (letter === q.correctAnswer) label.classList.add("correct"); - else if (letter === STATE.currentChoice && !correct) label.classList.add("incorrect"); + if (letter === q.correctAnswer) { + label.classList.add("correct"); + correctLabel = label; + } else if (letter === STATE.currentChoice && !correct) { + label.classList.add("incorrect"); + } + } + + // Card-level flash + optional rewards + const card = document.querySelector(".question-card"); + if (card) { + card.classList.remove("flash-correct", "flash-incorrect"); + void card.offsetWidth; // restart animation + card.classList.add(correct ? "flash-correct" : "flash-incorrect"); + setTimeout(() => card.classList.remove("flash-correct", "flash-incorrect"), 700); + } + if (correct && correctLabel) { + showFloatPlus(correctLabel); + // Toast at 3, 5, 7, 10, every 5 thereafter + if (STATE.streak === 3 || STATE.streak === 5 || STATE.streak === 7 + || STATE.streak === 10 || (STATE.streak > 10 && STATE.streak % 5 === 0)) { + showStreakToast(STATE.streak); + } } const fb = $("#quiz-feedback"); @@ -521,13 +574,16 @@ fb.className = correct ? "correct" : "incorrect"; clear(fb); fb.appendChild(el("h4", {}, - correct ? "✓ Correct" : `✗ Incorrect — correct answer: ${q.correctAnswer}`)); - if (q.title) { + correct ? "✓ Correct" : `✗ Incorrect · Correct answer: ${q.correctAnswer}`)); + // Suppress the Topic line when the title is a placeholder fallback + // ("Question 5") — the running head already shows the domain, which is + // the meaningful context for mock-exam questions. + if (q.title && !FALLBACK_TITLE_RE.test(q.title)) { fb.appendChild(el("p", { className: "fb-topic" }, - el("strong", {}, "Topic: "), q.title)); + "Topic — ", q.title)); } if (q.shortAnswer) { - const p = el("p"); + const p = el("p", { className: "fb-short" }); p.appendChild(renderInlineToFragment(q.shortAnswer)); fb.appendChild(p); } @@ -667,8 +723,10 @@ function applyTheme(theme) { document.documentElement.setAttribute("data-theme", theme); - const iconNode = $("#btn-theme-icon"); - if (iconNode) iconNode.textContent = THEME_ICONS[theme]; + const labelNode = $("#btn-theme-label"); + if (labelNode) { + labelNode.textContent = theme.charAt(0).toUpperCase() + theme.slice(1); + } } function cycleTheme() { @@ -680,6 +738,40 @@ // --- Init ---------------------------------------------------------------- + function handleKeydown(ev) { + // Only react when the quiz section is visible + if ($("#quiz").hidden) return; + // Ignore when the user is typing inside an input (defensive — we don't + // currently have any text inputs in the quiz, but cheap safety) + if (ev.target.matches && ev.target.matches("input, textarea, select")) return; + + const keyMap = { "1": "A", "2": "B", "3": "C", "4": "D", + "a": "A", "b": "B", "c": "C", "d": "D" }; + const letter = keyMap[ev.key.toLowerCase()]; + if (letter && !$("#btn-next").hidden === false) { + // After submit; ignore choice keys until next question + return; + } + if (letter) { + const radio = document.querySelector( + `fieldset#quiz-choices input[value="${letter}"]`); + if (radio && !radio.disabled) { + radio.checked = true; + radio.dispatchEvent(new Event("change", { bubbles: true })); + ev.preventDefault(); + } + return; + } + if (ev.key === "Enter") { + if (!$("#btn-next").hidden) { + $("#btn-next").click(); + } else if (!$("#btn-submit").disabled && !$("#btn-submit").hidden) { + $("#btn-submit").click(); + } + ev.preventDefault(); + } + } + function init() { // Apply persisted theme before anything renders so there's no flash applyTheme(loadTheme()); @@ -701,6 +793,8 @@ }); $("#btn-theme").addEventListener("click", cycleTheme); + document.addEventListener("keydown", handleKeydown); + loadAllBankMetadata().then(certBanks => { STATE.certBanks = certBanks; renderCertPicker(certBanks); diff --git a/practice/index.html b/practice/index.html index 806c566..fcf6fc5 100644 --- a/practice/index.html +++ b/practice/index.html @@ -3,123 +3,160 @@ - Databricks Certification Practice + Databricks Certification Practice — Study Companion + + + + + + + - + + -
+ + +
- + + +
+ A study companion

Databricks Certification Practice

-

Adaptive practice questions with localStorage progress tracking. View on GitHub

-
- -
+
-
+
-

Pick a question bank

-

Loading…

+

Loading question banks…

-
-

This is study material, not the official Databricks exam. Question banks are generated from the markdown practice-question files in this repo. Read the docs · Format spec

+
+

+ A study aid, not the official exam. + Question banks are generated from the markdown files in + this repository. + · + Documentation + · + MIT licensed +

- + + + - + diff --git a/practice/styles.css b/practice/styles.css index 07508f5..9ca00c5 100644 --- a/practice/styles.css +++ b/practice/styles.css @@ -1,100 +1,156 @@ -/* Theme variables. - * Default = light. `[data-theme="dark"]` forces dark. - * `[data-theme="auto"]` (default if no manual override) uses prefers-color-scheme. */ +/* ============================================================ + Databricks Certification Practice — design system + Typography: Geist (everything) · Geist Mono (code + labels) + Palette: warm neutral · vivid coral accent · per-cert gradients + ============================================================ */ :root { - --color-bg: #fafafa; - --color-fg: #1a1a1a; - --color-muted: #666; - --color-border: #d0d0d0; - --color-accent: #FF3621; - --color-correct: #1f8b4c; - --color-incorrect: #c52b2b; - --color-correct-bg: #e8f6ed; - --color-incorrect-bg: #fbeaea; - --color-card-bg: #ffffff; - --color-link: #0066cc; - --color-medium-bg: #fff2cc; - --color-medium-fg: #8a6a00; - --shadow-card: 0 1px 3px rgba(0,0,0,0.08); - --radius: 6px; - --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; - --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; -} - -/* System dark mode: only applied when theme = auto (no manual override) */ + /* Light theme */ + --bg: #FAFAF7; + --surface: #FFFFFF; + --surface-soft: #F4F3EF; + --fg: #18181B; + --fg-soft: #3F3F46; + --muted: #71717A; + --hairline: #E5E5E2; + --hairline-soft: #EFEFED; + --accent: #FF4F2C; + --accent-soft: rgba(255,79,44,0.08); + --positive: #15803D; + --positive-soft: #E0F2E7; + --negative: #B91C1C; + --negative-soft: #FBEAEA; + --code-bg: #F1F1ED; + + --shadow-1: 0 1px 0 var(--hairline); + --shadow-2: 0 1px 0 var(--hairline), 0 8px 24px -12px rgba(24,24,27,0.08); + --shadow-3: 0 1px 0 var(--hairline), 0 16px 40px -16px rgba(24,24,27,0.16); + + --sans: "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + --mono: "Geist Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + + --radius: 8px; + --radius-lg: 12px; + --content-w: 760px; + + --ease: cubic-bezier(0.32, 0.72, 0, 1); +} + @media (prefers-color-scheme: dark) { :root[data-theme="auto"], :root:not([data-theme]) { - --color-bg: #161616; - --color-fg: #f0f0f0; - --color-muted: #aaa; - --color-border: #333; - --color-card-bg: #1f1f1f; - --color-correct-bg: #16341e; - --color-incorrect-bg: #3a1717; - --color-link: #5cb3ff; - --color-medium-bg: #3a3105; - --color-medium-fg: #e7c83e; - --shadow-card: 0 1px 3px rgba(0,0,0,0.5); + --bg: #0A0A0C; + --surface: #131316; + --surface-soft: #1B1B1F; + --fg: #FAFAFA; + --fg-soft: #D4D4D8; + --muted: #8B8B95; + --hairline: #26262C; + --hairline-soft: #1D1D22; + --accent: #FF6240; + --accent-soft: rgba(255,98,64,0.12); + --positive: #4ADE80; + --positive-soft: rgba(74,222,128,0.12); + --negative: #F87171; + --negative-soft: rgba(248,113,113,0.12); + --code-bg: #1A1A1F; + + --shadow-2: 0 1px 0 var(--hairline), 0 12px 36px -16px rgba(0,0,0,0.6); + --shadow-3: 0 1px 0 var(--hairline), 0 20px 48px -18px rgba(0,0,0,0.8); } } -/* Manual dark override: works regardless of system preference */ :root[data-theme="dark"] { - --color-bg: #161616; - --color-fg: #f0f0f0; - --color-muted: #aaa; - --color-border: #333; - --color-card-bg: #1f1f1f; - --color-correct-bg: #16341e; - --color-incorrect-bg: #3a1717; - --color-link: #5cb3ff; - --color-medium-bg: #3a3105; - --color-medium-fg: #e7c83e; - --shadow-card: 0 1px 3px rgba(0,0,0,0.5); -} - -/* Manual light override: defaults already light, but explicit for completeness - so the toggle reaches a stable third state regardless of system preference */ + --bg: #0A0A0C; + --surface: #131316; + --surface-soft: #1B1B1F; + --fg: #FAFAFA; + --fg-soft: #D4D4D8; + --muted: #8B8B95; + --hairline: #26262C; + --hairline-soft: #1D1D22; + --accent: #FF6240; + --accent-soft: rgba(255,98,64,0.12); + --positive: #4ADE80; + --positive-soft: rgba(74,222,128,0.12); + --negative: #F87171; + --negative-soft: rgba(248,113,113,0.12); + --code-bg: #1A1A1F; + + --shadow-2: 0 1px 0 var(--hairline), 0 12px 36px -16px rgba(0,0,0,0.6); + --shadow-3: 0 1px 0 var(--hairline), 0 20px 48px -18px rgba(0,0,0,0.8); +} + :root[data-theme="light"] { - --color-bg: #fafafa; - --color-fg: #1a1a1a; - --color-muted: #666; - --color-border: #d0d0d0; - --color-card-bg: #ffffff; - --color-correct-bg: #e8f6ed; - --color-incorrect-bg: #fbeaea; - --color-link: #0066cc; - --color-medium-bg: #fff2cc; - --color-medium-fg: #8a6a00; - --shadow-card: 0 1px 3px rgba(0,0,0,0.08); + --bg: #FAFAF7; + --surface: #FFFFFF; + --surface-soft: #F4F3EF; + --fg: #18181B; + --fg-soft: #3F3F46; + --muted: #71717A; + --hairline: #E5E5E2; + --hairline-soft: #EFEFED; + --accent: #FF4F2C; + --accent-soft: rgba(255,79,44,0.08); + --code-bg: #F1F1ED; } +/* ---------- Base ---------- */ + * { box-sizing: border-box; } +html { -webkit-text-size-adjust: 100%; } + body { margin: 0; - font-family: var(--font-sans); - background: var(--color-bg); - color: var(--color-fg); + font-family: var(--sans); + font-weight: 400; + font-size: 16px; line-height: 1.55; + letter-spacing: -0.005em; + background: var(--bg); + color: var(--fg); + font-feature-settings: "cv11", "ss01"; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + +.skip-link { + position: absolute; + left: -9999px; + top: 0; + padding: 0.5rem 1rem; + background: var(--fg); + color: var(--bg); + font: 500 0.875rem var(--sans); + border-radius: 0 0 var(--radius) 0; } +.skip-link:focus { left: 0; z-index: 100; } -header { - padding: 1rem 1.5rem; - border-bottom: 1px solid var(--color-border); +a { + color: var(--fg); + text-decoration: underline; + text-decoration-color: var(--hairline); + text-decoration-thickness: 1px; + text-underline-offset: 3px; + transition: text-decoration-color 0.15s var(--ease); +} +a:hover { text-decoration-color: var(--accent); } + +/* Drop the paper-grain — it doesn't match the modern aesthetic */ +.paper-grain { display: none; } + +/* ---------- Masthead ---------- */ + +.masthead { + max-width: 1080px; + margin: 0 auto; + padding: 1.5rem 2rem; display: flex; justify-content: space-between; align-items: center; - gap: 1rem; -} - -footer { - padding: 1rem 1.5rem; - border-top: 1px solid var(--color-border); - font-size: 0.85em; - color: var(--color-muted); - margin-top: 2rem; + gap: 1.5rem; + border-bottom: 1px solid var(--hairline); } .brand { @@ -104,25 +160,41 @@ footer { min-width: 0; } -.brand-icon { +.brand-mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; flex-shrink: 0; - width: 40px; - height: 40px; - filter: drop-shadow(0 1px 2px rgba(0,0,0,0.15)); + color: var(--accent); + text-decoration: none; + transition: transform 0.2s var(--ease); } +.brand-mark:hover { transform: rotate(-8deg) scale(1.08); } +.brand-mark svg { width: 28px; height: 28px; display: block; } .brand-text { min-width: 0; } -.brand h1 { - margin: 0 0 0.15rem 0; - font-size: 1.35rem; - line-height: 1.2; +.brand-eyebrow { + display: block; + font-family: var(--mono); + font-size: 0.68rem; + font-weight: 500; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: 0.1rem; } -.brand .subtitle { +.brand h1 { margin: 0; - font-size: 0.85em; - color: var(--color-muted); + font-family: var(--sans); + font-weight: 600; + font-size: clamp(1.15rem, 2.5vw, 1.4rem); + letter-spacing: -0.02em; + line-height: 1.2; + color: var(--fg); } .brand-actions { @@ -132,439 +204,957 @@ footer { } #btn-theme { - font-size: 1.15rem; - padding: 0.3rem 0.55rem; - line-height: 1; + font-family: var(--mono); + font-size: 0.72rem; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; + padding: 0.5rem 0.85rem 0.5rem 0.75rem; + background: var(--surface); + color: var(--fg-soft); + border: 1px solid var(--hairline); + border-radius: 99px; + display: inline-flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + transition: all 0.15s var(--ease); +} +#btn-theme::before { + content: ""; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent); + flex-shrink: 0; + box-shadow: 0 0 0 2px var(--accent-soft); +} +#btn-theme:hover { + color: var(--fg); + border-color: var(--fg-soft); } -@media (max-width: 600px) { - header { flex-direction: column; align-items: flex-start; gap: 0.75rem; } - .brand h1 { font-size: 1.15rem; } - .brand .subtitle { font-size: 0.8em; } +@media (max-width: 640px) { + .masthead { padding: 1rem 1.25rem; } } -a { color: var(--color-link); } +/* ---------- Main column ---------- */ main { - max-width: 800px; - margin: 1.5rem auto; - padding: 0 1.5rem; + max-width: var(--content-w); + margin: 0 auto; + padding: 3rem 2rem 4rem; + position: relative; +} + +@media (max-width: 640px) { + main { padding: 2rem 1.25rem 3rem; } } h2 { - margin-top: 0; - font-size: 1.25rem; + font-family: var(--sans); + font-weight: 600; + font-size: clamp(1.75rem, 4vw, 2.5rem); + letter-spacing: -0.03em; + line-height: 1.05; + margin: 0 0 2rem 0; + color: var(--fg); } +/* ---------- Buttons ---------- */ + button { font: inherit; - padding: 0.5rem 1rem; - border: 1px solid var(--color-border); - background: var(--color-card-bg); - color: var(--color-fg); - border-radius: var(--radius); cursor: pointer; - transition: border-color 0.15s, background 0.15s; + border: none; + background: none; + color: inherit; } -button:hover:not(:disabled) { - border-color: var(--color-accent); +button.primary { + background: var(--fg); + color: var(--bg); + padding: 0.85rem 1.5rem; + border-radius: 99px; + font-family: var(--sans); + font-size: 0.92rem; + font-weight: 500; + letter-spacing: -0.005em; + transition: all 0.15s var(--ease); + display: inline-flex; + align-items: center; + gap: 0.5rem; } - -button:disabled { - opacity: 0.5; +button.primary:hover:not(:disabled) { + background: var(--accent); + color: #FFFFFF; + transform: translateY(-1px); + box-shadow: 0 8px 24px -8px var(--accent); +} +button.primary:active:not(:disabled) { transform: translateY(0); } +button.primary:disabled { + opacity: 0.4; cursor: not-allowed; } -button.danger { - color: var(--color-incorrect); - border-color: var(--color-incorrect); +button.ghost { + background: transparent; + color: var(--fg); + padding: 0.7rem 1.3rem; + border: 1px solid var(--hairline); + border-radius: 99px; + font-family: var(--sans); + font-size: 0.88rem; + font-weight: 500; + transition: border-color 0.15s var(--ease), background 0.15s var(--ease); +} +button.ghost:hover { + border-color: var(--fg-soft); + background: var(--surface-soft); +} +button.ghost.danger { color: var(--negative); border-color: var(--negative-soft); } +button.ghost.danger:hover { + border-color: var(--negative); + background: var(--negative-soft); +} + +button.link { + font-family: var(--mono); + font-size: 0.72rem; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); + padding: 0.25rem 0; + transition: color 0.15s var(--ease); } +button.link:hover { color: var(--fg); } +button.link.danger { color: var(--negative); } +button.link.danger:hover { color: var(--negative); } -button.danger:hover { background: var(--color-incorrect-bg); } +.back-button { + font-family: var(--sans); + font-size: 0.85rem; + font-weight: 500; + color: var(--muted); + padding: 0; + margin-bottom: 1.5rem; + transition: color 0.15s var(--ease); +} +.back-button:hover { color: var(--fg); } -/* Setup — Step 1: certification picker */ +/* ---------- Loading ---------- */ + +.loading { + font-family: var(--mono); + font-size: 0.85rem; + color: var(--muted); + text-align: center; + padding: 4rem 0; +} + +/* ---------- Step 1: Certification picker ---------- */ #setup .cert-list { display: grid; - gap: 1rem; + gap: 1.25rem; grid-template-columns: 1fr; - margin-top: 1.25rem; + margin-top: 1.5rem; } -@media (min-width: 700px) { +@media (min-width: 720px) { #setup .cert-list { grid-template-columns: 1fr 1fr; } } -#setup .cert-card { +.cert-card { + --gradient: linear-gradient(135deg, var(--accent), #FFAB1F); + + display: block; text-align: left; - padding: 1.25rem 1.5rem; - border: 1px solid var(--color-border); - border-radius: var(--radius); - background: var(--color-card-bg); - box-shadow: var(--shadow-card); + padding: 1.75rem; + background: var(--surface); + border: 1px solid var(--hairline); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-2); position: relative; + cursor: pointer; overflow: hidden; + transition: transform 0.25s var(--ease), box-shadow 0.25s var(--ease), border-color 0.15s var(--ease); + font-family: var(--sans); + isolation: isolate; } -#setup .cert-card::before { +/* Distinctive per-cert accent gradients */ +.cert-card[data-cert="data-engineer-associate"] { --gradient: linear-gradient(135deg, #FF4F2C, #FFAB1F); } +.cert-card[data-cert="data-engineer-professional"] { --gradient: linear-gradient(135deg, #6366F1, #4F46E5); } +.cert-card[data-cert="data-analyst-associate"] { --gradient: linear-gradient(135deg, #10B981, #14B8A6); } +.cert-card[data-cert="ml-associate"] { --gradient: linear-gradient(135deg, #A855F7, #EC4899); } +.cert-card[data-cert="ml-professional"] { --gradient: linear-gradient(135deg, #F59E0B, #DC2626); } +.cert-card[data-cert="genai-engineer-associate"] { --gradient: linear-gradient(135deg, #06B6D4, #3B82F6); } + +.cert-card::before { content: ""; position: absolute; - inset: 0 auto 0 0; - width: 4px; - background: var(--color-accent); + inset: 0 0 auto 0; + height: 4px; + background: var(--gradient); + border-radius: var(--radius-lg) var(--radius-lg) 0 0; } -#setup .cert-card strong { - display: block; - font-size: 1.15rem; - margin-bottom: 0.4rem; - line-height: 1.3; +.cert-card::after { + content: ""; + position: absolute; + inset: 0; + border-radius: var(--radius-lg); + background: var(--gradient); + opacity: 0; + z-index: -1; + filter: blur(40px); + transition: opacity 0.3s var(--ease); } -#setup .cert-card .cert-card-meta { - font-size: 0.92em; - color: var(--color-fg); - margin-bottom: 0.25rem; +.cert-card:hover { + transform: translateY(-3px); + box-shadow: var(--shadow-3); + border-color: transparent; } +.cert-card:hover::after { opacity: 0.15; } -#setup .cert-card .cert-card-blueprint { - font-size: 0.85em; - color: var(--color-muted); +.cert-card .cert-card-arrow { + position: absolute; + top: 1.5rem; + right: 1.5rem; + font-family: var(--mono); + font-size: 1.2rem; + color: var(--muted); + transition: transform 0.2s var(--ease), color 0.2s var(--ease); +} +.cert-card:hover .cert-card-arrow { + transform: translate(2px, -2px); + color: var(--fg); } -/* Setup — Step 2: per-cert bank picker */ +.cert-card strong { + display: block; + font-family: var(--sans); + font-weight: 600; + font-size: 1.45rem; + letter-spacing: -0.02em; + line-height: 1.15; + margin: 0.5rem 0 1.5rem 0; + color: var(--fg); + padding-right: 2rem; +} -#setup .back-button { - font-size: 0.85em; - padding: 0.25rem 0.6rem; - border: none; - background: none; - color: var(--color-link); - margin-bottom: 0.5rem; +.cert-card .cert-card-stats { + display: flex; + align-items: baseline; + gap: 0.5rem; + margin-bottom: 0.85rem; +} +.cert-card .cert-card-stats .stat-num { + font-family: var(--sans); + font-weight: 600; + font-size: 2.5rem; + letter-spacing: -0.03em; + line-height: 1; + color: var(--fg); + font-variant-numeric: tabular-nums; +} +.cert-card .cert-card-stats .stat-label { + font-family: var(--mono); + font-size: 0.72rem; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); + line-height: 1.2; +} + +.cert-card .cert-card-meta { + font-family: var(--mono); + font-size: 0.72rem; + font-weight: 500; + letter-spacing: 0.04em; + color: var(--fg-soft); + margin-bottom: 0.4rem; + font-variant-numeric: tabular-nums; +} + +.cert-card .cert-card-blueprint { + font-family: var(--mono); + font-size: 0.7rem; + font-weight: 400; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted); + padding-top: 1rem; + margin-top: 1rem; + border-top: 1px solid var(--hairline-soft); + font-variant-numeric: tabular-nums; } -#setup .back-button:hover { text-decoration: underline; } +/* ---------- Step 2: Bank picker ---------- */ #setup .cert-subtitle { - margin: 0 0 1.25rem 0; - color: var(--color-muted); - font-size: 0.9em; + font-family: var(--mono); + font-size: 0.72rem; + font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted); + margin: -1rem 0 2rem 0; } #setup .bank-list { display: grid; - gap: 0.75rem; + gap: 1rem; grid-template-columns: 1fr; } @media (min-width: 700px) { - #setup .bank-list { grid-template-columns: 1fr 1fr 1fr; } + #setup .bank-list { grid-template-columns: repeat(3, 1fr); } } -#setup .bank-card { +.bank-card { + display: block; text-align: left; - padding: 1rem 1.25rem; - border: 1px solid var(--color-border); - border-radius: var(--radius); - background: var(--color-card-bg); + padding: 1.5rem; + background: var(--surface); + border: 1px solid var(--hairline); + border-radius: var(--radius-lg); + cursor: pointer; + transition: all 0.15s var(--ease); + font-family: var(--sans); + position: relative; +} +.bank-card:hover { + border-color: var(--accent); + background: var(--accent-soft); + transform: translateY(-2px); } -#setup .bank-card strong { +.bank-card strong { display: block; - font-size: 1rem; - margin-bottom: 0.35rem; + font-family: var(--sans); + font-weight: 600; + font-size: 1.05rem; + letter-spacing: -0.01em; + margin-bottom: 1rem; + color: var(--fg); } -#setup .bank-card .bank-meta { - font-size: 0.85em; - color: var(--color-fg); - margin-bottom: 0.15rem; +.bank-card .bank-meta { + font-family: var(--mono); + font-size: 0.78rem; + letter-spacing: 0; + color: var(--fg-soft); + margin-bottom: 0.35rem; + font-variant-numeric: tabular-nums; } -#setup .bank-card .bank-purpose { - font-size: 0.78em; - color: var(--color-muted); - font-style: italic; +.bank-card .bank-purpose { + font-size: 0.82rem; + color: var(--muted); + font-weight: 400; } -/* Quiz */ -.quiz-header { +/* ---------- Quiz running head ---------- */ + +.quiz-running-head { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; - font-size: 0.85em; - color: var(--color-muted); - margin-bottom: 0.75rem; + font-family: var(--mono); + font-size: 0.72rem; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); + padding-bottom: 1rem; + margin-bottom: 1.5rem; + border-bottom: 1px solid var(--hairline); } -.quiz-controls { - display: flex; - gap: 0.4rem; - flex-wrap: wrap; +.quiz-running-head .rh-left, +.quiz-running-head .rh-right { + display: inline-flex; + align-items: center; + gap: 0.5rem; } -.quiz-controls button { - padding: 0.3rem 0.6rem; - font-size: 0.85em; +.quiz-running-head #quiz-cert { + color: var(--fg); + font-weight: 600; } - -.quiz-meta .separator { margin: 0 0.4rem; opacity: 0.4; } +.quiz-running-head .rh-sep { color: var(--hairline); } .difficulty { - padding: 0.1rem 0.5rem; + padding: 0.2rem 0.55rem; border-radius: 99px; - font-size: 0.8em; + font-family: var(--mono); + font-size: 0.62rem; font-weight: 600; - text-transform: capitalize; + letter-spacing: 0.08em; + text-transform: uppercase; } -.difficulty.easy { background: var(--color-correct-bg); color: var(--color-correct); } -.difficulty.medium { background: var(--color-medium-bg); color: var(--color-medium-fg); } -.difficulty.hard { background: var(--color-incorrect-bg); color: var(--color-incorrect); } +.difficulty.easy { background: var(--positive-soft); color: var(--positive); } +.difficulty.medium { background: var(--accent-soft); color: var(--accent); } +.difficulty.hard { background: var(--negative-soft); color: var(--negative); } + +/* ---------- Question card ---------- */ .question-card { - background: var(--color-card-bg); - padding: 1.5rem; - border-radius: var(--radius); - box-shadow: var(--shadow-card); + background: var(--surface); + border: 1px solid var(--hairline); + border-radius: var(--radius-lg); + padding: 2rem; + box-shadow: var(--shadow-2); } -.question-card h3 { - margin-top: 0; - font-size: 1.05rem; - color: var(--color-muted); +@media (max-width: 640px) { + .question-card { padding: 1.5rem 1.25rem; } } -.question-card #quiz-question { - font-size: 1.05rem; +#quiz-question { + font-family: var(--sans); + font-weight: 500; + font-size: 1.15rem; + line-height: 1.55; + letter-spacing: -0.012em; + color: var(--fg); + margin: 0 0 1.75rem 0; +} +#quiz-question p { margin: 0.5rem 0; } +#quiz-question p:first-child { margin-top: 0; } +#quiz-question p:last-child { margin-bottom: 0; } +#quiz-question code { + font-size: 0.92em; font-weight: 500; } -.question-card #quiz-question p { - margin: 0.5rem 0; -} - -.question-card #quiz-question p:first-child { - margin-top: 0; -} +/* ---------- Choices ---------- */ fieldset#quiz-choices { border: none; padding: 0; - margin: 1rem 0; + margin: 0 0 1.75rem 0; display: flex; flex-direction: column; - gap: 0.5rem; + gap: 0.625rem; } fieldset#quiz-choices label { display: flex; align-items: flex-start; - gap: 0.5rem; - padding: 0.75rem 1rem; - border: 1px solid var(--color-border); + gap: 0.85rem; + padding: 1rem 1.15rem; + border: 1px solid var(--hairline); border-radius: var(--radius); cursor: pointer; - transition: border-color 0.15s, background 0.15s; + background: var(--surface); + transition: all 0.15s var(--ease); + position: relative; +} +fieldset#quiz-choices label:hover:not(.disabled) { + border-color: var(--fg-soft); + background: var(--surface-soft); +} +fieldset#quiz-choices label.disabled { + cursor: default; + pointer-events: none; } -fieldset#quiz-choices label:hover { - border-color: var(--color-accent); +fieldset#quiz-choices input[type=radio] { + appearance: none; + width: 18px; + height: 18px; + border: 1.5px solid var(--hairline); + border-radius: 50%; + margin: 0.15rem 0 0 0; + flex-shrink: 0; + background: var(--surface); + cursor: pointer; + transition: all 0.15s var(--ease); + position: relative; } +fieldset#quiz-choices input[type=radio]:hover { border-color: var(--fg-soft); } +fieldset#quiz-choices input[type=radio]:checked { + border-color: var(--fg); + background: var(--fg); + box-shadow: inset 0 0 0 4px var(--surface); +} +fieldset#quiz-choices input[type=radio]:disabled { opacity: 0.5; cursor: default; } -fieldset#quiz-choices label.disabled { cursor: default; } +fieldset#quiz-choices .choice-letter { + font-family: var(--mono); + font-weight: 600; + font-size: 0.85rem; + color: var(--muted); + margin: 0.15rem 0 0 0; + flex-shrink: 0; + min-width: 1.25em; + text-transform: uppercase; + transition: color 0.15s var(--ease); +} -fieldset#quiz-choices input[type=radio] { margin-top: 0.3rem; } +fieldset#quiz-choices label:hover .choice-letter { color: var(--fg); } +fieldset#quiz-choices label input[type=radio]:checked ~ .choice-letter { color: var(--fg); } + +fieldset#quiz-choices label .choice-text { + flex: 1; + font-size: 0.95rem; + line-height: 1.55; + color: var(--fg); +} fieldset#quiz-choices label.correct { - background: var(--color-correct-bg); - border-color: var(--color-correct); + background: var(--positive-soft); + border-color: var(--positive); } +fieldset#quiz-choices label.correct .choice-letter, +fieldset#quiz-choices label.correct .choice-text { color: var(--positive); } +fieldset#quiz-choices label.correct input[type=radio] { border-color: var(--positive); } fieldset#quiz-choices label.incorrect { - background: var(--color-incorrect-bg); - border-color: var(--color-incorrect); + background: var(--negative-soft); + border-color: var(--negative); } +fieldset#quiz-choices label.incorrect .choice-letter, +fieldset#quiz-choices label.incorrect .choice-text { color: var(--negative); } -fieldset#quiz-choices .choice-letter { - font-weight: 600; - margin-right: 0.25rem; - min-width: 1.2em; -} +/* ---------- Quiz actions ---------- */ .quiz-actions { display: flex; - gap: 0.5rem; - margin-top: 1rem; + align-items: center; + gap: 1.25rem; + margin-bottom: 0.5rem; + flex-wrap: wrap; +} + +.kbd-hint { + font-family: var(--mono); + font-size: 0.68rem; + font-weight: 400; + color: var(--muted); + letter-spacing: 0.02em; + display: inline-flex; + align-items: center; + gap: 0.25rem; + flex-wrap: wrap; +} +.kbd-hint kbd { + display: inline-block; + min-width: 1.5em; + padding: 0.1rem 0.4rem; + border: 1px solid var(--hairline); + border-bottom-width: 2px; + border-radius: 4px; + background: var(--surface); + color: var(--fg); + font-family: var(--mono); + font-weight: 500; + font-size: 0.7rem; + text-align: center; } +/* ---------- Feedback ---------- */ + #quiz-feedback { - margin-top: 1rem; - padding: 1rem; + margin-top: 1.5rem; + padding: 1.5rem; + background: var(--surface-soft); border-radius: var(--radius); - background: var(--color-bg); - border: 1px solid var(--color-border); + border-left: 3px solid var(--positive); } +#quiz-feedback.incorrect { border-left-color: var(--negative); } #quiz-feedback h4 { - margin: 0 0 0.5rem 0; - font-size: 0.95rem; + margin: 0 0 0.85rem 0; + font-family: var(--mono); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--positive); } +#quiz-feedback.incorrect h4 { color: var(--negative); } + +#quiz-feedback p { margin: 0.5rem 0; font-size: 0.95rem; line-height: 1.6; } -#quiz-feedback.correct h4 { color: var(--color-correct); } -#quiz-feedback.incorrect h4 { color: var(--color-incorrect); } +#quiz-feedback p.fb-short { + font-family: var(--sans); + font-size: 1.02rem; + font-weight: 500; + color: var(--fg); + margin-bottom: 0.85rem; + letter-spacing: -0.005em; +} -#quiz-feedback p { margin: 0.4rem 0; } -#quiz-feedback p:first-of-type { font-weight: 500; } #quiz-feedback p.fb-topic { - font-weight: normal; - font-size: 0.9em; - color: var(--color-muted); + font-family: var(--mono); + font-size: 0.7rem; + font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted); margin-top: 0; + margin-bottom: 0.85rem; } -.session-bar { +#quiz-feedback pre { margin: 0.85rem 0; } + +/* ---------- Quiz footer ---------- */ + +.quiz-footer { + margin-top: 1.5rem; + padding-top: 1rem; + border-top: 1px solid var(--hairline); display: flex; - justify-content: space-between; - font-size: 0.85em; - color: var(--color-muted); - margin-top: 1rem; + align-items: center; + gap: 0.85rem; + font-family: var(--mono); + font-size: 0.72rem; + font-weight: 500; + letter-spacing: 0.02em; + color: var(--muted); + font-variant-numeric: tabular-nums; flex-wrap: wrap; - gap: 0.5rem; } +.quiz-footer .quiz-controls { + margin-left: auto; + display: inline-flex; + gap: 1.25rem; + align-items: center; +} +.quiz-footer #session-stats, +.quiz-footer #bank-stats { color: var(--fg-soft); } +.quiz-footer .rh-sep { color: var(--hairline); } + +/* ---------- Stats view ---------- */ -/* Stats */ #stats table { width: 100%; border-collapse: collapse; - margin: 1rem 0; - font-size: 0.95em; + margin: 1.5rem 0; + font-size: 0.92rem; } - #stats th, #stats td { - padding: 0.5rem 0.75rem; + padding: 0.75rem 0.85rem; text-align: left; - border-bottom: 1px solid var(--color-border); + border-bottom: 1px solid var(--hairline); } - #stats th { + font-family: var(--mono); font-weight: 600; - color: var(--color-muted); - font-size: 0.85em; + font-size: 0.7rem; + color: var(--muted); text-transform: uppercase; - letter-spacing: 0.04em; + letter-spacing: 0.06em; + border-bottom-color: var(--fg-soft); +} +#stats td.numeric { + text-align: right; + font-family: var(--mono); + font-variant-numeric: tabular-nums; +} +#stats tr:last-child td { + font-weight: 600; + border-bottom: none; + border-top: 1px solid var(--fg-soft); +} +#stats h3 { + font-family: var(--sans); + font-weight: 600; + font-size: 1.2rem; + letter-spacing: -0.015em; + margin: 2rem 0 0.75rem; +} +#stats ul { + list-style: none; + padding: 0; + margin: 0; +} +#stats ul li { + padding: 0.65rem 0; + border-bottom: 1px solid var(--hairline-soft); + font-size: 0.92rem; + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; } - -#stats td.numeric { text-align: right; font-variant-numeric: tabular-nums; } .stats-actions, .settings-actions { display: flex; - gap: 0.5rem; + gap: 0.75rem; flex-wrap: wrap; - margin-top: 1rem; + margin-top: 1.5rem; } -/* Settings */ -#settings label { +/* ---------- Settings ---------- */ + +.settings-fields { display: grid; gap: 1.25rem; margin: 1.5rem 0; } + +.settings-fields label { display: block; } +.settings-fields .field-label { display: block; - margin: 1rem 0; + font-family: var(--mono); + font-size: 0.7rem; font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: 0.5rem; } - -#settings select { +.settings-fields select { display: block; width: 100%; - margin-top: 0.4rem; - padding: 0.5rem; + padding: 0.7rem 0.85rem; font: inherit; - border: 1px solid var(--color-border); + font-family: var(--sans); + font-size: 0.92rem; + background: var(--surface); + color: var(--fg); + border: 1px solid var(--hairline); border-radius: var(--radius); - background: var(--color-card-bg); - color: var(--color-fg); + transition: border-color 0.15s var(--ease); } +.settings-fields select:focus { + outline: none; + border-color: var(--fg); +} + +/* ---------- Code blocks (Prism syntax highlighting) ---------- */ code { - font-family: var(--font-mono); - font-size: 0.92em; - background: rgba(125,125,125,0.15); - padding: 0.1rem 0.3rem; - border-radius: 3px; + font-family: var(--mono); + font-size: 0.86em; + font-weight: 500; + background: var(--code-bg); + padding: 0.12rem 0.4rem; + border-radius: 4px; + color: var(--fg); } -/* Code blocks — base styling, then override Prism's heavy background. - Prism's prism-tomorrow theme paints `pre[class*=language-]` dark; we re-tint - it so light-theme users see a light code block, dark-theme users see dark. */ - pre, pre[class*="language-"] { - background: var(--color-code-bg, rgba(125,125,125,0.12)); - padding: 0.75rem 1rem; + background: var(--code-bg); + padding: 1rem 1.15rem; border-radius: var(--radius); overflow-x: auto; - font-size: 0.88em; - margin: 0.75rem 0; - font-family: var(--font-mono); - line-height: 1.45; + font-size: 0.82rem; + margin: 1rem 0; + font-family: var(--mono); + line-height: 1.55; white-space: pre; - border: 1px solid var(--color-border); - color: var(--color-fg); + border: 1px solid var(--hairline); + color: var(--fg); + text-shadow: none; } - pre code, pre[class*="language-"] code { background: none; padding: 0; font-size: inherit; + font-weight: 400; border-radius: 0; color: inherit; text-shadow: none; } -/* Light theme: Prism's default token colors over a light background look fine, - but the bundled prism-tomorrow uses bright colors meant for dark backgrounds. - Tone token colors down on light mode so they don't glow against the soft bg. */ -:root:not([data-theme="dark"]) :not(pre) > code[class*="language-"] .token, -:root:not([data-theme="dark"]) pre[class*="language-"] .token { text-shadow: none; } - -:root[data-theme="light"] { - --color-code-bg: #f3f3f3; -} +/* Light-mode Prism overrides */ :root[data-theme="light"] .token.comment, :root[data-theme="light"] .token.prolog, :root[data-theme="light"] .token.doctype, -:root[data-theme="light"] .token.cdata { color: #6a7480; } -:root[data-theme="light"] .token.punctuation { color: #4a4a4a; } +:root[data-theme="light"] .token.cdata { color: #8a8a85; font-style: italic; } +:root[data-theme="light"] .token.punctuation { color: #52525B; } :root[data-theme="light"] .token.keyword, :root[data-theme="light"] .token.boolean, -:root[data-theme="light"] .token.null { color: #a626a4; } +:root[data-theme="light"] .token.null { color: #DC2626; } :root[data-theme="light"] .token.string, :root[data-theme="light"] .token.char, -:root[data-theme="light"] .token.attr-value { color: #50a14f; } -:root[data-theme="light"] .token.number { color: #986801; } +:root[data-theme="light"] .token.attr-value { color: #15803D; } +:root[data-theme="light"] .token.number { color: #B45309; } :root[data-theme="light"] .token.function, -:root[data-theme="light"] .token.class-name { color: #4078f2; } -:root[data-theme="light"] .token.operator { color: #4078f2; } - -:root[data-theme="dark"] { --color-code-bg: #1a1a1a; } +:root[data-theme="light"] .token.class-name { color: #1D4ED8; } +:root[data-theme="light"] .token.operator { color: #52525B; } -/* Auto theme: follow prefers-color-scheme */ @media (prefers-color-scheme: light) { - :root[data-theme="auto"], :root:not([data-theme]) { - --color-code-bg: #f3f3f3; - } :root[data-theme="auto"] .token.comment, - :root:not([data-theme]) .token.comment { color: #6a7480; } + :root:not([data-theme]) .token.comment { color: #8a8a85; font-style: italic; } :root[data-theme="auto"] .token.keyword, - :root:not([data-theme]) .token.keyword { color: #a626a4; } + :root:not([data-theme]) .token.keyword { color: #DC2626; } :root[data-theme="auto"] .token.string, - :root:not([data-theme]) .token.string { color: #50a14f; } + :root:not([data-theme]) .token.string { color: #15803D; } :root[data-theme="auto"] .token.number, - :root:not([data-theme]) .token.number { color: #986801; } + :root:not([data-theme]) .token.number { color: #B45309; } :root[data-theme="auto"] .token.function, - :root:not([data-theme]) .token.function { color: #4078f2; } + :root:not([data-theme]) .token.function { color: #1D4ED8; } } -#quiz-question pre, -#quiz-feedback pre { - white-space: pre; +/* ---------- Page footer ---------- */ + +.page-footer { + max-width: var(--content-w); + margin: 4rem auto 0; + padding: 1.5rem 2rem 2.5rem; + border-top: 1px solid var(--hairline); + font-family: var(--mono); + font-size: 0.72rem; + letter-spacing: 0.02em; + color: var(--muted); + line-height: 1.7; +} +.page-footer p { margin: 0; } +.page-footer .colophon { font-style: italic; } +.page-footer .rh-sep { color: var(--hairline); padding: 0 0.25rem; } + +/* ---------- Focus ---------- */ + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 4px; +} +button:focus-visible { outline-offset: 3px; } +fieldset#quiz-choices label:focus-within { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* ---------- Motion & micro-interactions ---------- */ + +@media (prefers-reduced-motion: no-preference) { + /* Entrance fade-up for cards */ + .cert-card, .bank-card, .question-card { + animation: fadeUp 0.4s var(--ease) backwards; + } + .cert-card:nth-child(1) { animation-delay: 0s; } + .cert-card:nth-child(2) { animation-delay: 0.05s; } + .cert-card:nth-child(3) { animation-delay: 0.1s; } + .cert-card:nth-child(4) { animation-delay: 0.15s; } + .cert-card:nth-child(5) { animation-delay: 0.2s; } + .cert-card:nth-child(6) { animation-delay: 0.25s; } + + /* Answer reveal: green pulse + glow on the correct choice */ + fieldset#quiz-choices label.correct { + animation: pulse-correct 0.7s var(--ease); + } + /* Red shake on whichever choice the user picked wrong */ + fieldset#quiz-choices label.incorrect { + animation: shake-incorrect 0.4s var(--ease); + } + + /* Feedback panel slides in from above */ + #quiz-feedback:not([hidden]) { + animation: slideDown 0.35s var(--ease); + } + + /* Question card transitions in cleanly between questions */ + .question-card { transition: none; } + + /* Brief tint flash on the whole question card */ + .question-card.flash-correct { animation: flash-correct 0.6s var(--ease); } + .question-card.flash-incorrect { animation: flash-incorrect 0.5s var(--ease); } +} + +@keyframes fadeUp { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes pulse-correct { + 0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(21,128,61,0); } + 35% { transform: scale(1.015); box-shadow: 0 0 0 6px rgba(21,128,61,0.18); } + 70% { transform: scale(1.005); box-shadow: 0 0 0 3px rgba(21,128,61,0.08); } + 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(21,128,61,0); } +} + +@keyframes shake-incorrect { + 0%, 100% { transform: translateX(0); } + 15% { transform: translateX(-6px); } + 30% { transform: translateX(6px); } + 45% { transform: translateX(-4px); } + 60% { transform: translateX(4px); } + 75% { transform: translateX(-2px); } + 90% { transform: translateX(2px); } +} + +@keyframes slideDown { + from { opacity: 0; transform: translateY(-6px); max-height: 0; } + to { opacity: 1; transform: translateY(0); max-height: 1000px; } +} + +@keyframes flash-correct { + 0%, 100% { background: var(--surface); } + 20% { background: var(--positive-soft); } +} + +@keyframes flash-incorrect { + 0%, 100% { background: var(--surface); } + 20% { background: var(--negative-soft); } +} + +/* ---------- Streak indicator (appears briefly on a correct streak) ---------- */ + +.streak-toast { + position: fixed; + top: 5rem; + left: 50%; + transform: translateX(-50%) translateY(-30px); + background: var(--fg); + color: var(--bg); + padding: 0.75rem 1.5rem; + border-radius: 99px; + font-family: var(--mono); + font-size: 0.85rem; + font-weight: 600; + letter-spacing: -0.005em; + z-index: 1000; + pointer-events: none; + opacity: 0; + display: inline-flex; + align-items: center; + gap: 0.6rem; + box-shadow: 0 12px 32px -8px rgba(0,0,0,0.25); +} +.streak-toast.show { + animation: streakIn 1.6s var(--ease); +} +.streak-toast .streak-num { + background: var(--accent); + color: #FFFFFF; + padding: 0.1rem 0.55rem; + border-radius: 99px; + font-variant-numeric: tabular-nums; +} + +@keyframes streakIn { + 0% { opacity: 0; transform: translateX(-50%) translateY(-30px); } + 20% { opacity: 1; transform: translateX(-50%) translateY(0); } + 80% { opacity: 1; transform: translateX(-50%) translateY(0); } + 100% { opacity: 0; transform: translateX(-50%) translateY(-20px); } +} + +/* ---------- "+1" floating indicator on correct answer ---------- */ + +.float-plus { + position: absolute; + right: 1rem; + top: 50%; + transform: translateY(-50%); + font-family: var(--mono); + font-weight: 600; + font-size: 0.95rem; + color: var(--positive); + pointer-events: none; + opacity: 0; + animation: floatPlus 1.1s var(--ease); +} +@keyframes floatPlus { + 0% { opacity: 0; transform: translateY(-50%); } + 20% { opacity: 1; transform: translate(0, calc(-50% - 8px)); } + 100% { opacity: 0; transform: translate(0, calc(-50% - 36px)); } } From 4d1fb731c1991e6d149540ad98e836a87cf861db Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Thu, 21 May 2026 21:37:35 +0700 Subject: [PATCH 04/33] feat(practice): keyboard shortcuts working + 1-4 choice labels + skip button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things in one batch: 1. Fix keyboard shortcuts that weren't actually working. Bug: the condition `if (letter && !$("#btn-next").hidden === false)` was inverted — it blocked choice selection BEFORE submit instead of after. Now: choice keys (1-4 and A-D) work pre-submit; Enter submits or advances; modifier keys (Ctrl/Cmd/Alt) are passed through to the browser so things like Cmd+R still work. 2. Display choices as 1/2/3/4 instead of A/B/C/D. The source markdown still uses A/B/C/D internally (no data migration needed), but the rendered choice label and the "Correct answer: X" line in the feedback panel now both show numbers. Reason: number labels make the available keyboard shortcuts obvious — see "1" beside a choice, press 1 to select it. 3. Add a Skip button (also bound to the S key). The user can now skip a question they don't know without recording an incorrect attempt. Skipped questions go into seenThisSession so they don't immediately repeat, but adaptive mode will re-surface them in a future session (untouched history → high weight = early in the queue). Skip is only visible before the user submits — after submit only "Next question" shows. Updated kbd hint: "1 2 3 4 select · ↵ submit · S skip" Cache version bumped to ?v=9 across app.js, styles.css, and JSON fetches. --- practice/app.js | 54 +++++++++++++++++++++++++++++++++++---------- practice/index.html | 9 +++++--- 2 files changed, 48 insertions(+), 15 deletions(-) diff --git a/practice/app.js b/practice/app.js index fc332c3..9f514b3 100644 --- a/practice/app.js +++ b/practice/app.js @@ -26,7 +26,7 @@ // Bump on every deploy that changes app.js / data/*.json. Appended to // bank-JSON fetch URLs so browsers don't serve stale banks after a deploy. - const APP_VERSION = "8"; + const APP_VERSION = "9"; // Title patterns that are placeholder fallbacks (mock-exam questions whose // source heading is `## Question N *(Difficulty)*` with no real title text). @@ -469,6 +469,7 @@ const choices = $("#quiz-choices"); clear(choices); for (const letter of ["A", "B", "C", "D"]) { + const num = LETTER_TO_NUM[letter]; const radio = el("input", { type: "radio", name: "choice", value: letter, onchange: () => { STATE.currentChoice = letter; @@ -478,13 +479,14 @@ choiceTextSpan.appendChild(renderInlineToFragment(q.choices[letter])); const label = el("label", { dataset: { letter } }, radio, - el("span", { className: "choice-letter" }, letter), + el("span", { className: "choice-letter" }, num), choiceTextSpan); choices.appendChild(label); } $("#btn-submit").hidden = false; $("#btn-submit").disabled = true; + $("#btn-skip").hidden = false; $("#btn-next").hidden = true; $("#quiz-feedback").hidden = true; updateSessionBar(); @@ -573,8 +575,9 @@ fb.hidden = false; fb.className = correct ? "correct" : "incorrect"; clear(fb); + const correctNum = LETTER_TO_NUM[q.correctAnswer] || q.correctAnswer; fb.appendChild(el("h4", {}, - correct ? "✓ Correct" : `✗ Incorrect · Correct answer: ${q.correctAnswer}`)); + correct ? "✓ Correct" : `✗ Incorrect · Correct answer: ${correctNum}`)); // Suppress the Topic line when the title is a placeholder fallback // ("Question 5") — the running head already shows the domain, which is // the meaningful context for mock-exam questions. @@ -592,10 +595,20 @@ } $("#btn-submit").hidden = true; + $("#btn-skip").hidden = true; $("#btn-next").hidden = false; updateSessionBar(); } + // Skip — moves to the next question without recording an attempt. + // The question is added to seenThisSession so it doesn't immediately repeat, + // but in adaptive mode it'll come back in a future session. + function skipQuestion() { + // Don't double-fire if already past submit + if ($("#btn-next").hidden === false) return; + renderQuiz(); + } + // --- Stats view ---------------------------------------------------------- function renderStats() { @@ -738,21 +751,28 @@ // --- Init ---------------------------------------------------------------- + // UI shows 1-4 but data uses A-D (matches source markdown). These maps + // bridge the two consistently throughout the quiz UI + keyboard handler. + const NUM_TO_LETTER = { "1": "A", "2": "B", "3": "C", "4": "D" }; + const LETTER_TO_NUM = { "A": "1", "B": "2", "C": "3", "D": "4" }; + function handleKeydown(ev) { // Only react when the quiz section is visible if ($("#quiz").hidden) return; - // Ignore when the user is typing inside an input (defensive — we don't - // currently have any text inputs in the quiz, but cheap safety) + // Ignore when the user is typing inside an input/select if (ev.target.matches && ev.target.matches("input, textarea, select")) return; + // Ignore when a modifier key is held (let browser shortcuts pass through) + if (ev.ctrlKey || ev.metaKey || ev.altKey) return; + + const key = ev.key.toLowerCase(); + // Choice keys: 1/2/3/4 (primary) + a/b/c/d (alias) + const letter = NUM_TO_LETTER[key] + || { "a": "A", "b": "B", "c": "C", "d": "D" }[key]; - const keyMap = { "1": "A", "2": "B", "3": "C", "4": "D", - "a": "A", "b": "B", "c": "C", "d": "D" }; - const letter = keyMap[ev.key.toLowerCase()]; - if (letter && !$("#btn-next").hidden === false) { - // After submit; ignore choice keys until next question - return; - } if (letter) { + // After submit (btn-next visible): ignore choice keys + const submitted = !$("#btn-next").hidden; + if (submitted) return; const radio = document.querySelector( `fieldset#quiz-choices input[value="${letter}"]`); if (radio && !radio.disabled) { @@ -769,6 +789,15 @@ $("#btn-submit").click(); } ev.preventDefault(); + return; + } + if (key === "s") { + // Skip — only available before submit + const submitted = !$("#btn-next").hidden; + if (!submitted) { + skipQuestion(); + ev.preventDefault(); + } } } @@ -777,6 +806,7 @@ applyTheme(loadTheme()); $("#btn-submit").addEventListener("click", submitAnswer); + $("#btn-skip").addEventListener("click", skipQuestion); $("#btn-next").addEventListener("click", renderQuiz); $("#btn-stats").addEventListener("click", () => { renderStats(); show("stats"); }); $("#btn-stats-back").addEventListener("click", () => show("quiz")); diff --git a/practice/index.html b/practice/index.html index fcf6fc5..26e9f87 100644 --- a/practice/index.html +++ b/practice/index.html @@ -16,7 +16,7 @@ - + @@ -70,11 +70,14 @@

Databricks Certification Practice

+ @@ -157,6 +160,6 @@

Settings

- + From 2b7df4c288119529a771936a9067888beb96e703 Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Thu, 21 May 2026 21:44:01 +0700 Subject: [PATCH 05/33] feat(practice): sticky compact header + sticky action bar + n/-> next keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the in-quiz chrome so the question/choices/feedback are the only thing that scrolls, with header info and action buttons pinned. Top sticky masthead (52px tall): - Brand mark (28×28) + "DBX Practice" wordmark on the left - Quiz meta strip — cert · domain · difficulty · counter — slides in when quiz is active, hidden in setup/stats/settings - Theme toggle on the right - Uses backdrop-filter blur for a "frosted" look over scrolled content Bottom sticky actionbar (64px tall, visible only in quiz mode): - Left: Submit / Skip OR Next question + kbd hint - Right: session stats, bank stats, then Stats / Settings / Reset / Switch - Replaces the old in-card .quiz-actions + .quiz-footer rows Keyboard: - Already-added: 1-4 select, S skip, Enter submit - New: Space, N, → all advance after submit - Pre-submit kbd hint: 1/2/3/4 select · ↵ submit · S skip - Post-submit kbd hint: N or → next question - Hints swap automatically based on submit state Behaviour guarantees ("next ต้องกดไม่ได้ถ้ายังไม่ submit"): - btn-next has the `hidden` attribute initially and is only un-hidden inside submitAnswer() after recordAttempt runs - handleKeydown checks `submitted` (= !btn-next.hidden) before firing N / → / next-via-Enter — there's no path to advance without submitting Mobile (≤ 720px): - Drops wordmark and kbd hints to keep the bar compact - Hides bank stats in the right side of the actionbar - Action bar grows taller to wrap submit + skip on its own line - body.quiz-active main { padding-bottom: 10rem } so the last choice isn't covered by the actionbar Cache versions bumped to ?v=10 across app.js, styles.css, and JSON fetches. --- practice/app.js | 40 ++++++-- practice/index.html | 141 +++++++++++++-------------- practice/styles.css | 228 +++++++++++++++++++++++++------------------- 3 files changed, 229 insertions(+), 180 deletions(-) diff --git a/practice/app.js b/practice/app.js index 9f514b3..e35aa49 100644 --- a/practice/app.js +++ b/practice/app.js @@ -26,7 +26,7 @@ // Bump on every deploy that changes app.js / data/*.json. Appended to // bank-JSON fetch URLs so browsers don't serve stale banks after a deploy. - const APP_VERSION = "9"; + const APP_VERSION = "10"; // Title patterns that are placeholder fallbacks (mock-exam questions whose // source heading is `## Question N *(Difficulty)*` with no real title text). @@ -109,6 +109,14 @@ for (const id of ["setup", "quiz", "stats", "settings"]) { $("#" + id).hidden = id !== sectionId; } + // Sticky bottom actionbar + masthead quiz-meta-strip belong to the quiz + // section only; hide them everywhere else. + const inQuiz = sectionId === "quiz"; + const actionbar = $("#actionbar"); + const metaStrip = $("#quiz-meta-strip"); + if (actionbar) actionbar.hidden = !inQuiz; + if (metaStrip) metaStrip.hidden = !inQuiz; + document.body.classList.toggle("quiz-active", inQuiz); } // --- Safe markdown → DOM rendering -------------------------------------- @@ -489,6 +497,9 @@ $("#btn-skip").hidden = false; $("#btn-next").hidden = true; $("#quiz-feedback").hidden = true; + const preHint = $("#kbd-hint-pre"), postHint = $("#kbd-hint-post"); + if (preHint) preHint.hidden = false; + if (postHint) postHint.hidden = true; updateSessionBar(); } @@ -597,6 +608,9 @@ $("#btn-submit").hidden = true; $("#btn-skip").hidden = true; $("#btn-next").hidden = false; + const preHint = $("#kbd-hint-pre"), postHint = $("#kbd-hint-post"); + if (preHint) preHint.hidden = true; + if (postHint) postHint.hidden = false; updateSessionBar(); } @@ -782,8 +796,11 @@ } return; } - if (ev.key === "Enter") { - if (!$("#btn-next").hidden) { + const submitted = !$("#btn-next").hidden; + + // Enter / Space — primary action (submit before answer, next after) + if (ev.key === "Enter" || ev.key === " ") { + if (submitted) { $("#btn-next").click(); } else if (!$("#btn-submit").disabled && !$("#btn-submit").hidden) { $("#btn-submit").click(); @@ -791,13 +808,16 @@ ev.preventDefault(); return; } - if (key === "s") { - // Skip — only available before submit - const submitted = !$("#btn-next").hidden; - if (!submitted) { - skipQuestion(); - ev.preventDefault(); - } + // Right arrow / N — explicitly next, only after submit + if ((ev.key === "ArrowRight" || key === "n") && submitted) { + $("#btn-next").click(); + ev.preventDefault(); + return; + } + // S — skip, only before submit + if (key === "s" && !submitted) { + skipQuestion(); + ev.preventDefault(); } } diff --git a/practice/index.html b/practice/index.html index 26e9f87..af14f01 100644 --- a/practice/index.html +++ b/practice/index.html @@ -3,97 +3,67 @@ - Databricks Certification Practice — Study Companion + Databricks Certification Practice - - - - + -
-
- - - -
- A study companion -

Databricks Certification Practice

+ +
+
+
+ + + + DBX Practice +
+ + + + +
+
-
+

Loading question banks…

@@ -140,26 +110,51 @@

Settings

+ + + - - - - + diff --git a/practice/styles.css b/practice/styles.css index 9ca00c5..6ec817e 100644 --- a/practice/styles.css +++ b/practice/styles.css @@ -140,76 +140,96 @@ a:hover { text-decoration-color: var(--accent); } /* Drop the paper-grain — it doesn't match the modern aesthetic */ .paper-grain { display: none; } -/* ---------- Masthead ---------- */ +/* ---------- Sticky top masthead ---------- */ .masthead { + position: sticky; + top: 0; + z-index: 50; + background: color-mix(in srgb, var(--bg) 88%, transparent); + backdrop-filter: saturate(180%) blur(12px); + -webkit-backdrop-filter: saturate(180%) blur(12px); + border-bottom: 1px solid var(--hairline); +} + +.masthead-inner { max-width: 1080px; margin: 0 auto; - padding: 1.5rem 2rem; + padding: 0.65rem 1.5rem; display: flex; - justify-content: space-between; align-items: center; - gap: 1.5rem; - border-bottom: 1px solid var(--hairline); + gap: 1.25rem; + min-height: 52px; } -.brand { - display: flex; +.brand-cluster { + display: inline-flex; align-items: center; - gap: 0.85rem; - min-width: 0; + gap: 0.6rem; + flex-shrink: 0; } .brand-mark { display: inline-flex; align-items: center; justify-content: center; - width: 36px; - height: 36px; + width: 28px; + height: 28px; flex-shrink: 0; color: var(--accent); text-decoration: none; transition: transform 0.2s var(--ease); } .brand-mark:hover { transform: rotate(-8deg) scale(1.08); } -.brand-mark svg { width: 28px; height: 28px; display: block; } +.brand-mark svg { width: 22px; height: 22px; display: block; } -.brand-text { min-width: 0; } +.brand-title { + font-family: var(--sans); + font-weight: 600; + font-size: 0.95rem; + letter-spacing: -0.02em; + color: var(--fg); + white-space: nowrap; +} -.brand-eyebrow { - display: block; +.quiz-meta-strip { + display: inline-flex; + align-items: center; + gap: 0.5rem; + flex: 1; + min-width: 0; + padding-left: 1rem; + border-left: 1px solid var(--hairline); + margin-left: 0.25rem; font-family: var(--mono); - font-size: 0.68rem; + font-size: 0.72rem; font-weight: 500; - letter-spacing: 0.16em; + letter-spacing: 0.02em; text-transform: uppercase; color: var(--muted); - margin-bottom: 0.1rem; -} - -.brand h1 { - margin: 0; - font-family: var(--sans); - font-weight: 600; - font-size: clamp(1.15rem, 2.5vw, 1.4rem); - letter-spacing: -0.02em; - line-height: 1.2; - color: var(--fg); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; } +.quiz-meta-strip[hidden] { display: none; } +.quiz-meta-strip #quiz-cert { color: var(--fg); font-weight: 600; } +.quiz-meta-strip #quiz-domain { color: var(--fg-soft); } +.quiz-meta-strip .rh-sep { color: var(--hairline); padding: 0 0.15rem; } -.brand-actions { - display: flex; +.masthead-actions { + display: inline-flex; gap: 0.5rem; flex-shrink: 0; + margin-left: auto; } #btn-theme { font-family: var(--mono); - font-size: 0.72rem; + font-size: 0.7rem; font-weight: 500; letter-spacing: 0.04em; text-transform: uppercase; - padding: 0.5rem 0.85rem 0.5rem 0.75rem; + padding: 0.4rem 0.8rem 0.4rem 0.7rem; background: var(--surface); color: var(--fg-soft); border: 1px solid var(--hairline); @@ -222,8 +242,8 @@ a:hover { text-decoration-color: var(--accent); } } #btn-theme::before { content: ""; - width: 8px; - height: 8px; + width: 7px; + height: 7px; border-radius: 50%; background: var(--accent); flex-shrink: 0; @@ -234,8 +254,14 @@ a:hover { text-decoration-color: var(--accent); } border-color: var(--fg-soft); } -@media (max-width: 640px) { - .masthead { padding: 1rem 1.25rem; } +@media (max-width: 720px) { + .masthead-inner { padding: 0.55rem 1rem; gap: 0.75rem; } + .brand-title { display: none; } /* keep just the logo on small screens */ + .quiz-meta-strip { + font-size: 0.65rem; + padding-left: 0.6rem; + margin-left: 0; + } } /* ---------- Main column ---------- */ @@ -559,46 +585,17 @@ button.link.danger:hover { color: var(--negative); } font-weight: 400; } -/* ---------- Quiz running head ---------- */ - -.quiz-running-head { - display: flex; - justify-content: space-between; - align-items: center; - gap: 1rem; - flex-wrap: wrap; - font-family: var(--mono); - font-size: 0.72rem; - font-weight: 500; - letter-spacing: 0.04em; - text-transform: uppercase; - color: var(--muted); - padding-bottom: 1rem; - margin-bottom: 1.5rem; - border-bottom: 1px solid var(--hairline); -} - -.quiz-running-head .rh-left, -.quiz-running-head .rh-right { - display: inline-flex; - align-items: center; - gap: 0.5rem; -} - -.quiz-running-head #quiz-cert { - color: var(--fg); - font-weight: 600; -} -.quiz-running-head .rh-sep { color: var(--hairline); } +/* ---------- Difficulty pill (used in masthead quiz-meta-strip) ---------- */ .difficulty { - padding: 0.2rem 0.55rem; + padding: 0.15rem 0.5rem; border-radius: 99px; font-family: var(--mono); font-size: 0.62rem; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; + display: inline-block; } .difficulty.easy { background: var(--positive-soft); color: var(--positive); } .difficulty.medium { background: var(--accent-soft); color: var(--accent); } @@ -725,16 +722,67 @@ fieldset#quiz-choices label.incorrect { fieldset#quiz-choices label.incorrect .choice-letter, fieldset#quiz-choices label.incorrect .choice-text { color: var(--negative); } -/* ---------- Quiz actions ---------- */ +/* ---------- Sticky bottom action bar (quiz mode only) ---------- */ -.quiz-actions { +.actionbar { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 50; + background: color-mix(in srgb, var(--bg) 92%, transparent); + backdrop-filter: saturate(180%) blur(12px); + -webkit-backdrop-filter: saturate(180%) blur(12px); + border-top: 1px solid var(--hairline); +} +.actionbar[hidden] { display: none; } + +.actionbar-inner { + max-width: 1080px; + margin: 0 auto; + padding: 0.85rem 1.5rem; display: flex; align-items: center; gap: 1.25rem; - margin-bottom: 0.5rem; + flex-wrap: wrap; + min-height: 64px; +} + +.actionbar-left { + display: inline-flex; + align-items: center; + gap: 0.75rem; flex-wrap: wrap; } +.actionbar-right { + display: inline-flex; + align-items: center; + gap: 0.85rem; + margin-left: auto; + flex-wrap: wrap; + font-family: var(--mono); + font-size: 0.72rem; + font-weight: 500; + letter-spacing: 0.02em; + color: var(--muted); + font-variant-numeric: tabular-nums; +} +.actionbar-right #session-stats, +.actionbar-right #bank-stats { color: var(--fg-soft); } +.actionbar-right .rh-sep { color: var(--hairline); } +.actionbar-right .actionbar-divider { + width: 1px; + height: 16px; + background: var(--hairline); + margin: 0 0.15rem; +} + +/* When quiz is active, leave room at the bottom of main so the fixed + actionbar doesn't cover the last few choices / feedback rows */ +body.quiz-active main { padding-bottom: 6rem; } +body.quiz-active .page-footer { display: none; } + .kbd-hint { font-family: var(--mono); font-size: 0.68rem; @@ -745,7 +793,9 @@ fieldset#quiz-choices label.incorrect .choice-text { color: var(--negative); } align-items: center; gap: 0.25rem; flex-wrap: wrap; + margin-left: 0.5rem; } +.kbd-hint[hidden] { display: none; } .kbd-hint kbd { display: inline-block; min-width: 1.5em; @@ -761,6 +811,15 @@ fieldset#quiz-choices label.incorrect .choice-text { color: var(--negative); } text-align: center; } +@media (max-width: 720px) { + .actionbar-inner { padding: 0.65rem 1rem; gap: 0.6rem; } + .actionbar-right { font-size: 0.65rem; gap: 0.5rem; } + .actionbar-right .actionbar-divider { display: none; } + .actionbar-right #bank-stats { display: none; } /* save room */ + .kbd-hint { display: none; } /* hide kbd hints on small screens */ + body.quiz-active main { padding-bottom: 10rem; } +} + /* ---------- Feedback ---------- */ #quiz-feedback { @@ -807,32 +866,7 @@ fieldset#quiz-choices label.incorrect .choice-text { color: var(--negative); } #quiz-feedback pre { margin: 0.85rem 0; } -/* ---------- Quiz footer ---------- */ - -.quiz-footer { - margin-top: 1.5rem; - padding-top: 1rem; - border-top: 1px solid var(--hairline); - display: flex; - align-items: center; - gap: 0.85rem; - font-family: var(--mono); - font-size: 0.72rem; - font-weight: 500; - letter-spacing: 0.02em; - color: var(--muted); - font-variant-numeric: tabular-nums; - flex-wrap: wrap; -} -.quiz-footer .quiz-controls { - margin-left: auto; - display: inline-flex; - gap: 1.25rem; - align-items: center; -} -.quiz-footer #session-stats, -.quiz-footer #bank-stats { color: var(--fg-soft); } -.quiz-footer .rh-sep { color: var(--hairline); } +/* (quiz-footer removed — replaced by sticky bottom actionbar) */ /* ---------- Stats view ---------- */ From fd84fa50486271699401b77e5ef4d3a40fa898ed Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Thu, 21 May 2026 21:45:21 +0700 Subject: [PATCH 06/33] =?UTF-8?q?feat(practice):=20=E2=86=91/=E2=86=93=20t?= =?UTF-8?q?o=20cycle=20between=20choices=20before=20submit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user picks choice 2 and changes their mind, they can now press ↑ or ↓ to move the selection without taking their hand off the home row. Behaviour: - ↑ / ↓ are only active before submit (after submit they fall through to other handlers; specifically → / N for next-question still work) - Wrap around at edges: ↓ from choice 4 → choice 1; ↑ from 1 → 4 - No current selection: ↓ starts at choice 1, ↑ starts at choice 4 - Triggers the radio's change event so the existing onchange wiring (setting STATE.currentChoice + enabling the Submit button) fires exactly as if the user had clicked Pre-submit kbd hint updated to: 1 2 3 4 or ↑ ↓ select · ↵ submit · S skip Cache versions bumped to ?v=11. --- practice/app.js | 20 +++++++++++++++++++- practice/index.html | 6 +++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/practice/app.js b/practice/app.js index e35aa49..902c538 100644 --- a/practice/app.js +++ b/practice/app.js @@ -26,7 +26,7 @@ // Bump on every deploy that changes app.js / data/*.json. Appended to // bank-JSON fetch URLs so browsers don't serve stale banks after a deploy. - const APP_VERSION = "10"; + const APP_VERSION = "11"; // Title patterns that are placeholder fallbacks (mock-exam questions whose // source heading is `## Question N *(Difficulty)*` with no real title text). @@ -798,6 +798,24 @@ } const submitted = !$("#btn-next").hidden; + // ↑ / ↓ — cycle through choices (only meaningful before submit) + if ((ev.key === "ArrowUp" || ev.key === "ArrowDown") && !submitted) { + const letters = ["A", "B", "C", "D"]; + const currentIdx = letters.indexOf(STATE.currentChoice); + const delta = ev.key === "ArrowDown" ? 1 : -1; + const nextIdx = currentIdx === -1 + ? (ev.key === "ArrowDown" ? 0 : 3) // no selection → start at top/bottom + : (currentIdx + delta + 4) % 4; // wrap around + const radio = document.querySelector( + `fieldset#quiz-choices input[value="${letters[nextIdx]}"]`); + if (radio && !radio.disabled) { + radio.checked = true; + radio.dispatchEvent(new Event("change", { bubbles: true })); + } + ev.preventDefault(); + return; + } + // Enter / Space — primary action (submit before answer, next after) if (ev.key === "Enter" || ev.key === " ") { if (submitted) { diff --git a/practice/index.html b/practice/index.html index af14f01..2c91ed3 100644 --- a/practice/index.html +++ b/practice/index.html @@ -12,7 +12,7 @@ href="https://fonts.googleapis.com/css2?family=Geist:wght@300..700&family=Geist+Mono:wght@400..600&display=swap"> - + @@ -118,7 +118,7 @@

Settings

@@ -155,6 +155,6 @@

Settings

- + From 4bcc0ea73e5cd22792508bb9d8538cd0ac8b438d Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Thu, 21 May 2026 21:50:46 +0700 Subject: [PATCH 07/33] fix(practice): arrow keys move focus + [hidden] beats button.primary + show Space in kbd hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small fixes from live-testing feedback: 1. ↑/↓ now move keyboard focus along with the radio's checked state. Previously the focus ring would stay on the previously-clicked choice while the filled radio moved elsewhere — visually decoupled. Fix: call radio.focus() inside the arrow handler. The :focus-within rule on the label then shows the focus outline on the same choice that's checked. 2. Next button could be clicked before submit because button.primary { display: inline-flex } has higher CSS specificity (0,0,1,1) than the user-agent [hidden] { display: none } rule (0,0,1,0), so the hidden attribute was silently being ignored. Fix: explicit `[hidden] { display: none !important }` at the top of styles.css. This makes the HTML `hidden` attribute always win, regardless of other selectors. Covers all current and future cases. 3. kbd hint now spells out Space alongside Enter. Pre-submit: 1234 / ↑↓ select · ↵ / Space submit · S skip Post-submit: ↵ / Space / N / → next question Cache version bumped to ?v=12. --- practice/app.js | 9 ++++++--- practice/index.html | 8 ++++---- practice/styles.css | 6 ++++++ 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/practice/app.js b/practice/app.js index 902c538..e3e6686 100644 --- a/practice/app.js +++ b/practice/app.js @@ -26,7 +26,7 @@ // Bump on every deploy that changes app.js / data/*.json. Appended to // bank-JSON fetch URLs so browsers don't serve stale banks after a deploy. - const APP_VERSION = "11"; + const APP_VERSION = "12"; // Title patterns that are placeholder fallbacks (mock-exam questions whose // source heading is `## Question N *(Difficulty)*` with no real title text). @@ -798,18 +798,21 @@ } const submitted = !$("#btn-next").hidden; - // ↑ / ↓ — cycle through choices (only meaningful before submit) + // ↑ / ↓ — cycle through choices (only meaningful before submit). + // Move both `checked` AND keyboard focus so the visible focus ring on + // the choice label stays in sync with which radio is selected. if ((ev.key === "ArrowUp" || ev.key === "ArrowDown") && !submitted) { const letters = ["A", "B", "C", "D"]; const currentIdx = letters.indexOf(STATE.currentChoice); const delta = ev.key === "ArrowDown" ? 1 : -1; const nextIdx = currentIdx === -1 - ? (ev.key === "ArrowDown" ? 0 : 3) // no selection → start at top/bottom + ? (ev.key === "ArrowDown" ? 0 : 3) // no selection → top/bottom : (currentIdx + delta + 4) % 4; // wrap around const radio = document.querySelector( `fieldset#quiz-choices input[value="${letters[nextIdx]}"]`); if (radio && !radio.disabled) { radio.checked = true; + radio.focus({ preventScroll: false }); // <-- this is the fix radio.dispatchEvent(new Event("change", { bubbles: true })); } ev.preventDefault(); diff --git a/practice/index.html b/practice/index.html index 2c91ed3..cf13ae4 100644 --- a/practice/index.html +++ b/practice/index.html @@ -12,7 +12,7 @@ href="https://fonts.googleapis.com/css2?family=Geist:wght@300..700&family=Geist+Mono:wght@400..600&display=swap"> - + @@ -119,11 +119,11 @@

Settings

@@ -155,6 +155,6 @@

Settings

- + diff --git a/practice/styles.css b/practice/styles.css index 6ec817e..492ce85 100644 --- a/practice/styles.css +++ b/practice/styles.css @@ -98,6 +98,12 @@ * { box-sizing: border-box; } +/* The HTML `hidden` attribute must always win, even against button.primary + { display: inline-flex } which has higher specificity than the built-in + `[hidden] { display: none }` user-agent rule. Without this, btn-next + stays visually clickable before the user submits. */ +[hidden] { display: none !important; } + html { -webkit-text-size-adjust: 100%; } body { From 65dceef7813ae875d4a9e8f7a89363c0eebd8166 Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Thu, 21 May 2026 21:52:44 +0700 Subject: [PATCH 08/33] fix(practice): allow Space/Enter to submit when focus is on a choice radio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user picked a choice via click or ↑/↓, focus landed on a radio button — and my handleKeydown had `if (target.matches('input, textarea, select')) return` which fired on the radio (it's an ), bailing before reaching the Space/Enter submit branch. Fix: narrow the early-return to *text* form controls. Radios and checkboxes are kept in the handler so Space/Enter/↑/↓/letter keys all keep working when focus is on a choice radio. Cache version bumped to ?v=13. --- practice/app.js | 11 ++++++++--- practice/index.html | 4 ++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/practice/app.js b/practice/app.js index e3e6686..2e9c2b2 100644 --- a/practice/app.js +++ b/practice/app.js @@ -26,7 +26,7 @@ // Bump on every deploy that changes app.js / data/*.json. Appended to // bank-JSON fetch URLs so browsers don't serve stale banks after a deploy. - const APP_VERSION = "12"; + const APP_VERSION = "13"; // Title patterns that are placeholder fallbacks (mock-exam questions whose // source heading is `## Question N *(Difficulty)*` with no real title text). @@ -773,8 +773,13 @@ function handleKeydown(ev) { // Only react when the quiz section is visible if ($("#quiz").hidden) return; - // Ignore when the user is typing inside an input/select - if (ev.target.matches && ev.target.matches("input, textarea, select")) return; + // Ignore when the user is typing in a real input. Radios + checkboxes + // are explicitly NOT excluded — we still want Space/Enter/arrows to work + // when focus is on a choice's radio (which is normal after ↑/↓ or click). + if (ev.target.matches + && ev.target.matches("input:not([type=radio]):not([type=checkbox]), textarea, select")) { + return; + } // Ignore when a modifier key is held (let browser shortcuts pass through) if (ev.ctrlKey || ev.metaKey || ev.altKey) return; diff --git a/practice/index.html b/practice/index.html index cf13ae4..aa014c6 100644 --- a/practice/index.html +++ b/practice/index.html @@ -12,7 +12,7 @@ href="https://fonts.googleapis.com/css2?family=Geist:wght@300..700&family=Geist+Mono:wght@400..600&display=swap"> - + @@ -155,6 +155,6 @@

Settings

- + From eb8ce75253799b81bb4a2c23160a4e89899f9951 Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Thu, 21 May 2026 21:56:15 +0700 Subject: [PATCH 09/33] fix(practice): bottom padding tracks actionbar's actual height User reported: scrolling to the bottom of a long explanation, the sticky actionbar covered the last lines. Root cause was a hard-coded padding-bottom: 6rem that didn't account for the actionbar wrapping onto a second row (Next button + kbd hint on one line, session stats + Stats/Settings/Reset/Switch links on another). Fix: - Read --actionbar-h CSS variable as the bottom padding, with a 7rem fallback + 1.5rem of breathing room - New syncActionbarHeight() in app.js measures the actionbar's bounding rect and writes the value to body's style - ResizeObserver on the actionbar fires the sync whenever its rendered height changes (content wrap, viewport resize, content-driven layout shifts when feedback shows) - Also fires on window resize and on show() transitions - Removed the obsolete mobile-specific padding-bottom override Also include the inline-code wrap fix from earlier: - code { overflow-wrap: anywhere; word-break: break-word } so long unbreakable identifiers (e.g. spark.sql.streaming.stateStore...) wrap inside the question/feedback container instead of overflowing right - pre code { overflow-wrap: normal } so fenced code blocks still use horizontal scroll (preserving indentation) - question-card and #quiz-feedback get min-width:0 + overflow-wrap as defense-in-depth so any other unwrappable content can't stretch the cards past their container Cache version bumped to ?v=14. --- practice/app.js | 28 +++++++++++++++++++++++++++- practice/index.html | 4 ++-- practice/styles.css | 33 ++++++++++++++++++++++++++++++--- 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/practice/app.js b/practice/app.js index 2e9c2b2..bbcf3a5 100644 --- a/practice/app.js +++ b/practice/app.js @@ -26,7 +26,7 @@ // Bump on every deploy that changes app.js / data/*.json. Appended to // bank-JSON fetch URLs so browsers don't serve stale banks after a deploy. - const APP_VERSION = "13"; + const APP_VERSION = "14"; // Title patterns that are placeholder fallbacks (mock-exam questions whose // source heading is `## Question N *(Difficulty)*` with no real title text). @@ -117,6 +117,23 @@ if (actionbar) actionbar.hidden = !inQuiz; if (metaStrip) metaStrip.hidden = !inQuiz; document.body.classList.toggle("quiz-active", inQuiz); + syncActionbarHeight(); + } + + // Keep `--actionbar-h` CSS var in sync with the actionbar's actual + // rendered height. The actionbar wraps onto multiple rows when content + // doesn't fit; if we hard-coded padding-bottom on main, the last + // explanation line would get hidden under the bar. + function syncActionbarHeight() { + const ab = $("#actionbar"); + if (!ab || ab.hidden) { + document.body.style.removeProperty("--actionbar-h"); + return; + } + const h = ab.getBoundingClientRect().height; + if (h > 0) { + document.body.style.setProperty("--actionbar-h", h + "px"); + } } // --- Safe markdown → DOM rendering -------------------------------------- @@ -871,6 +888,15 @@ document.addEventListener("keydown", handleKeydown); + // Observe actionbar height — content wrapping (kbd hint long/short, + // viewport resize, etc.) changes how many rows the actionbar uses. + const actionbar = $("#actionbar"); + if (actionbar && typeof ResizeObserver !== "undefined") { + const ro = new ResizeObserver(() => syncActionbarHeight()); + ro.observe(actionbar); + } + window.addEventListener("resize", syncActionbarHeight); + loadAllBankMetadata().then(certBanks => { STATE.certBanks = certBanks; renderCertPicker(certBanks); diff --git a/practice/index.html b/practice/index.html index aa014c6..fa358d7 100644 --- a/practice/index.html +++ b/practice/index.html @@ -12,7 +12,7 @@ href="https://fonts.googleapis.com/css2?family=Geist:wght@300..700&family=Geist+Mono:wght@400..600&display=swap"> - + @@ -155,6 +155,6 @@

Settings

- + diff --git a/practice/styles.css b/practice/styles.css index 492ce85..2b44968 100644 --- a/practice/styles.css +++ b/practice/styles.css @@ -615,6 +615,15 @@ button.link.danger:hover { color: var(--negative); } border-radius: var(--radius-lg); padding: 2rem; box-shadow: var(--shadow-2); + /* Prevent unbreakable inline content from making the card wider than its + container. min-width:0 lets the box shrink past its intrinsic width. */ + min-width: 0; + overflow-wrap: break-word; +} + +#quiz-feedback { + min-width: 0; + overflow-wrap: break-word; } @media (max-width: 640px) { @@ -785,8 +794,13 @@ fieldset#quiz-choices label.incorrect .choice-text { color: var(--negative); } } /* When quiz is active, leave room at the bottom of main so the fixed - actionbar doesn't cover the last few choices / feedback rows */ -body.quiz-active main { padding-bottom: 6rem; } + actionbar doesn't cover the last few choices / feedback rows. + --actionbar-h is updated by a ResizeObserver in app.js whenever the + actionbar's height changes (wrap onto a second row, viewport resize, + feedback visibility, etc). +1.5rem of breathing room. */ +body.quiz-active main { + padding-bottom: calc(var(--actionbar-h, 7rem) + 1.5rem); +} body.quiz-active .page-footer { display: none; } .kbd-hint { @@ -823,7 +837,6 @@ body.quiz-active .page-footer { display: none; } .actionbar-right .actionbar-divider { display: none; } .actionbar-right #bank-stats { display: none; } /* save room */ .kbd-hint { display: none; } /* hide kbd hints on small screens */ - body.quiz-active main { padding-bottom: 10rem; } } /* ---------- Feedback ---------- */ @@ -978,6 +991,20 @@ code { padding: 0.12rem 0.4rem; border-radius: 4px; color: var(--fg); + /* Long unbroken strings like spark.sql.streaming.stateStore.providerClass + are a single "word" with no word-break points. Allow the renderer to + break anywhere when there's no better option, otherwise the inline + code overflows the right edge of the question/feedback container. */ + overflow-wrap: anywhere; + word-break: break-word; +} + +/* Code blocks (pre > code) handle long lines with horizontal scroll + instead — preserving whitespace + indentation is more important there + than fitting in the container. */ +pre code, pre[class*="language-"] code { + overflow-wrap: normal; + word-break: normal; } pre, pre[class*="language-"] { From d1520ada15c9884d62257bb04775ab452d3625d1 Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Thu, 21 May 2026 21:57:15 +0700 Subject: [PATCH 10/33] fix(practice): drop redundant 'Docs' link from page footer (GitHub link already there) --- practice/index.html | 2 -- 1 file changed, 2 deletions(-) diff --git a/practice/index.html b/practice/index.html index fa358d7..3ac3b3c 100644 --- a/practice/index.html +++ b/practice/index.html @@ -144,8 +144,6 @@

Settings

A study aid, not the official exam. View on GitHub · - Docs - · MIT

From 4bfc77db0fdf18b2df0f7850f9b654c027866522 Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Thu, 21 May 2026 21:57:43 +0700 Subject: [PATCH 11/33] =?UTF-8?q?feat(practice):=20add=20=C2=A9=202026=20@?= =?UTF-8?q?kengio=20attribution=20linking=20to=20GitHub=20profile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- practice/index.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/practice/index.html b/practice/index.html index 3ac3b3c..94d1a06 100644 --- a/practice/index.html +++ b/practice/index.html @@ -145,6 +145,8 @@

Settings

View on GitHub · MIT + · + © 2026 @kengio

From 9303157f86e4a260310a29e01f3b920aec908be2 Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Thu, 21 May 2026 22:01:13 +0700 Subject: [PATCH 12/33] feat(practice): confetti burst on correct answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 40 colourful particles launch from the centre of the correct-answer label, follow a parabolic arc (upward burst → gravity pulls them down), rotate randomly, and fade out around 1.4-1.6s. Pure CSS animation + zero-dependency JS — no library. Implementation details: - fireConfetti(originEl) reads the bounding rect of the correct label so the burst feels like it's coming from that row - Per-particle CSS custom props (--mid-dx, --mid-dy, --end-dx, --end-dy, --rot) describe the parabola apex and final position; one shared CSS keyframe interpolates through them - 10-colour palette (matches per-cert accent gradients + white) - 30% of particles are circular for shape variety - Animation-delay 0-80ms per particle so they don't all launch in a perfectly synchronised flash - Container element appended/removed from so positioning is fixed-relative-to-viewport regardless of scroll Auto-disabled when prefers-reduced-motion: reduce (CSS rule + JS guard). Cache version bumped to ?v=15. --- practice/app.js | 64 ++++++++++++++++++++++++++++++++++++++++++++- practice/index.html | 4 +-- practice/styles.css | 47 +++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 3 deletions(-) diff --git a/practice/app.js b/practice/app.js index bbcf3a5..fa1480a 100644 --- a/practice/app.js +++ b/practice/app.js @@ -26,7 +26,7 @@ // Bump on every deploy that changes app.js / data/*.json. Appended to // bank-JSON fetch URLs so browsers don't serve stale banks after a deploy. - const APP_VERSION = "14"; + const APP_VERSION = "15"; // Title patterns that are placeholder fallbacks (mock-exam questions whose // source heading is `## Question N *(Difficulty)*` with no real title text). @@ -555,6 +555,67 @@ setTimeout(() => plus.remove(), 1200); } + // Zero-dependency confetti burst — 40 particles in random colors, each + // following a parabolic arc with rotation. Origin is the centre of the + // chosen correct-answer label so the burst feels like it comes from that + // row. Auto-skipped when prefers-reduced-motion: reduce is set. + function fireConfetti(originEl) { + if (window.matchMedia + && window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + return; + } + const rect = originEl.getBoundingClientRect(); + const ox = rect.left + rect.width / 2; + const oy = rect.top + rect.height / 2; + + const colors = [ + "#FF4F2C", "#FFAB1F", "#10B981", "#14B8A6", + "#3B82F6", "#6366F1", "#A855F7", "#EC4899", + "#F59E0B", "#FFFFFF", + ]; + const N = 40; + const container = document.createElement("div"); + container.className = "confetti-container"; + + for (let i = 0; i < N; i++) { + const p = document.createElement("span"); + p.className = "confetti-particle"; + + // Mostly-upward initial direction with wide horizontal spread + const angle = -Math.PI / 2 + (Math.random() - 0.5) * Math.PI * 1.3; + const speed = 160 + Math.random() * 200; + const dx = Math.cos(angle) * speed; + const dy = Math.sin(angle) * speed; + // Midpoint of parabola (apex) + const midDx = dx * 0.65; + const midDy = dy * 0.85; + // Final position: initial horizontal + gravity fall + const endDx = dx; + const endDy = dy + 380 + Math.random() * 220; + const rot = (Math.random() - 0.5) * 720; + + p.style.left = ox + "px"; + p.style.top = oy + "px"; + p.style.background = colors[Math.floor(Math.random() * colors.length)]; + // Vary particle shape so it doesn't all look identical + if (Math.random() < 0.3) { + p.style.borderRadius = "50%"; + p.style.width = "8px"; p.style.height = "8px"; + } + p.style.setProperty("--mid-dx", midDx + "px"); + p.style.setProperty("--mid-dy", midDy + "px"); + p.style.setProperty("--end-dx", endDx + "px"); + p.style.setProperty("--end-dy", endDy + "px"); + p.style.setProperty("--rot", rot + "deg"); + p.style.animationDelay = (Math.random() * 80) + "ms"; + + container.appendChild(p); + } + + document.body.appendChild(container); + setTimeout(() => container.remove(), 2000); + } + function submitAnswer() { if (!STATE.currentChoice) return; const q = STATE.currentQ; @@ -592,6 +653,7 @@ } if (correct && correctLabel) { showFloatPlus(correctLabel); + fireConfetti(correctLabel); // Toast at 3, 5, 7, 10, every 5 thereafter if (STATE.streak === 3 || STATE.streak === 5 || STATE.streak === 7 || STATE.streak === 10 || (STATE.streak > 10 && STATE.streak % 5 === 0)) { diff --git a/practice/index.html b/practice/index.html index 94d1a06..c99c268 100644 --- a/practice/index.html +++ b/practice/index.html @@ -12,7 +12,7 @@ href="https://fonts.googleapis.com/css2?family=Geist:wght@300..700&family=Geist+Mono:wght@400..600&display=swap"> - + @@ -155,6 +155,6 @@

Settings

- + diff --git a/practice/styles.css b/practice/styles.css index 2b44968..86d3efb 100644 --- a/practice/styles.css +++ b/practice/styles.css @@ -1225,3 +1225,50 @@ fieldset#quiz-choices label:focus-within { 20% { opacity: 1; transform: translate(0, calc(-50% - 8px)); } 100% { opacity: 0; transform: translate(0, calc(-50% - 36px)); } } + +/* ---------- Confetti burst on correct answer ---------- */ + +.confetti-container { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 1000; + overflow: hidden; +} + +.confetti-particle { + position: absolute; + width: 8px; + height: 12px; + border-radius: 1.5px; + pointer-events: none; + transform: translate(-50%, -50%); + animation: confetti-arc 1.4s cubic-bezier(0.2, 0.6, 0.35, 1) forwards; + will-change: transform, opacity; +} + +@keyframes confetti-arc { + 0% { + transform: translate(-50%, -50%) rotate(0); + opacity: 1; + } + 10% { opacity: 1; } + 50% { + transform: translate( + calc(-50% + var(--mid-dx)), + calc(-50% + var(--mid-dy)) + ) rotate(calc(var(--rot) / 2)); + opacity: 1; + } + 100% { + transform: translate( + calc(-50% + var(--end-dx)), + calc(-50% + var(--end-dy)) + ) rotate(var(--rot)); + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .confetti-container { display: none; } +} From f46e78e4c5580798b88b11fd417e3bd6ffd377bb Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Thu, 21 May 2026 22:01:56 +0700 Subject: [PATCH 13/33] fix(practice): hide radio's own focus outline (label's :focus-within outline is enough) Was rendering a double focus ring on keyboard-focused choices: one around the radio button itself (from the universal :focus-visible rule) and one around the whole label row (from :focus-within). Per user feedback, keep only the outer label outline. --- practice/app.js | 2 +- practice/index.html | 4 ++-- practice/styles.css | 7 +++++++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/practice/app.js b/practice/app.js index fa1480a..dcc0c83 100644 --- a/practice/app.js +++ b/practice/app.js @@ -26,7 +26,7 @@ // Bump on every deploy that changes app.js / data/*.json. Appended to // bank-JSON fetch URLs so browsers don't serve stale banks after a deploy. - const APP_VERSION = "15"; + const APP_VERSION = "16"; // Title patterns that are placeholder fallbacks (mock-exam questions whose // source heading is `## Question N *(Difficulty)*` with no real title text). diff --git a/practice/index.html b/practice/index.html index c99c268..2d71fa3 100644 --- a/practice/index.html +++ b/practice/index.html @@ -12,7 +12,7 @@ href="https://fonts.googleapis.com/css2?family=Geist:wght@300..700&family=Geist+Mono:wght@400..600&display=swap"> - + @@ -155,6 +155,6 @@

Settings

- + diff --git a/practice/styles.css b/practice/styles.css index 86d3efb..5335040 100644 --- a/practice/styles.css +++ b/practice/styles.css @@ -1086,10 +1086,17 @@ pre code, pre[class*="language-"] code { border-radius: 4px; } button:focus-visible { outline-offset: 3px; } + +/* The label gets the focus ring via :focus-within when the radio inside + is keyboard-focused — that's the visible "selected row" indicator. + Suppress the radio's own :focus-visible outline so we don't double up. */ fieldset#quiz-choices label:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; } +fieldset#quiz-choices input[type=radio]:focus-visible { + outline: none; +} /* ---------- Motion & micro-interactions ---------- */ From 7479ebafbe130f29da0f0b4c9e6f4f10fe52931a Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Thu, 21 May 2026 22:04:51 +0700 Subject: [PATCH 14/33] fix(practice): unify mouse + keyboard selection visuals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback: the keyboard-selected choice rendered with a loud orange focus-within outline while the mouse-selected choice used a subtle cream-background tint. They preferred the subtle look for both. Changes: - New rule label:has(input:checked):not(.correct):not(.incorrect) → same surface-soft background + fg-soft border as :hover, so checked state matches hover/click visual regardless of how it was selected - Dropped the label:focus-within and input[type=radio]:focus-visible outlines — no more orange ring on keyboard nav Cache version bumped to ?v=17. --- practice/app.js | 2 +- practice/index.html | 4 ++-- practice/styles.css | 23 +++++++++++++---------- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/practice/app.js b/practice/app.js index dcc0c83..bb315cf 100644 --- a/practice/app.js +++ b/practice/app.js @@ -26,7 +26,7 @@ // Bump on every deploy that changes app.js / data/*.json. Appended to // bank-JSON fetch URLs so browsers don't serve stale banks after a deploy. - const APP_VERSION = "16"; + const APP_VERSION = "17"; // Title patterns that are placeholder fallbacks (mock-exam questions whose // source heading is `## Question N *(Difficulty)*` with no real title text). diff --git a/practice/index.html b/practice/index.html index 2d71fa3..89847da 100644 --- a/practice/index.html +++ b/practice/index.html @@ -12,7 +12,7 @@ href="https://fonts.googleapis.com/css2?family=Geist:wght@300..700&family=Geist+Mono:wght@400..600&display=swap"> - + @@ -155,6 +155,6 @@

Settings

- + diff --git a/practice/styles.css b/practice/styles.css index 5335040..9907a4a 100644 --- a/practice/styles.css +++ b/practice/styles.css @@ -674,6 +674,14 @@ fieldset#quiz-choices label:hover:not(.disabled) { border-color: var(--fg-soft); background: var(--surface-soft); } +/* Selected state — same subtle tint as hover so mouse-click and + keyboard-arrow selections look identical. (Was orange focus-within + outline for keyboard only, which felt jarring next to the gentle + mouse-click highlight.) */ +fieldset#quiz-choices label:has(input[type=radio]:checked):not(.correct):not(.incorrect) { + border-color: var(--fg-soft); + background: var(--surface-soft); +} fieldset#quiz-choices label.disabled { cursor: default; pointer-events: none; @@ -1087,16 +1095,11 @@ pre code, pre[class*="language-"] code { } button:focus-visible { outline-offset: 3px; } -/* The label gets the focus ring via :focus-within when the radio inside - is keyboard-focused — that's the visible "selected row" indicator. - Suppress the radio's own :focus-visible outline so we don't double up. */ -fieldset#quiz-choices label:focus-within { - outline: 2px solid var(--accent); - outline-offset: 2px; -} -fieldset#quiz-choices input[type=radio]:focus-visible { - outline: none; -} +/* The choice label uses a subtle background tint (matching hover) for both + mouse-click and keyboard-arrow selection — see :has(input:checked) above. + Drop the loud orange focus outline so they look consistent. */ +fieldset#quiz-choices label:focus-within { outline: none; } +fieldset#quiz-choices input[type=radio]:focus-visible { outline: none; } /* ---------- Motion & micro-interactions ---------- */ From e4ce192641a4af63678325bb2a1b3a0dcc6851c3 Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Thu, 21 May 2026 22:10:12 +0700 Subject: [PATCH 15/33] fix(practice): distinct hover vs selected styles + reset focus on new question User reported choices looking "stuck selected" between questions. Two contributing issues: 1. Hover and selected used identical styling (cream bg + fg-soft border), so when the mouse rested on any choice in a new question it was indistinguishable from the actually-picked choice. Fix: - Hover: just darken the border (no background) - Selected: cream background + darker (--fg) border These are now clearly distinct at a glance. 2. After clicking Next via a keyboard shortcut, focus could remain on the previously-focused control, and any residual :focus-within / :hover state could carry over visually to the new question's choices at the same screen position. Fix: blur the active element at the start of renderQuiz so the new question loads with a clean focus state. Cache version bumped to ?v=18. --- practice/app.js | 10 +++++++++- practice/index.html | 4 ++-- practice/styles.css | 12 ++++++------ 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/practice/app.js b/practice/app.js index bb315cf..c36aa6f 100644 --- a/practice/app.js +++ b/practice/app.js @@ -26,7 +26,7 @@ // Bump on every deploy that changes app.js / data/*.json. Appended to // bank-JSON fetch URLs so browsers don't serve stale banks after a deploy. - const APP_VERSION = "17"; + const APP_VERSION = "18"; // Title patterns that are placeholder fallbacks (mock-exam questions whose // source heading is `## Question N *(Difficulty)*` with no real title text). @@ -472,6 +472,14 @@ } STATE.currentQ = q; STATE.currentChoice = null; + // Defensive: blur any leftover focus from the previous question so + // a residual :focus-within / :hover style doesn't carry over to a + // choice in the new question (which would look like a stuck selection). + if (document.activeElement + && document.activeElement !== document.body + && typeof document.activeElement.blur === "function") { + try { document.activeElement.blur(); } catch (_) { /* no-op */ } + } $("#quiz-cert").textContent = STATE.bank.certTitle; $("#quiz-counter").textContent = diff --git a/practice/index.html b/practice/index.html index 89847da..9ad4f41 100644 --- a/practice/index.html +++ b/practice/index.html @@ -12,7 +12,7 @@ href="https://fonts.googleapis.com/css2?family=Geist:wght@300..700&family=Geist+Mono:wght@400..600&display=swap"> - + @@ -155,6 +155,6 @@

Settings

- + diff --git a/practice/styles.css b/practice/styles.css index 9907a4a..7aed581 100644 --- a/practice/styles.css +++ b/practice/styles.css @@ -670,16 +670,16 @@ fieldset#quiz-choices label { transition: all 0.15s var(--ease); position: relative; } +/* Hover — just darken the border, no background. Keeps hover visually + distinct from the cream-tinted "selected" state below so the user + can always tell at a glance which row they've actually picked. */ fieldset#quiz-choices label:hover:not(.disabled) { border-color: var(--fg-soft); - background: var(--surface-soft); } -/* Selected state — same subtle tint as hover so mouse-click and - keyboard-arrow selections look identical. (Was orange focus-within - outline for keyboard only, which felt jarring next to the gentle - mouse-click highlight.) */ +/* Selected — cream tint + dark border. Same look whether the user + clicked or used ↑/↓ keys; clearly distinct from plain hover. */ fieldset#quiz-choices label:has(input[type=radio]:checked):not(.correct):not(.incorrect) { - border-color: var(--fg-soft); + border-color: var(--fg); background: var(--surface-soft); } fieldset#quiz-choices label.disabled { From cf1d1e661db0f223d86bf4f7e82c22ffcfb4f3f1 Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Thu, 21 May 2026 22:18:06 +0700 Subject: [PATCH 16/33] feat(practice): exam timer + wall clock + slide transition + hover removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four user-driven UX improvements in one batch: 1. Drop the hover visual entirely. Hovering a choice now gives no border/background change — only an actual selection (click, ↑/↓, or 1-4) colors a row. Fixes "stuck highlight" complaints where a leftover mouse position made hover look identical to selection while the user was keyboard-navigating. 2. Slide-in transition on every new question. The .question-card class gets a slide-in animation re-triggered via a class toggle + reflow in renderQuiz(), giving a clear visual cue that a fresh question has appeared. 3. Wall clock pill in the masthead — shows local time HH:MM, updates every second via setInterval. Helps you keep an eye on the time during long practice sessions. 4. Exam timer pill in the masthead — set duration in Settings (None / 30 / 60 / 90 / 120 minutes; 90 = Associate exam, 120 = Professional exam). Counts down MM:SS, pulses warning-red under 5 minutes, locks to 00:00 + shows a "Time's up" toast when it expires. Timer choice persisted in localStorage; starts fresh on each new bank load. CSS: - Selected state now uses accent-soft bg + accent border — distinctive color hover can never produce, so there's no visual confusion about which row is picked even if the mouse is over something else - New .meta-pill base style + .timer .warning/.expired variants - @keyframes question-slide gated behind prefers-reduced-motion Cache version bumped to ?v=19. --- practice/app.js | 108 ++++++++++++++++++++++++++++++++++++++++++-- practice/index.html | 17 ++++++- practice/styles.css | 80 ++++++++++++++++++++++++++++---- 3 files changed, 190 insertions(+), 15 deletions(-) diff --git a/practice/app.js b/practice/app.js index c36aa6f..4e86000 100644 --- a/practice/app.js +++ b/practice/app.js @@ -26,7 +26,7 @@ // Bump on every deploy that changes app.js / data/*.json. Appended to // bank-JSON fetch URLs so browsers don't serve stale banks after a deploy. - const APP_VERSION = "18"; + const APP_VERSION = "19"; // Title patterns that are placeholder fallbacks (mock-exam questions whose // source heading is `## Question N *(Difficulty)*` with no real title text). @@ -55,6 +55,7 @@ const STORAGE_PREFIX = "dbx-practice-"; const THEME_KEY = "dbx-practice-theme"; + const TIMER_KEY = "dbx-practice-timer-minutes"; const THEMES = ["auto", "light", "dark"]; const THEME_ICONS = { auto: "🖥️", light: "☀️", dark: "🌙" }; const STATE = { @@ -71,8 +72,11 @@ difficulty: "", }, sequentialIndex: 0, - certBanks: null, // Map from loadAllBankMetadata, cached on first load - streak: 0, // consecutive-correct counter for the streak toast + certBanks: null, + streak: 0, + timerMinutes: 0, // 0 = no timer; >0 = exam timer total length in minutes + timerEnd: null, // absolute epoch ms when the timer expires, or null + timerExpired: false, }; // --- DOM helpers --------------------------------------------------------- @@ -282,6 +286,11 @@ const data = await res.json(); STATE.bank = data; STATE.history = loadHistory(data.cert); + // Start the configured timer fresh when entering a new bank + if (STATE.timerMinutes > 0) { + startTimer(STATE.timerMinutes); + updateClockAndTimer(); + } populateSettings(); renderQuiz(); show("quiz"); @@ -480,6 +489,13 @@ && typeof document.activeElement.blur === "function") { try { document.activeElement.blur(); } catch (_) { /* no-op */ } } + // Re-trigger the slide-in animation on every render + const card = document.querySelector(".question-card"); + if (card) { + card.classList.remove("slide-in"); + void card.offsetWidth; // force reflow so animation restarts + card.classList.add("slide-in"); + } $("#quiz-cert").textContent = STATE.bank.certTitle; $("#quiz-counter").textContent = @@ -814,18 +830,98 @@ $("#setting-mode").value = STATE.settings.mode; $("#setting-domain").value = STATE.settings.domain; $("#setting-difficulty").value = STATE.settings.difficulty; + $("#setting-timer").value = String(STATE.timerMinutes || 0); } function applySettings() { STATE.settings.mode = $("#setting-mode").value; STATE.settings.domain = $("#setting-domain").value; STATE.settings.difficulty = $("#setting-difficulty").value; + const tmin = parseInt($("#setting-timer").value, 10) || 0; + if (tmin !== STATE.timerMinutes || tmin > 0) { + saveTimerMinutes(tmin); + startTimer(tmin); + updateClockAndTimer(); + } STATE.sequentialIndex = 0; STATE.seenThisSession.clear(); renderQuiz(); show("quiz"); } + // --- Clock + timer ------------------------------------------------------- + + function loadTimerMinutes() { + try { + const v = parseInt(localStorage.getItem(TIMER_KEY) || "0", 10); + return Number.isFinite(v) && v >= 0 ? v : 0; + } catch (_) { return 0; } + } + + function saveTimerMinutes(min) { + try { localStorage.setItem(TIMER_KEY, String(min)); } catch (_) { /* no-op */ } + } + + function startTimer(minutes) { + STATE.timerMinutes = minutes; + STATE.timerExpired = false; + STATE.timerEnd = minutes > 0 ? Date.now() + minutes * 60 * 1000 : null; + } + + function showTimerExpiredToast() { + const existing = document.querySelector(".streak-toast"); + if (existing) existing.remove(); + const toast = el("div", { className: "streak-toast" }, + el("span", { className: "streak-num", + style: "background:var(--negative);color:#fff" }, "Time's up"), + " · review your stats or keep going"); + document.body.appendChild(toast); + void toast.offsetWidth; + toast.classList.add("show"); + setTimeout(() => toast.remove(), 3200); + } + + function updateClockAndTimer() { + // Wall clock + const clockEl = $("#quiz-clock"); + if (clockEl) { + const now = new Date(); + clockEl.textContent = now.toLocaleTimeString([], { + hour: "2-digit", minute: "2-digit", hour12: false, + }); + } + // Timer + const timerEl = $("#quiz-timer"); + if (!timerEl) return; + if (STATE.timerExpired) { + timerEl.textContent = "00:00"; + timerEl.classList.add("expired"); + timerEl.classList.remove("warning"); + timerEl.hidden = false; + return; + } + if (!STATE.timerEnd) { + timerEl.hidden = true; + timerEl.classList.remove("warning", "expired"); + return; + } + const remainMs = Math.max(0, STATE.timerEnd - Date.now()); + const totalSec = Math.floor(remainMs / 1000); + const m = Math.floor(totalSec / 60); + const s = totalSec % 60; + timerEl.textContent = + String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0"); + timerEl.hidden = false; + timerEl.classList.toggle("warning", + remainMs > 0 && remainMs <= 5 * 60 * 1000); + if (remainMs === 0) { + STATE.timerExpired = true; + timerEl.classList.add("expired"); + timerEl.classList.remove("warning"); + showTimerExpiredToast(); + } + } + // --- Theme toggle -------------------------------------------------------- function loadTheme() { @@ -958,6 +1054,12 @@ document.addEventListener("keydown", handleKeydown); + // Load timer preference + start the clock interval. The clock pill + // updates every second whether a timer is active or not. + STATE.timerMinutes = loadTimerMinutes(); + updateClockAndTimer(); + setInterval(updateClockAndTimer, 1000); + // Observe actionbar height — content wrapping (kbd hint long/short, // viewport resize, etc.) changes how many rows the actionbar uses. const actionbar = $("#actionbar"); diff --git a/practice/index.html b/practice/index.html index 9ad4f41..fe55c36 100644 --- a/practice/index.html +++ b/practice/index.html @@ -12,7 +12,7 @@ href="https://fonts.googleapis.com/css2?family=Geist:wght@300..700&family=Geist+Mono:wght@400..600&display=swap"> - + @@ -43,6 +43,9 @@
+ +
@@ -155,6 +168,6 @@

Settings

- + diff --git a/practice/styles.css b/practice/styles.css index 7aed581..65ce708 100644 --- a/practice/styles.css +++ b/practice/styles.css @@ -227,6 +227,46 @@ a:hover { text-decoration-color: var(--accent); } gap: 0.5rem; flex-shrink: 0; margin-left: auto; + align-items: center; +} + +.meta-pill { + font-family: var(--mono); + font-size: 0.7rem; + font-weight: 500; + letter-spacing: 0.02em; + color: var(--muted); + padding: 0.4rem 0.7rem; + border: 1px solid var(--hairline); + border-radius: 99px; + background: var(--surface); + font-variant-numeric: tabular-nums; + white-space: nowrap; + display: inline-block; +} +.meta-pill.timer { + color: var(--fg); + font-weight: 600; +} +.meta-pill.timer.warning { + color: var(--accent); + border-color: var(--accent); + background: var(--accent-soft); + animation: timer-pulse 1s ease-in-out infinite; +} +.meta-pill.timer.expired { + color: var(--negative); + border-color: var(--negative); + background: var(--negative-soft); + animation: none; +} +@keyframes timer-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.55; } +} + +@media (max-width: 720px) { + #quiz-clock { display: none; } /* save room — timer is more important */ } #btn-theme { @@ -670,17 +710,18 @@ fieldset#quiz-choices label { transition: all 0.15s var(--ease); position: relative; } -/* Hover — just darken the border, no background. Keeps hover visually - distinct from the cream-tinted "selected" state below so the user - can always tell at a glance which row they've actually picked. */ -fieldset#quiz-choices label:hover:not(.disabled) { - border-color: var(--fg-soft); -} -/* Selected — cream tint + dark border. Same look whether the user - clicked or used ↑/↓ keys; clearly distinct from plain hover. */ +/* Hover gives no visual cue — only an actual selection (click, arrow + keys, or 1-4) changes a row's appearance. This prevents leftover + mouse position from looking like a selection. The mouse cursor turns + into a pointer on hover, which is enough affordance that the row is + clickable. */ + +/* Selected — the only state that changes a row's color. Accent-tinted + background + accent border, identical regardless of how the user + picked (click, arrow keys, or number key). */ fieldset#quiz-choices label:has(input[type=radio]:checked):not(.correct):not(.incorrect) { - border-color: var(--fg); - background: var(--surface-soft); + border-color: var(--accent); + background: var(--accent-soft); } fieldset#quiz-choices label.disabled { cursor: default; @@ -1142,6 +1183,25 @@ fieldset#quiz-choices input[type=radio]:focus-visible { outline: none; } to { opacity: 1; transform: translateY(0); } } +/* Question-to-question slide transition. Applied via a class toggle in + renderQuiz() so the animation re-fires for each new question even + though the .question-card element itself is reused. */ +@media (prefers-reduced-motion: no-preference) { + .question-card.slide-in { + animation: question-slide 0.28s cubic-bezier(0.2, 0.7, 0.2, 1); + } +} +@keyframes question-slide { + from { + opacity: 0; + transform: translateX(18px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + @keyframes pulse-correct { 0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(21,128,61,0); } 35% { transform: scale(1.015); box-shadow: 0 0 0 6px rgba(21,128,61,0.18); } From cc0ca3416aecb7ed4616eea08866ddd60873b5d3 Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Thu, 21 May 2026 22:24:24 +0700 Subject: [PATCH 17/33] feat(practice): split clock + timer into distinct regions, slow slide animation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Header (ambient awareness): 14:32 ●Auto Action bar (active session state): [TIMER 47:23] [Submit] [Skip] … Two visually distinct regions instead of two near-identical pills: - Wall clock in the masthead is now plain mono text (no border, no background) in a muted tone — secondary, "what's the wall time" - Exam timer moved to the action bar with a real labelled pill: small "TIMER" header in uppercase letterspaced caps + large MM:SS value. Sits right next to the Submit button so it visually belongs to the "active state" group, not the ambient header chrome. - Warning state (< 5 min) pulses accent-coloured; expired state locks to negative red, no animation. - Mobile (≤ 720px): clock is hidden to save room, timer drops the "TIMER" label and shows just MM:SS. Slide transition slowed from 0.28s → 0.55s with a softer ease curve and a longer travel (28px vs 18px). The new fade-in opacity stop at 30% gives the eye a clearer "okay, new question arrived" cue. Cache version bumped to ?v=20. --- practice/app.js | 9 +++--- practice/index.html | 13 +++++---- practice/styles.css | 69 ++++++++++++++++++++++++++++++++------------- 3 files changed, 63 insertions(+), 28 deletions(-) diff --git a/practice/app.js b/practice/app.js index 4e86000..85221a6 100644 --- a/practice/app.js +++ b/practice/app.js @@ -26,7 +26,7 @@ // Bump on every deploy that changes app.js / data/*.json. Appended to // bank-JSON fetch URLs so browsers don't serve stale banks after a deploy. - const APP_VERSION = "19"; + const APP_VERSION = "20"; // Title patterns that are placeholder fallbacks (mock-exam questions whose // source heading is `## Question N *(Difficulty)*` with no real title text). @@ -893,8 +893,10 @@ // Timer const timerEl = $("#quiz-timer"); if (!timerEl) return; + const valueEl = timerEl.querySelector(".timer-value"); + const setValue = (text) => { if (valueEl) valueEl.textContent = text; }; if (STATE.timerExpired) { - timerEl.textContent = "00:00"; + setValue("00:00"); timerEl.classList.add("expired"); timerEl.classList.remove("warning"); timerEl.hidden = false; @@ -909,8 +911,7 @@ const totalSec = Math.floor(remainMs / 1000); const m = Math.floor(totalSec / 60); const s = totalSec % 60; - timerEl.textContent = - String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0"); + setValue(String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0")); timerEl.hidden = false; timerEl.classList.toggle("warning", remainMs > 0 && remainMs <= 5 * 60 * 1000); diff --git a/practice/index.html b/practice/index.html index fe55c36..06083da 100644 --- a/practice/index.html +++ b/practice/index.html @@ -12,7 +12,7 @@ href="https://fonts.googleapis.com/css2?family=Geist:wght@300..700&family=Geist+Mono:wght@400..600&display=swap"> - + @@ -43,9 +43,7 @@
- - + @@ -168,6 +171,6 @@

Settings

- + diff --git a/practice/styles.css b/practice/styles.css index 65ce708..dbcfbf2 100644 --- a/practice/styles.css +++ b/practice/styles.css @@ -230,43 +230,70 @@ a:hover { text-decoration-color: var(--accent); } align-items: center; } -.meta-pill { +/* Wall clock in masthead — minimal, ambient. No pill, no border; + just plain mono text in a muted tone so it stays clearly secondary + to the timer (which lives in the action bar). */ +#quiz-clock { font-family: var(--mono); - font-size: 0.7rem; + font-size: 0.78rem; font-weight: 500; letter-spacing: 0.02em; color: var(--muted); - padding: 0.4rem 0.7rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; + padding-right: 0.25rem; +} + +/* Exam timer in the actionbar — prominent, labelled, lives next to + the Submit/Skip buttons because it's part of the "active session" + state, not ambient awareness. */ +.actionbar-timer { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.45rem 0.85rem 0.45rem 0.65rem; border: 1px solid var(--hairline); border-radius: 99px; background: var(--surface); + font-family: var(--mono); font-variant-numeric: tabular-nums; white-space: nowrap; - display: inline-block; + transition: color 0.15s, border-color 0.15s, background 0.15s; } -.meta-pill.timer { - color: var(--fg); +.actionbar-timer .timer-label { + font-size: 0.6rem; font-weight: 600; + letter-spacing: 0.12em; + color: var(--muted); } -.meta-pill.timer.warning { - color: var(--accent); +.actionbar-timer .timer-value { + font-size: 0.95rem; + font-weight: 600; + color: var(--fg); +} +.actionbar-timer.warning { border-color: var(--accent); background: var(--accent-soft); animation: timer-pulse 1s ease-in-out infinite; } -.meta-pill.timer.expired { - color: var(--negative); +.actionbar-timer.warning .timer-label, +.actionbar-timer.warning .timer-value { color: var(--accent); } +.actionbar-timer.expired { border-color: var(--negative); background: var(--negative-soft); animation: none; } +.actionbar-timer.expired .timer-label, +.actionbar-timer.expired .timer-value { color: var(--negative); } @keyframes timer-pulse { 0%, 100% { opacity: 1; } - 50% { opacity: 0.55; } + 50% { opacity: 0.6; } } @media (max-width: 720px) { - #quiz-clock { display: none; } /* save room — timer is more important */ + #quiz-clock { display: none; } /* save room on mobile */ + .actionbar-timer .timer-label { display: none; } /* keep just MM:SS */ + .actionbar-timer { padding: 0.4rem 0.7rem; } } #btn-theme { @@ -1183,20 +1210,24 @@ fieldset#quiz-choices input[type=radio]:focus-visible { outline: none; } to { opacity: 1; transform: translateY(0); } } -/* Question-to-question slide transition. Applied via a class toggle in - renderQuiz() so the animation re-fires for each new question even - though the .question-card element itself is reused. */ +/* Question-to-question slide transition. 0.55s is slow enough to be + clearly perceptible without being annoying — the user can see "okay, + new question" before reading. The card travels a bit further (28px) + so the motion registers even at this longer duration. */ @media (prefers-reduced-motion: no-preference) { .question-card.slide-in { - animation: question-slide 0.28s cubic-bezier(0.2, 0.7, 0.2, 1); + animation: question-slide 0.55s cubic-bezier(0.16, 0.84, 0.32, 1); } } @keyframes question-slide { - from { + 0% { opacity: 0; - transform: translateX(18px); + transform: translateX(28px); } - to { + 30% { + opacity: 0.4; + } + 100% { opacity: 1; transform: translateX(0); } From 16a61f1e1e9dd4ac1145df8cf09227a18b53a704 Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Thu, 21 May 2026 22:25:41 +0700 Subject: [PATCH 18/33] fix(practice): move timer to far-right of actionbar, away from action buttons Per user feedback the timer sitting next to Submit/Skip/Next felt crowded with the action cluster. Moved to the rightmost slot of the actionbar after the Stats/Settings/Reset/Switch links, with a vertical divider separating it from the controls so it reads as its own zone. Cache version bumped to ?v=21. --- practice/app.js | 2 +- practice/index.html | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/practice/app.js b/practice/app.js index 85221a6..75e60e3 100644 --- a/practice/app.js +++ b/practice/app.js @@ -26,7 +26,7 @@ // Bump on every deploy that changes app.js / data/*.json. Appended to // bank-JSON fetch URLs so browsers don't serve stale banks after a deploy. - const APP_VERSION = "20"; + const APP_VERSION = "21"; // Title patterns that are placeholder fallbacks (mock-exam questions whose // source heading is `## Question N *(Difficulty)*` with no real title text). diff --git a/practice/index.html b/practice/index.html index 06083da..d318e58 100644 --- a/practice/index.html +++ b/practice/index.html @@ -12,7 +12,7 @@ href="https://fonts.googleapis.com/css2?family=Geist:wght@300..700&family=Geist+Mono:wght@400..600&display=swap"> - + @@ -125,11 +125,6 @@

Settings

@@ -171,6 +172,6 @@

Settings

- + From bc06d0045e9db2b187d74057c4c02430663f82ad Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Thu, 21 May 2026 22:30:17 +0700 Subject: [PATCH 19/33] fix(practice): move timer into masthead quiz-meta-strip, next to question counter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per user feedback the timer worked better visually beside the question counter — "Q6 this session · ⏱ 22:27" reads as a single line of session state instead of being split between header and footer. Changes: - HTML: #quiz-timer moved from the actionbar back into the masthead quiz-meta-strip, after #quiz-counter - CSS: new .meta-timer pill style — compact, ⏱ icon via ::before, monospace MM:SS, pulses accent-coloured under 5 min, locks negative red when expired. Removed the obsolete .actionbar-timer styling. - Action bar is back to just submit/skip/next on the left and the stats + Stats/Settings/Reset/Switch links on the right. Cache version bumped to ?v=22. --- practice/app.js | 2 +- practice/index.html | 14 ++++++-------- practice/styles.css | 41 +++++++++++++++++++++-------------------- 3 files changed, 28 insertions(+), 29 deletions(-) diff --git a/practice/app.js b/practice/app.js index 75e60e3..1e4030c 100644 --- a/practice/app.js +++ b/practice/app.js @@ -26,7 +26,7 @@ // Bump on every deploy that changes app.js / data/*.json. Appended to // bank-JSON fetch URLs so browsers don't serve stale banks after a deploy. - const APP_VERSION = "21"; + const APP_VERSION = "22"; // Title patterns that are placeholder fallbacks (mock-exam questions whose // source heading is `## Question N *(Difficulty)*` with no real title text). diff --git a/practice/index.html b/practice/index.html index d318e58..0cccd34 100644 --- a/practice/index.html +++ b/practice/index.html @@ -12,7 +12,7 @@ href="https://fonts.googleapis.com/css2?family=Geist:wght@300..700&family=Geist+Mono:wght@400..600&display=swap"> - + @@ -40,6 +40,10 @@ · +
@@ -146,12 +150,6 @@

Settings

- -
@@ -172,6 +170,6 @@

Settings

- + diff --git a/practice/styles.css b/practice/styles.css index dbcfbf2..4b83044 100644 --- a/practice/styles.css +++ b/practice/styles.css @@ -244,14 +244,16 @@ a:hover { text-decoration-color: var(--accent); } padding-right: 0.25rem; } -/* Exam timer in the actionbar — prominent, labelled, lives next to - the Submit/Skip buttons because it's part of the "active session" - state, not ambient awareness. */ -.actionbar-timer { +/* Exam timer in the masthead quiz-meta-strip — lives RIGHT AFTER the + question counter so "Q6 this session · 22:27 remaining" reads as a + single line of session state. Subtle pill with monospace value; + warning state pulses accent-coloured, expired locks negative-red. */ +.meta-timer { display: inline-flex; align-items: center; - gap: 0.5rem; - padding: 0.45rem 0.85rem 0.45rem 0.65rem; + gap: 0.35rem; + padding: 0.18rem 0.55rem; + margin-left: 0.4rem; border: 1px solid var(--hairline); border-radius: 99px; background: var(--surface); @@ -260,31 +262,32 @@ a:hover { text-decoration-color: var(--accent); } white-space: nowrap; transition: color 0.15s, border-color 0.15s, background 0.15s; } -.actionbar-timer .timer-label { - font-size: 0.6rem; - font-weight: 600; - letter-spacing: 0.12em; +.meta-timer::before { + content: "⏱"; + font-size: 0.78rem; color: var(--muted); + text-transform: none; } -.actionbar-timer .timer-value { - font-size: 0.95rem; +.meta-timer .timer-value { + font-size: 0.78rem; font-weight: 600; color: var(--fg); + letter-spacing: 0; } -.actionbar-timer.warning { +.meta-timer.warning { border-color: var(--accent); background: var(--accent-soft); animation: timer-pulse 1s ease-in-out infinite; } -.actionbar-timer.warning .timer-label, -.actionbar-timer.warning .timer-value { color: var(--accent); } -.actionbar-timer.expired { +.meta-timer.warning::before, +.meta-timer.warning .timer-value { color: var(--accent); } +.meta-timer.expired { border-color: var(--negative); background: var(--negative-soft); animation: none; } -.actionbar-timer.expired .timer-label, -.actionbar-timer.expired .timer-value { color: var(--negative); } +.meta-timer.expired::before, +.meta-timer.expired .timer-value { color: var(--negative); } @keyframes timer-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } @@ -292,8 +295,6 @@ a:hover { text-decoration-color: var(--accent); } @media (max-width: 720px) { #quiz-clock { display: none; } /* save room on mobile */ - .actionbar-timer .timer-label { display: none; } /* keep just MM:SS */ - .actionbar-timer { padding: 0.4rem 0.7rem; } } #btn-theme { From 71ac0487d847169cc1676d51c0e5b991a5b0654a Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Thu, 21 May 2026 22:33:20 +0700 Subject: [PATCH 20/33] fix(practice): timer was being clipped by quiz-meta-strip overflow:hidden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Putting #quiz-timer inside the .quiz-meta-strip hid it whenever the cert + domain text pushed past the strip's allotted width — the strip's overflow:hidden + text-overflow:ellipsis chopped off the rightmost child (= the timer). Fix: keep the timer as a SIBLING of the strip, not a child. Sits visually next to the counter (same masthead row, after the strip) but is its own flex item with flex-shrink:0 so it never gets squished. Cache version bumped to ?v=23. --- practice/app.js | 2 +- practice/index.html | 16 ++++++++++------ practice/styles.css | 4 ++-- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/practice/app.js b/practice/app.js index 1e4030c..196b58d 100644 --- a/practice/app.js +++ b/practice/app.js @@ -26,7 +26,7 @@ // Bump on every deploy that changes app.js / data/*.json. Appended to // bank-JSON fetch URLs so browsers don't serve stale banks after a deploy. - const APP_VERSION = "22"; + const APP_VERSION = "23"; // Title patterns that are placeholder fallbacks (mock-exam questions whose // source heading is `## Question N *(Difficulty)*` with no real title text). diff --git a/practice/index.html b/practice/index.html index 0cccd34..10d8e6b 100644 --- a/practice/index.html +++ b/practice/index.html @@ -12,7 +12,7 @@ href="https://fonts.googleapis.com/css2?family=Geist:wght@300..700&family=Geist+Mono:wght@400..600&display=swap"> - + @@ -40,12 +40,16 @@ · - + + +
+ - - -
+ +
@@ -175,6 +177,6 @@

Settings

- + diff --git a/practice/styles.css b/practice/styles.css index 885b7c0..01f28d1 100644 --- a/practice/styles.css +++ b/practice/styles.css @@ -218,7 +218,25 @@ a:hover { text-decoration-color: var(--accent); } text-overflow: ellipsis; } .quiz-meta-strip[hidden] { display: none; } -.quiz-meta-strip #quiz-cert { color: var(--fg); font-weight: 600; } +.quiz-meta-strip #quiz-cert, +.quiz-meta-strip .cert-link { + color: var(--fg); + font-weight: 600; + background: none; + border: none; + padding: 0; + font: inherit; + cursor: pointer; + text-transform: inherit; + letter-spacing: inherit; + transition: color 0.15s; +} +.quiz-meta-strip .cert-link:hover { + color: var(--accent); + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 3px; +} .quiz-meta-strip #quiz-domain { color: var(--fg-soft); } .quiz-meta-strip .rh-sep { color: var(--hairline); padding: 0 0.15rem; } @@ -248,11 +266,13 @@ a:hover { text-decoration-color: var(--accent); } question counter so "Q6 this session · 22:27 remaining" reads as a single line of session state. Subtle pill with monospace value; warning state pulses accent-coloured, expired locks negative-red. */ -.meta-timer { +/* Exam timer — large pill in the action bar, far right. Designed to + be clearly visible during a timed practice. */ +.actionbar-timer { display: inline-flex; align-items: center; - gap: 0.35rem; - padding: 0.25rem 0.65rem; + gap: 0.4rem; + padding: 0.5rem 0.9rem 0.5rem 0.75rem; border: 1px solid var(--hairline); border-radius: 99px; background: var(--surface); @@ -260,34 +280,32 @@ a:hover { text-decoration-color: var(--accent); } font-variant-numeric: tabular-nums; white-space: nowrap; transition: color 0.15s, border-color 0.15s, background 0.15s; - flex-shrink: 0; /* never let it shrink/clip when the cert name is long */ + flex-shrink: 0; } -.meta-timer::before { - content: "⏱"; - font-size: 0.78rem; +.actionbar-timer .timer-icon { + font-size: 0.95rem; color: var(--muted); - text-transform: none; } -.meta-timer .timer-value { - font-size: 0.78rem; +.actionbar-timer .timer-value { + font-size: 1.05rem; font-weight: 600; color: var(--fg); letter-spacing: 0; } -.meta-timer.warning { +.actionbar-timer.warning { border-color: var(--accent); background: var(--accent-soft); animation: timer-pulse 1s ease-in-out infinite; } -.meta-timer.warning::before, -.meta-timer.warning .timer-value { color: var(--accent); } -.meta-timer.expired { +.actionbar-timer.warning .timer-icon, +.actionbar-timer.warning .timer-value { color: var(--accent); } +.actionbar-timer.expired { border-color: var(--negative); background: var(--negative-soft); animation: none; } -.meta-timer.expired::before, -.meta-timer.expired .timer-value { color: var(--negative); } +.actionbar-timer.expired .timer-icon, +.actionbar-timer.expired .timer-value { color: var(--negative); } @keyframes timer-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } From af1ae14e427691a1b8e85f2ecd6ce8e6b283bce6 Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Thu, 21 May 2026 22:46:43 +0700 Subject: [PATCH 23/33] fix(practice): pin timer on the same row as Submit/Skip, push to far right MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure: #quiz-timer is now a sibling of .actionbar-left and .actionbar-right inside .actionbar-inner. margin-left:auto pushes it to the right edge of the first flex row; .actionbar-right has flex-basis:100% to force the stats + controls onto a second row. Layout: Row 1: [Submit][Skip] 1234·↵·S … [⏱ 29:52] Row 2: Session 0/0 · Bank 85/85 STATS SETTINGS RESET SWITCH BANK Plus the in-flight clickable brand changes are kept (DBX Practice brand goes to all-certs picker with a confirm). The cert-link still goes to that cert's bank picker. Cache version bumped to ?v=26. --- practice/app.js | 2 +- practice/index.html | 31 ++++++++++++++++++------------- practice/styles.css | 31 +++++++++++++++++++++++++++---- 3 files changed, 46 insertions(+), 18 deletions(-) diff --git a/practice/app.js b/practice/app.js index 4364f2d..7763b4b 100644 --- a/practice/app.js +++ b/practice/app.js @@ -26,7 +26,7 @@ // Bump on every deploy that changes app.js / data/*.json. Appended to // bank-JSON fetch URLs so browsers don't serve stale banks after a deploy. - const APP_VERSION = "25"; + const APP_VERSION = "26"; // Title patterns that are placeholder fallbacks (mock-exam questions whose // source heading is `## Question N *(Difficulty)*` with no real title text). diff --git a/practice/index.html b/practice/index.html index 5ec6f86..c006105 100644 --- a/practice/index.html +++ b/practice/index.html @@ -12,7 +12,7 @@ href="https://fonts.googleapis.com/css2?family=Geist:wght@300..700&family=Geist+Mono:wght@400..600&display=swap"> - + @@ -20,16 +20,18 @@
-
- - + DBX Practice -
+ +
@@ -177,6 +182,6 @@

Settings

- + diff --git a/practice/styles.css b/practice/styles.css index 01f28d1..0ef32ba 100644 --- a/practice/styles.css +++ b/practice/styles.css @@ -168,12 +168,28 @@ a:hover { text-decoration-color: var(--accent); } min-height: 52px; } -.brand-cluster { +/* Brand cluster doubles as "back to all certifications" link. Hover + tints the title and rotates the mark; confirm dialog handled in JS. */ +button.brand-cluster { display: inline-flex; align-items: center; gap: 0.6rem; flex-shrink: 0; + padding: 0.25rem 0.5rem 0.25rem 0.25rem; + background: none; + border: 1px solid transparent; + border-radius: var(--radius); + cursor: pointer; + font: inherit; + color: inherit; + transition: background 0.15s, border-color 0.15s; } +button.brand-cluster:hover { + background: var(--surface-soft); + border-color: var(--hairline); +} +button.brand-cluster:hover .brand-mark { transform: rotate(-8deg) scale(1.08); } +button.brand-cluster:hover .brand-title { color: var(--accent); } .brand-mark { display: inline-flex; @@ -183,11 +199,10 @@ a:hover { text-decoration-color: var(--accent); } height: 28px; flex-shrink: 0; color: var(--accent); - text-decoration: none; transition: transform 0.2s var(--ease); } -.brand-mark:hover { transform: rotate(-8deg) scale(1.08); } .brand-mark svg { width: 22px; height: 22px; display: block; } +.brand-title { transition: color 0.15s; } .brand-title { font-family: var(--sans); @@ -885,11 +900,17 @@ fieldset#quiz-choices label.incorrect .choice-text { color: var(--negative); } flex-wrap: wrap; } +/* Timer sits on the SAME row as Submit/Skip, pushed to the far right + via margin-left:auto. The actionbar-right (stats + controls) wraps to + row 2 because of its flex-basis:100%. */ +#quiz-timer.actionbar-timer { + margin-left: auto; + order: 1; +} .actionbar-right { display: inline-flex; align-items: center; gap: 0.85rem; - margin-left: auto; flex-wrap: wrap; font-family: var(--mono); font-size: 0.72rem; @@ -897,6 +918,8 @@ fieldset#quiz-choices label.incorrect .choice-text { color: var(--negative); } letter-spacing: 0.02em; color: var(--muted); font-variant-numeric: tabular-nums; + flex-basis: 100%; + order: 2; } .actionbar-right #session-stats, .actionbar-right #bank-stats { color: var(--fg-soft); } From 5f792ca8639dd85da859bd10ad0d23967a36ca24 Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Thu, 21 May 2026 22:47:49 +0700 Subject: [PATCH 24/33] =?UTF-8?q?feat(practice):=20wire=20brand=20?= =?UTF-8?q?=E2=86=92=20all-certs=20picker,=20cert=20name=20=E2=86=92=20ban?= =?UTF-8?q?k=20picker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both masthead links lead to a different breadcrumb level, both gated behind the same confirm-leave-exam helper: - DBX Practice brand → renderCertPicker (step 1: pick which cert) - cert name e.g. "DATA ENGINEER ASSOCIATE" → renderBankPicker for that cert's items (step 2: pick Practice / Mock 1 / Mock 2) Refactored the confirm logic into hasActiveSession + resetSessionState + confirmLeaveExam(destinationFn) so the same prompt covers both links. History in localStorage is preserved either way; only the session counters + running timer get reset. Cache version bumped to ?v=27. --- practice/app.js | 64 +++++++++++++++++++++++++++++++++------------ practice/index.html | 4 +-- 2 files changed, 50 insertions(+), 18 deletions(-) diff --git a/practice/app.js b/practice/app.js index 7763b4b..af78038 100644 --- a/practice/app.js +++ b/practice/app.js @@ -26,7 +26,7 @@ // Bump on every deploy that changes app.js / data/*.json. Appended to // bank-JSON fetch URLs so browsers don't serve stale banks after a deploy. - const APP_VERSION = "26"; + const APP_VERSION = "27"; // Title patterns that are placeholder fallbacks (mock-exam questions whose // source heading is `## Question N *(Difficulty)*` with no real title text). @@ -871,15 +871,11 @@ STATE.timerEnd = minutes > 0 ? Date.now() + minutes * 60 * 1000 : null; } - function confirmBackToCertPicker() { - const hasActiveSession = STATE.sessionTotal > 0 || STATE.timerEnd != null; - if (hasActiveSession) { - const msg = STATE.timerEnd - ? "Stop this timed exam and go back to certifications? Your timer will reset." - : "Go back to certifications? Your session progress will reset (history is kept)."; - if (!confirm(msg)) return; - } - // Reset session state (history in localStorage is preserved per-bank) + function hasActiveSession() { + return STATE.sessionTotal > 0 || STATE.timerEnd != null; + } + + function resetSessionState() { STATE.sessionCorrect = 0; STATE.sessionTotal = 0; STATE.seenThisSession.clear(); @@ -887,6 +883,21 @@ STATE.timerEnd = null; STATE.timerExpired = false; updateClockAndTimer(); + } + + function confirmLeaveExam(destination) { + if (hasActiveSession()) { + const msg = STATE.timerEnd + ? "Stop this timed exam and leave? Your timer will reset." + : "Leave this session? Your progress will reset (history is kept)."; + if (!confirm(msg)) return false; + } + resetSessionState(); + destination(); + return true; + } + + function goToCertPicker() { if (STATE.certBanks) { renderCertPicker(STATE.certBanks); show("setup"); @@ -895,6 +906,25 @@ } } + function goToBankPickerForCurrentCert() { + if (!STATE.bank || !STATE.certBanks) { goToCertPicker(); return; } + const sourceCert = STATE.bank.sourceCert || STATE.bank.cert; + const items = STATE.certBanks.get(sourceCert); + if (items) { + renderBankPicker(sourceCert, items); + show("setup"); + } else { + goToCertPicker(); + } + } + + function confirmBackToCertPicker() { + confirmLeaveExam(goToCertPicker); + } + function confirmBackToBankPicker() { + confirmLeaveExam(goToBankPickerForCurrentCert); + } + function showTimerExpiredToast() { const existing = document.querySelector(".streak-toast"); if (existing) existing.remove(); @@ -1079,13 +1109,15 @@ show("setup"); }); - // Cert name in the masthead acts as a "back to certifications" link. - // Confirm before leaving so the user doesn't accidentally lose their - // timer / session counts on a stray click. + // Masthead links: + // DBX Practice brand → step 1 (all certifications) + // cert name (e.g. "DATA ENGINEER ASSOCIATE") → step 2 (bank + // picker for that specific cert) + // Both confirm() before leaving an in-progress session/timer. + const brandLink = $("#brand-link"); + if (brandLink) brandLink.addEventListener("click", confirmBackToCertPicker); const certLink = $("#quiz-cert"); - if (certLink) { - certLink.addEventListener("click", confirmBackToCertPicker); - } + if (certLink) certLink.addEventListener("click", confirmBackToBankPicker); $("#btn-theme").addEventListener("click", cycleTheme); document.addEventListener("keydown", handleKeydown); diff --git a/practice/index.html b/practice/index.html index c006105..bcd1132 100644 --- a/practice/index.html +++ b/practice/index.html @@ -12,7 +12,7 @@ href="https://fonts.googleapis.com/css2?family=Geist:wght@300..700&family=Geist+Mono:wght@400..600&display=swap"> - + @@ -182,6 +182,6 @@

Settings

- + From 9c3e67f61b607209862f2a2f64b458fb56f73c89 Mon Sep 17 00:00:00 2001 From: Suppaseth Charoenkarnka Date: Thu, 21 May 2026 22:52:50 +0700 Subject: [PATCH 25/33] feat(practice): blue selected state + settings card redesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. New --selected / --selected-soft theme tokens (blue: #2563EB light, #60A5FA dark). The "I picked this choice" state now uses blue instead of the Databricks red accent. Reason: red conflicted with the post-submit incorrect state — both looked like "wrong answer". Semantics now read cleanly: blue = picked, awaiting submit green = correct red = incorrect 2. Settings page redesigned to match the rest of the app: - Wrapped fields in a .settings-panel card (surface bg + border + shadow, matching .question-card aesthetic) - Each field is now a row with a heading + helper description on the left and the select on the right - Custom CSS chevron (rotated bordered square) replaces the native dropdown arrow so it themes consistently - Focus ring uses --selected (blue) instead of the red accent - Cancel button added next to Apply at the bottom - "Customize how you practice…" intro paragraph - Mobile (≤ 700px): rows stack vertically Cache version bumped to ?v=28. --- practice/app.js | 4 +- practice/index.html | 113 +++++++++++++++++++++++++++++-------------- practice/styles.css | 114 ++++++++++++++++++++++++++++++++++++-------- 3 files changed, 172 insertions(+), 59 deletions(-) diff --git a/practice/app.js b/practice/app.js index af78038..3220593 100644 --- a/practice/app.js +++ b/practice/app.js @@ -26,7 +26,7 @@ // Bump on every deploy that changes app.js / data/*.json. Appended to // bank-JSON fetch URLs so browsers don't serve stale banks after a deploy. - const APP_VERSION = "27"; + const APP_VERSION = "28"; // Title patterns that are placeholder fallbacks (mock-exam questions whose // source heading is `## Question N *(Difficulty)*` with no real title text). @@ -1102,6 +1102,8 @@ $("#btn-reset-top").addEventListener("click", resetHistory); $("#btn-settings").addEventListener("click", () => show("settings")); $("#btn-settings-cancel").addEventListener("click", () => show("quiz")); + const cancel2 = $("#btn-settings-cancel-2"); + if (cancel2) cancel2.addEventListener("click", () => show("quiz")); $("#btn-settings-apply").addEventListener("click", applySettings); $("#btn-exit").addEventListener("click", () => { if (STATE.certBanks) renderCertPicker(STATE.certBanks); diff --git a/practice/index.html b/practice/index.html index bcd1132..2a7508c 100644 --- a/practice/index.html +++ b/practice/index.html @@ -12,7 +12,7 @@ href="https://fonts.googleapis.com/css2?family=Geist:wght@300..700&family=Geist+Mono:wght@400..600&display=swap"> - + @@ -87,47 +87,86 @@

Stats

+ + +