Skip to content

feat(practice): mock exams + auto-rebuild on cert markdown edits (931 q / 18 banks)#29

Merged
kengio merged 33 commits into
mainfrom
feat/mock-exams-in-practice
May 21, 2026
Merged

feat(practice): mock exams + auto-rebuild on cert markdown edits (931 q / 18 banks)#29
kengio merged 33 commits into
mainfrom
feat/mock-exams-in-practice

Conversation

@kengio
Copy link
Copy Markdown
Owner

@kengio kengio commented May 21, 2026

Summary

Two big wins requested in this session:

  1. Mock exams in the practice quiz — 12 new banks (6 certs × 2 mocks), 567 questions
  2. Auto-rebuild + auto-deploy when cert markdown changes — no more "edit a question → forget to run build.py → live site stale" footgun

Combined totals went from 364 q / 6 banks to 931 q / 18 banks.

Three-round self-review (per CONTRIBUTING.md)

Round 1 — Technical correctness

  • ✅ All 18 JSON banks parse cleanly (verified via python3 practice/build.py --check)
  • ✅ JSON shape valid across all 18 banks (correctAnswer ∈ A-D, all 4 choices present, difficulty ∈ easy/medium/hard)
  • node --check practice/app.js passes
  • ✅ YAML syntax valid on deploy-practice.yml
  • ✅ Domain attribution on mock banks works (Q1-11 → "Databricks Lakehouse Platform", Q12-24 → "ELT with Spark SQL and Python", etc.)

Round 2 — Factual / blueprint accuracy

  • ✅ Mock counts match source markdown (45/45 for most certs, 63/60 for DE Pro, 43/44 for ML Assoc and 45/42 for GenAI — 4 questions skipped total due to multi-line-code-fence choices, same edge case as the practice banks)
  • ✅ Cert title naming consistent: <Cert Name> — Mock Exam N

Round 3 — Style & conventions

  • ✅ markdownlint passes on touched md
  • ✅ KNOWN_BANKS uses spread pattern from a single CERTS array (no 18-line repetition)
  • ✅ Removed redundant setup-python@v5 step (ubuntu-latest pre-installs python3)
  • ✅ CHANGELOG entry follows existing [YYYY.MM.DD-N] format

What changed

practice/build.py

  • New build_mock(cert, exam_n) — pre-scans the mock-exam questions.md for ## <Domain> (Questions X-Y) headings to build a qnum→domain map, then reuses the existing parse_questions() and overlays the per-question domain
  • New --kind {practice,mock,all} flag (default all)

practice/app.js

  • KNOWN_BANKS declared via spread from a CERTS array — three groups (practice, mock-1, mock-2) generated programmatically
  • renderSetup() groups bank cards under labelled section headings
  • APP_VERSION bumped to 4 for cache-busting

practice/styles.css

  • Bank-list switches to two-column grid on screens ≥ 700 px (single column on mobile)
  • .bank-group-heading uppercase + letter-spacing label style

.github/workflows/deploy-practice.yml

  • Paths filter extended to certifications/**/practice-questions/**, certifications/**/mock-exam/**, certifications/**/mock-exam-2/**
  • New step: python3 practice/build.py runs before artifact upload, so deploy always reflects current cert markdown

Docs

  • practice/README.md — Available banks table split into Practice vs Mock sections; Deploy section explains the auto-rebuild flow
  • CHANGELOG.md[2026.05.21-24] entry

After-merge expectation

The deploy workflow auto-runs (workflow file change triggers it). Within ~1 min the live site at https://kengio.github.io/databricks-certification-study-guide/ shows:

  • "Pick a question bank" with two section headings
  • 6 practice cards + 12 mock cards
  • Each card lists question count + domain count + blueprint date
  • Two-column layout on desktop

Future edits to any practice or mock-exam markdown will auto-redeploy on push to main.

Test plan

  • python3 practice/build.py --check → 18 banks / 931 q, no errors
  • markdownlint + lychee pass on touched files
  • App.js syntax valid (node --check)
  • After merge: live site shows all 18 banks grouped into Practice + Mock sections
  • After merge: edit a practice-question markdown → push → workflow auto-builds + deploys with the change visible

Follow-up (separate PR, not this one)

Per user comment "Anki ทำเป็น interactive ด้วยได้มั้ยบน github page" — yes, possible via a /flashcards/ route with SM-2 spaced repetition. Doable in ~300 lines vanilla JS. Will scope as a separate PR to keep this one focused.

kengio added 30 commits May 21, 2026 21:09
… q / 18 banks)

Adds 12 full-length mock-exam banks alongside the 6 practice banks.
Source files are the existing certifications/<cert>/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 `## <Domain> (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
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<certKey, items[]> 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
…ts + answer-reveal effects

Big design pass addressing user feedback during local testing:

Typography fix — "ฟ้อนต์อ่านยากอ่ะ + monospace ไม่เข้ากัน":
- Drop Instrument Serif / Bricolage Grotesque (display serifs that fought
  with inline code spans).
- Switch to **Geist + Geist Mono** — Vercel's sans+mono designed together
  with matching x-height and proportions, so inline `code` spans now blend
  smoothly into question stems instead of looking glued on.

Topic-bug fix — "topic คือ Question 2":
- All 12 mock-exam banks have title = "Question N" placeholders (source
  markdown headings `## Question N *(Difficulty)*` carry no real title).
- Audit confirmed: 6 practice banks have 0 fallback titles; 12 mock banks
  have 100 % fallback titles.
- Fix: suppress the Topic line in the feedback panel when title matches
  /^Question \d+$/. The running head already shows the domain, which is
  the meaningful context for mock questions.

Modern redesign — "อยากให้ดูทันสมัย น่าสนใจกว่านี้":
- Per-cert accent gradients on cert cards (DE → coral, DE Pro → indigo,
  DA → emerald, ML → purple-pink, ML Pro → amber-red, GenAI → cyan-blue)
  — top edge gradient bar + blurred halo on hover. Each cert has a
  recognisable visual identity.
- Big tabular-figures question count (2.5rem) as the dominant element on
  cert cards — confident, scannable.
- Cards: subtle lift on hover, glow halo behind, top gradient strip.
- Pill-shaped primary buttons with hover-shadow + accent-coloured glow.
- Larger, more confident H2 (clamp 1.75-2.5rem, -0.03em tracking).

Answer-reveal effects — "เพิ่ม effect ตอนตอบถูก/ผิด":
- Correct answer: green pulse + 0-6px glow ring + brief card-bg flash to
  positive-soft + floating "+1" rising from the choice.
- Incorrect answer: 6-step horizontal shake on the user's wrong choice +
  brief card-bg flash to negative-soft.
- Feedback panel slides down from above when revealed (0.35s).
- Streak toast pill appears at 3/5/7/10/+5 consecutive corrects ("3 in a
  row · keep going") — top of screen, 1.8s, accent-coloured number badge.
  Resets on first wrong answer.
- All animations gated behind prefers-reduced-motion: no-preference.

Other:
- Cache version bumped to ?v=8 across CSS, JS, and JSON fetches.
- Drop paper-grain texture (didn't suit the modern aesthetic).
- Theme toggle: remove old emoji icon span (was overlapping with the new
  dot indicator), use pure CSS ::before dot.
… button

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.
… keys

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.
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.
…+ show Space in kbd hint

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.
…radio

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 <input>), 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.
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.
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 <body> 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.
…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.
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.
… 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.
…moval

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.
… animation

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.
… 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.
…tion counter

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.
…dden

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.
…urther

Two user-driven changes:

1. Drop #quiz-difficulty and #quiz-counter from the masthead quiz-meta-
   strip and render them in a new .question-header above the question
   card instead.

   Masthead now shows only: cert / domain · [⏱ timer]
   Above the card now shows: Q1 THIS SESSION · MEDIUM

   Question identity (which question + how hard) reads as context for
   the card, not as global chrome competing with cert/domain.

2. Slide-in transition slowed 0.55s → 0.85s with a longer travel
   (28px → 40px) and a sharper start (opacity ramp from 0 → 0.25
   → 1 instead of 0 → 0.4 → 1). The new question-header animates
   alongside the card at 0.7s so the whole row arrives as one unit.

renderQuiz() now class-toggles both .question-header and .question-card
to re-trigger the animation per question.

Cache version bumped to ?v=24.
1. Cert name in masthead is now a back-to-certifications link.
   - Rendered as a <button class="cert-link"> instead of a <span>
   - Hover: accent color + underline
   - Click triggers confirmBackToCertPicker() — if a session has any
     answered questions or an active timer, a confirm() prompt asks
     before discarding the in-progress exam (history is always kept;
     only session counts + the running timer get reset)
   - If confirmed, jumps straight back to the cert picker (step 1)
     without forcing a page reload

2. Timer moved from masthead back to the right end of the action bar,
   styled as a noticeably bigger pill:
   - Value font 1.05rem (was 0.78rem in masthead) so it's readable at
     a glance during a timed run
   - Larger ⏱ icon + more padding
   - Same warning-pulse + expired-locked states
   - Divider before it visually separates it from Switch bank link

Cache version bumped to ?v=25.
…right

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.
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.
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.
…uck radio focus

- Custom theme-aware confirm modal with focus trap, ESC/Enter/backdrop
  handling; replaces native window.confirm() in confirmLeaveExam and
  resetHistory. Quiz keyboard shortcuts are gated while modal is open.
- Fix stuck radio focus outline: kill :focus + :focus-visible with
  !important and preserve the checked indicator's box-shadow. Browser
  default focus ring (system accent color) could leak through the
  previous override for programmatically-focused radios.
- Mobile responsive overhaul: new 540px + 380px + landscape breakpoints.
  Choice text bumped to 16px on phones, denser card padding, full-width
  action bar buttons, secondary stats hide on tiny screens.
- Touch target compliance (≥44px) for actionbar link buttons, brand
  cluster, cert-link, theme pill (≥36px).
- @media (hover: none) safeguards to prevent sticky :hover effects
  lingering after taps on touch devices.
- Replace stopwatch emoji (⏱) with inline SVG for the timer icon.
- aria-live="polite" + role="status" on quiz-feedback for screen readers;
  aria-label on the choices fieldset; role="alertdialog" + aria-describedby
  on the confirm modal.
…edge

The brand-cluster, cert-link, and #btn-theme buttons live inside a
sticky masthead with backdrop-filter, which creates a stacking
context + filter clipping region. An outline drawn outside the
button's own bounds (which is how :focus-visible draws) gets cut off
by that region — visually it looks like the bottom border is missing.

Fix: replace outline with an in-bounds visual (border-color tint on
the existing 1px transparent/hairline border, plus a soft background
shift). The indicator now stays inside the button's painted area, so
nothing can be clipped.
Editorial touches that lift the design from clean to refined:
- Q.N watermark — faded oversized numeral in the question-card corner.
  Padding-right reserved on #quiz-question so long lines never collide
  with it; clamp() + lowered opacity ensure it reads as texture.
- Soft radial glow at the top of the body bg via background-attachment:
  fixed — replaces flat single-tone bg with subtle depth.
- Editorial eyebrow + lead paragraph on the cert picker — establishes
  curated framing before the cards.
- Bigger, sans verdict heading ('Correct' / 'Incorrect …') replacing
  the small mono-uppercase that felt under-celebrated.
- Three-line colophon footer: tagline · meta · set notes ('Set in
  Geist & Geist Mono · No tracking · Progress stays in your browser').
- text-wrap: balance on h2 / card titles / modal title / lead.
- Per-cert confetti palette: each cert celebrates in its own gradient
  stops (DE coral, DE Pro indigo, DA emerald, ML purple-pink, ML Pro
  amber-red, GenAI cyan-blue) — cohesive instead of generic candy mix.
- Cert cards now expose data-cert which finally activates the per-cert
  gradient strips defined in styles.css (the selector was there, the
  attribute was missing). Also surfaces the question-count as a big
  numeral with a multi-line label.

Actionbar swap requested by user — primary actions on the right for
thumb-reach (also matches mobile convention):
  Row 1: [Timer ←]   …   [kbd-hint] [Next] [Skip] [Submit →]
  Row 2: [session · bank stats · Stats Settings Reset Switch]

HTML source order is Next → Skip → Submit so the rightmost visible
button is always the primary action (Submit before submit, Next after).
On mobile the cluster wraps full-width but justify-content:flex-end
keeps it pinned to the right edge.

v=31.
The Q.N watermark on the question card used 0.035 opacity, which on a
near-black ink + near-white surface reads as essentially zero contrast.
The previous value was tuned visually on dark mode (where the inverse
pairing has more headroom) and the same number was wrong for light.

  Light: 0.035 → 0.08
  Dark:  0.05  → 0.06   (was a touch heavy on the dark surface)

Mobile keeps a slightly lower value (0.07 / 0.055) since the smaller
glyph reads denser at the same opacity.
… multi-digit Q.N

Layout change:
- actionbar-inner is now a 3-column grid: timer (left) | kbd-hint (centre)
  | buttons (right). With equal-fr outer columns, the kbd-hint sits on
  true visual centre of the bar regardless of how wide the timer or
  buttons cluster grow.
- HTML: kbd-hint moved out of .actionbar-left to become a direct
  grid child so it can be addressed by grid-column.
- Mobile (≤720px): kbd-hint is already display:none; mobile collapses
  the grid to 2 columns (auto / 1fr) so the buttons get the full
  remaining width whether or not the timer is showing.

Watermark fix:
- Q.N watermark was overlapping the wrapped question text once N
  reached 2+ digits. Two reinforcing fixes:
    * Cap the watermark font-size lower (clamp max 2.1rem, was 2.75rem)
      with max-width:6rem + white-space:nowrap so width is bounded.
    * Bump #quiz-question's reserved right padding to 6rem desktop /
      4rem mobile so wrapped text always clears the watermark zone.
kengio added 3 commits May 21, 2026 23:50
…e timer

A completion experience for finishing a bank, persisted history across
sessions, weak-area highlighting, exportable summaries, and a pause/resume
flow so users can step away mid-exam without losing the run.

Summary screen (when every question in the filtered bank has been
visited and at least one answered):
- Hero block: big tone-coloured percentage + correct/total + verdict
  badge (PASS/BELOW for mock exams using a 70% threshold; descriptive
  labels for practice mode).
- Per-module breakdown: name + ratio + progress bar tinted by the same
  tone bands as the hero.
- Weak-area callout: domains scoring below 70% are surfaced as
  'Focus next on these modules' so users know where to drill.
- Time stats (only when the exam timer was running): total time,
  average per question. Paused time is subtracted so the figures
  reflect actual effort, not wall-clock.
- Best streak metric (always shown when > 0).
- Confetti at >= 80% (live summaries only; respects reduced-motion).

History:
- Each completed session is appended to localStorage under
  'dbx-practice-sessions-<cert>' (50-record ring buffer per bank).
- Summary screen shows the last six past attempts as a clickable
  compact list for at-a-glance comparison.
- Stats page now lists every past attempt for the current bank with
  date/time, score, fraction, duration and verdict — clicking any
  row reopens that attempt's summary in read-only mode.
- 'Clear history' button on stats with confirm modal.

Export:
- HTML export: self-contained printable report with inline styles,
  the same hero + breakdown + focus-areas + stats layout, downloads
  as 'dbx-<cert>-<timestamp>.html'.
- CSV export: two-section sheet — metadata pairs at top, per-module
  table at bottom; opens cleanly in Excel/Numbers/Sheets.

Pause / resume:
- Click the timer pill (or press P) to pause. Click again or press
  Esc / Enter / Space / outside the resume card to come back.
- A backdrop-blur overlay covers the page so the questions aren't
  readable while the user is away; main content gets pointer-events
  none + filter:blur so accidental input is blocked.
- Paused time accumulates in STATE.totalPausedMs and is subtracted
  from the session's elapsed-time figure on the summary screen.
- Pause state is reset by resetSessionState + when entering a new
  bank.

Plumbing:
- show() now knows about the 'summary' section.
- renderQuiz redirects to renderSummary on completion instead of
  silently looping back through the bank.
- handleKeydown skips quiz shortcuts while paused or while a confirm
  modal is open; P is a global toggle when a timer is configured.
- APP_VERSION bumped to 34, styles.css to v=34.
…ired timer

Three small leave-prompt fixes:
- After the summary screen has rendered (summarySaved=true) the user
  shouldn't be asked 'Leave this session?' when they click the brand
  link or cert link — the session is already over.
- An expired timer ends the exam too, so it shouldn't trigger the
  leave-confirm either.
- When the timer is paused, treat that as an active session even if
  sessionTotal is still 0 — leaving would lose the pause state.
Color tokens darkened so every text usage meets WCAG AAA (≥7:1 normal,
≥4.5:1 large). Each value annotated with measured ratio in the CSS.

Light theme bumps:
- --muted:   #71717A → #52525B  (4.45 → 7.1)
- --positive:#15803D → #14532D  (4.6  → 9.0)
- --negative:#B91C1C → #991B1B  (6.4  → 8.2)
- --selected:#2563EB → #1D4ED8  (5.7  → 8.6)

Dark theme bumps:
- --muted:    #8B8B95 → #A1A1AA  (5.8  → 7.4)
- --positive: #4ADE80 → #6EE7B7  (8.5  → 11.2)
- --negative: #F87171 → #FCA5A5  (7.5  → 9.7)
- --selected: #60A5FA → #93C5FD  (7.0  → 10.4)

New AAA-safe text-only variants for the vivid brand accent:
- --accent-text in light  = #9A3412 (7.1:1)
- --accent-text in dark   = #FFA88B (7.2:1)
- --warn        in light  = #92400E (7.5:1)  — for small warning text
- --warn        in dark   = #FBBF24 (11.5:1)

The vivid --accent (#FF4F2C light / #FF7A5C dark) stays vivid for
graphic/large-display uses (focus rings, gradient strips, big numerals)
where 3:1 / AA-Large is sufficient. Small text that used --accent now
routes through --accent-text:
- .eyebrow
- .difficulty.medium  (was 2.8:1 against accent-soft bg — failed even AA)
- .actionbar-timer.warning .timer-value
- footer-meta a:hover
- summary-export button.link:hover
- .brand-cluster:hover .brand-title
- .cert-link:hover / :focus-visible

Hardcoded #D97706 warn replaced with var(--warn) across:
- .summary-hero[data-tone='warn']::before
- .summary-num[data-tone='warn']
- .summary-verdict[data-tone='warn']
- .summary-meter-fill[data-tone='warn']
- .attempt-pct[data-tone='warn']
- HTML export inline stylesheet

Primary button hover redesigned: was background-swap to vivid accent
(white-on-coral fails AA in dark mode at 2.7:1). Now keeps the AAA-
strong idle colours and adds elevation + an accent-coloured glow as
the only visual cue.

Destructive modal button hardcoded #991B1B → #7F1D1D hover; semantic
--negative token is too light in dark mode for white-text-on-button.

Rich aria-labels on past-attempt rows so screen readers announce the
full context in one phrase: 'View attempt from 21 May 2026 at 14:35,
scored 85 percent, 38 of 45 correct, took 24:15, passed' instead of
reading each cell separately.

Settings selects now properly associated with their h4 + helper text
via aria-labelledby + aria-describedby.

Mobile timer-pause discoverability: surface the pause glyph at
opacity 0.45 on touch devices (no hover state) so users can see the
timer is interactive without tapping blind.

APP_VERSION + styles.css bumped to v=35.
@kengio kengio merged commit c68da12 into main May 21, 2026
2 checks passed
@kengio kengio deleted the feat/mock-exams-in-practice branch May 21, 2026 17:06
kengio added a commit that referenced this pull request May 21, 2026
…that shipped in PR #29 / #30 (#31)

Per the README & CLAUDE.md Sync Rule in CLAUDE.md, the practice-quiz
overhaul that landed in PR #29 (mock exams + summary + history + export +
pause + AAA) shipped without a corresponding doc update for the new
user-facing surfaces. This PR closes that gap.

CHANGELOG.md
- New entry '2026.05.21-25 — Practice quiz: completion summary, attempt
  history, export, pause/resume, WCAG AAA' covering everything that
  rode along with the mock-exam launch but is its own feature surface.
  Includes the AAA contrast-ratio tables (before/after, both themes),
  the actionbar grid layout, the export formats, and the pause/resume
  effort-time accounting.
- Existing '2026.05.21-24' entry stays as the mock-exam launch.

practice/README.md
- New 'Features' section listing every user-visible capability (two-step
  picker, adaptive selector, timer + pause/resume, completion summary,
  attempt history, HTML/CSV export, keyboard map, theme, AAA, mobile).
- 'Privacy' section expanded with the two localStorage key families
  (per-question + completed sessions) and the new summary-export route.
- 'Caveats' counts updated (was '198 + 573'; now '364 + 567 = 931').

README.md
- Top-level live-link blurb expanded from 'Light/dark/auto theme +
  accessible reset' to enumerate the new features so casual visitors
  know what's there without clicking through.

CLAUDE.md
- No change. The sync table doesn't require updates for adding banks
  to existing certs, and the one-line tree-diagram comment for
  'practice/' is still accurate at that level of abstraction.

i18n/th/STATUS.md
- No change. Top-level README.md is already tracked as ⏳ (not yet
  translated, so no Thai counterpart to mark stale). CHANGELOG.md and
  CLAUDE.md are ❌ policy (never translated). practice/README.md isn't
  in the Thai scope.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant