From 14542f169a861a1d68ae1062878d9138b86cac89 Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 11 Oct 2026 21:54:17 +0530 Subject: [PATCH] Make the exam screen work on a phone The screen had no mobile state at all: the clock scrolled away mid-paper, the only navigation was save-and-next so a candidate could not go back, the palette sat below the question where it pushed submit off-screen, and the nav row lost three of its four links under 700px. Sticky header carries the clock and a progress bar; previous/next are real buttons; the palette is a drawer below 1024px opened by a floating button that sits above the tab bar. Primary destinations move to a bottom tab bar on phones, data tables scroll inside their own card, and every control meets the 44px touch minimum. Verified with a headless browser across five viewports rather than by reading the stylesheet: scripts/audit_mobile.py fails on horizontal overflow, tap targets under 44px, and content hidden behind a fixed bar; scripts/shots.py captures each page at both widths. Both caught bugs I would otherwise have shipped, including a palette toggle that lived inside the drawer it opened and so could never be pressed on a phone. fmt_dur drops a leading zero hour, so a 24 minute paper reads 24m 00s. --- scripts/audit_mobile.py | 123 ++++++++++++++++++++++++++++++++++++ scripts/shots.py | 70 +++++++++++++++++++++ src/analytics.rs | 11 +++- src/views.rs | 82 +++++++++++++++++------- static/exam.js | 135 ++++++++++++++++++++++++++++++--------- static/style.css | 136 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 505 insertions(+), 52 deletions(-) create mode 100755 scripts/audit_mobile.py create mode 100755 scripts/shots.py diff --git a/scripts/audit_mobile.py b/scripts/audit_mobile.py new file mode 100755 index 0000000..028a870 --- /dev/null +++ b/scripts/audit_mobile.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Measures the layout at phone, tablet and desktop widths. + +Checks the things a stylesheet review cannot: real horizontal overflow, real +tap-target sizes, and whether fixed bars cover content at the bottom. + scripts/audit_mobile.py +""" +import sys +import urllib.parse +import urllib.request +from playwright.sync_api import sync_playwright + +BASE = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8180" + +DEVICES = [ + ("iPhone SE", 375, 667, True), + ("iPhone 13", 390, 844, True), + ("iPad", 768, 1024, True), + ("small laptop", 1024, 768, False), + ("desktop", 1440, 900, False), +] + +AUDIT = """ +() => { + const de = document.documentElement; + const overflow = de.scrollWidth - de.clientWidth; + // Anything sticking out past the viewport is a horizontal scroll leak. + const wide = [...document.querySelectorAll('body *')] + .filter(el => { + const r = el.getBoundingClientRect(); + return r.width > 0 && r.right > de.clientWidth + 1; + }) + .slice(0, 6) + .map(el => el.tagName.toLowerCase() + (el.className ? '.' + String(el.className).split(' ')[0] : '')); + // Touch targets on the controls a thumb has to hit. A checkbox is measured + // through its wrapping label, which is the box a thumb actually lands on. + const TARGETS = 'button, a.btn, .pal, .opt, .tabbar a, input, select, label.chk'; + const small = [...document.querySelectorAll(TARGETS)] + .map(el => { + const box = (el.matches('input') && el.closest('label.chk')) ? el.closest('label.chk') : el; + return { el: box, r: box.getBoundingClientRect() }; + }) + .filter(x => x.r.width > 0 && x.r.height > 0) + .filter(x => x.r.height < 44 || x.r.width < 44) + .slice(0, 8) + .map(x => { + const cls = x.el.className ? '.' + String(x.el.className).split(' ')[0] : ''; + return `${x.el.tagName.toLowerCase()}${cls} ${Math.round(x.r.width)}x${Math.round(x.r.height)}`; + }); + // Does the last visible element sit under a fixed bar? + const fixed = [...document.querySelectorAll('body *')] + .filter(el => ['fixed', 'sticky'].includes(getComputedStyle(el).position)); + let covered = null; + if (fixed.length) { + const lowest = Math.max(...fixed.map(el => el.getBoundingClientRect().bottom)); + if (lowest >= de.clientHeight - 1) { + const hit = document.elementFromPoint(de.clientWidth / 2, de.clientHeight - 24); + covered = hit ? hit.tagName.toLowerCase() + (hit.className ? '.' + String(hit.className).split(' ')[0] : '') : 'none'; + } + } + return { overflow, wide, small, covered, scrollH: de.scrollHeight }; +} +""" + + +def sign_in(page, base): + page.goto(base + "/student", wait_until="networkidle") + page.fill("#name", "Audit") + page.fill("#email", "audit@example.com") + page.click("button[type=submit]") + page.wait_for_load_state("networkidle") + + +def paper_url(page, base): + page.goto(base + "/build", wait_until="networkidle") + page.evaluate("document.querySelectorAll('.chap').forEach(c => { if (!c.checked) c.click(); })") + page.click("button[type=submit]") + page.wait_for_load_state("networkidle") + return page.url + + +fails = [] +with sync_playwright() as pw: + browser = pw.chromium.launch() + for name, w, h, touch in DEVICES: + ctx = browser.new_context( + viewport={"width": w, "height": h}, + has_touch=touch, + is_mobile=touch, + user_agent=("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) " + "AppleWebKit/605.1.15 Mobile/15E148") if touch else None, + ) + page = ctx.new_page() + sign_in(page, BASE) + pages = {"build": BASE + "/build"} + exam = paper_url(page, BASE) + page.goto(exam, wait_until="networkidle") + pages["exam"] = exam + + print(f"\n=== {name} ({w}x{h}) ===") + for label, url in pages.items(): + page.goto(url, wait_until="networkidle") + r = page.evaluate(AUDIT) + ok = r["overflow"] <= 0 and not r["wide"] + print(f" {label:<7} overflow={r['overflow']}px {'OK' if ok else 'LEAK'}") + if r["wide"]: + print(f" too wide: {', '.join(r['wide'])}") + fails.append(f"{name}/{label}: horizontal overflow {r['overflow']}px") + if r["small"]: + print(f" small targets: {', '.join(r['small'])}") + fails.append(f"{name}/{label}: {len(r['small'])} targets under 44px") + if r["covered"]: + print(f" bottom of screen is covered by: {r['covered']}") + ctx.close() + browser.close() + +print() +if fails: + print("FAILURES") + for f in fails: + print(" -", f) + sys.exit(1) +print("all viewports clean: no overflow, every target 44px or larger") \ No newline at end of file diff --git a/scripts/shots.py b/scripts/shots.py new file mode 100755 index 0000000..54a2165 --- /dev/null +++ b/scripts/shots.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Screenshots each page at phone and desktop width. + scripts/shots.py +""" +import os +import sys +import urllib.request +from playwright.sync_api import sync_playwright + +BASE = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8180" +OUT = sys.argv[2] if len(sys.argv) > 2 else "/tmp/shots" +os.makedirs(OUT, exist_ok=True) + +SIZES = [("phone", 390, 844, True), ("desktop", 1440, 900, False)] + + +def sign_in(page): + page.goto(BASE + "/student", wait_until="networkidle") + page.fill("#name", "Shot") + page.fill("#email", "shot@example.com") + page.click("button[type=submit]") + page.wait_for_load_state("networkidle") + + +with sync_playwright() as pw: + browser = pw.chromium.launch() + for label, w, h, touch in SIZES: + ctx = browser.new_context(viewport={"width": w, "height": h}, + has_touch=touch, is_mobile=touch, device_scale_factor=2) + page = ctx.new_page() + sign_in(page) + + page.goto(BASE + "/", wait_until="networkidle") + page.screenshot(path=f"{OUT}/{label}-landing.png") + + page.goto(BASE + "/build", wait_until="networkidle") + page.evaluate("document.querySelectorAll('.chap').forEach(c => { if (!c.checked) c.click(); })") + page.screenshot(path=f"{OUT}/{label}-build.png", full_page=label == "desktop") + + page.click("button[type=submit]") + page.wait_for_load_state("networkidle") + exam = page.url + page.goto(exam, wait_until="networkidle") + page.screenshot(path=f"{OUT}/{label}-exam.png") + + # Answer one so the palette shows the answered state too. + # On a phone the palette is a drawer, so it has to be opened before use. + if touch: + page.click("#pal-toggle") + page.wait_for_timeout(350) + page.screenshot(path=f"{OUT}/{label}-palette-open.png") + page.click("[data-close-palette]") + page.wait_for_timeout(350) + + # Step forward with the on-screen control until a multiple-choice + # question shows, then pick an option. + for _ in range(12): + opt = page.locator(".qpane:not([hidden]) .opt").first + if opt.count() > 0: + opt.click() + break + page.click(".qpane:not([hidden]) [data-act=save]") + page.wait_for_timeout(150) + page.wait_for_timeout(400) + page.screenshot(path=f"{OUT}/{label}-exam-answered.png") + + ctx.close() + browser.close() + +print("\n".join(sorted(os.listdir(OUT)))) \ No newline at end of file diff --git a/src/analytics.rs b/src/analytics.rs index f5de56c..1b1e681 100644 --- a/src/analytics.rs +++ b/src/analytics.rs @@ -233,8 +233,17 @@ pub fn coach_notes(a: &Analysis, history: &[TestRow]) -> Vec { notes } +/// Clock text for a countdown. An hour that is always zero takes width that the +/// minutes need on a phone, so empty leading units are dropped. pub fn fmt_dur(s: i64) -> String { - format!("{}h {:02}m {:02}s", s / 3600, (s % 3600) / 60, s % 60) + let (h, m, sec) = (s / 3600, (s % 3600) / 60, s % 60); + if h > 0 { + format!("{h}h {m:02}m {sec:02}s") + } else if m > 0 { + format!("{m}m {sec:02}s") + } else { + format!("{sec}s") + } } pub fn fmt_pct(score: i32, max: i32) -> i32 { diff --git a/src/views.rs b/src/views.rs index e3a0d36..b1a316b 100644 --- a/src/views.rs +++ b/src/views.rs @@ -57,7 +57,19 @@ fn nav(ctx: &Ctx, user: Option<&str>) -> String { b = ctx.base ), }; - format!("{brand}{links}") + // Below 700px the row of links has nowhere to go, so the primary destinations + // move to a bottom bar rather than disappearing behind a hidden class. + let tabbar = match user { + Some(_) => format!( + r#""#, + b = ctx.base + ), + None => format!( + r#""#, + b = ctx.base + ), + }; + format!("{brand}{links}{tabbar}") } fn foot(ctx: &Ctx) -> String { @@ -71,6 +83,13 @@ pub fn shell(ctx: &Ctx, user: Option<&str>, title: &str, body: String) -> String format!("{}{}{}{}", head(ctx, title), nav(ctx, user), body, foot(ctx)) } +/// The exam screen carries a floating palette button, so the page needs extra +/// bottom room for it on a phone. +pub fn shell_exam(ctx: &Ctx, user: Option<&str>, title: &str, body: String) -> String { + let page = shell(ctx, user, title, body); + page.replacen("", "", 1) +} + pub fn landing(ctx: &Ctx, q: i64, shifts: i64, subtopics: i64, subjects: i64, students: i64, signed: bool) -> String { let cta = if signed { format!(r#"build a paper"#, b = ctx.base) @@ -358,20 +377,24 @@ pub fn attempt(ctx: &Ctx, user: &str, attempt_id: &str, questions: &[Question], }; format!( r#"
-
- question {n} · {subj} · {ch} +
+ question {n} of {total} · {subj} {qt} · +4 / −1{revflag}
-

{stem}

-
{sub2} · {year} {shift} · {difficulty}
+

{stem}

+
{sub2} · {ch} · {year} {shift} · {difficulty}
{opts} -
+
+ - +
"#, hidden = if i == 0 { "" } else { " hidden" }, + prevdis = if i == 0 { " disabled" } else { "" }, + revcls = if rev { " danger" } else { "" }, + total = questions.len(), n = i + 1, subj = esc(&q.subject), ch = esc(&q.chapter), @@ -387,18 +410,31 @@ pub fn attempt(ctx: &Ctx, user: &str, attempt_id: &str, questions: &[Question], }) .collect(); let body = format!( - r#"
-

{title}

{n} questions · +4 / −1
-
{dur}
remaining
+ r#"
+
+
{title}{n} questions
+
time left
{dur}
+
+
-
+
{qs}
-

palette

{pal}
-

answered · review · blank

- -
-
+ +
+ + "#, title = esc(title), n = questions.len(), @@ -409,7 +445,7 @@ pub fn attempt(ctx: &Ctx, user: &str, attempt_id: &str, questions: &[Question], qs = body_questions.join(""), pal = palette(questions, answers) ); - shell(ctx, Some(user), "Test", body) + shell_exam(ctx, Some(user), "Test", body) } pub struct RowView<'a> { @@ -502,11 +538,11 @@ pub fn result(ctx: &Ctx, user: &str, _attempt_id: &str, a: &Analysis, rows: &[Ro
time used{dur}
-

by subject

{subj}
subjectrightwrongblank%
-

by difficulty

{diff}
difficultyrightwrongblank%
+

by subject

{subj}
subjectrightwrongblank%
+

by difficulty

{diff}
difficultyrightwrongblank%

chapters that cost you marks

-
{topic}
subtopicrightwrongblank%
+
{topic}
subtopicrightwrongblank%

every question

{detail}

@@ -557,7 +593,7 @@ pub fn history(ctx: &Ctx, user: &str, tests: &[TestRow]) -> String { ) } else { format!( - r#"

History

{rows}
datepaperscore%accuracytime
"#, + r#"

History

{rows}
datepaperscore%accuracytime
"#, rows = rows ) }; @@ -570,7 +606,7 @@ pub fn dashboard(ctx: &Ctx, user: &str, tests: &[TestRow], weak: &[(Bucket, Stri String::new() } else { let best = tests.iter().map(|t| t.score * 100 / t.max_score.max(1)).max().unwrap_or(0); - let mut s = String::from(r#"

percentage across papers

"#); + let mut s = String::from(r#"

percentage across papers

datepaper%accuracy
"#); for t in tests { s.push_str(&format!( r#""#, @@ -616,7 +652,7 @@ pub fn dashboard(ctx: &Ctx, user: &str, tests: &[TestRow], weak: &[(Bucket, Stri {trend}

weakest subtopics across all papers

-
datepaper%accuracy
{}{}{}%{}%
{weak}
subtopicseenwrongblank
+
{weak}
subtopicseenwrongblank

coach notes

    {notes}

Written from your own submitted papers. No data leaves this machine.

all papers

"#, diff --git a/static/exam.js b/static/exam.js index 2031d93..f061162 100644 --- a/static/exam.js +++ b/static/exam.js @@ -1,8 +1,14 @@ -// Exam screen: pane navigation, palette sync, per-question timing and autosave. +// Exam screen: pane navigation, palette, per-question timing and autosave. (() => { + const form = document.getElementById('exam-form'); + if (!form) return; + const panes = [...document.querySelectorAll('.qpane')]; const palBtns = [...document.querySelectorAll('#pal .pal')]; const timerEl = document.getElementById('timer'); + const progressEl = document.getElementById('exam-progress'); + const fillEl = document.getElementById('exam-fill'); + const countEl = document.getElementById('cbt-count'); let cur = 0; const entered = new Map(); @@ -11,23 +17,14 @@ if (box) entered.set(p.dataset.i, Number(box.dataset.t || 0)); } - function show(i) { - cur = Math.max(0, Math.min(panes.length - 1, i)); - panes.forEach((p, k) => { p.hidden = k !== cur; }); - palBtns.forEach((b, k) => b.classList.toggle('cur', k === cur)); - const focusable = panes[cur].querySelector('input:not([type=hidden])'); - if (focusable && matchMedia('(pointer: fine)').matches) focusable.focus({ preventScroll: true }); - history.replaceState(null, '', '#q' + (cur + 1)); - } - function questionId(pane) { const radios = [...pane.querySelectorAll('input[type=radio]')]; if (radios.length) return Number(radios[0].name.replace('q-', '')); - return Number(pane.querySelector('input[type=text]').name.replace('num-', '')); + const num = pane.querySelector('input[type=text]'); + return num ? Number(num.name.replace('num-', '')) : 0; } function collect(pane) { - const i = pane.dataset.i; const radios = [...pane.querySelectorAll('input[type=radio]')]; const picked = radios.find(r => r.checked); const num = pane.querySelector('input[type=text]'); @@ -35,10 +32,31 @@ question_id: questionId(pane), selected: picked ? picked.value : (num ? num.value.trim() : ''), marked_review: pane.dataset.review === '1', - time_secs: Number(entered.get(i) || 0), + time_secs: Number(entered.get(pane.dataset.i) || 0), }; } + function show(i) { + cur = Math.max(0, Math.min(panes.length - 1, i)); + panes.forEach((p, k) => { p.hidden = k !== cur; }); + palBtns.forEach((b, k) => { + b.classList.toggle('cur', k === cur); + b.setAttribute('aria-current', k === cur ? 'true' : 'false'); + }); + const prev = panes[cur].querySelector('[data-act=prev]'); + if (prev) prev.disabled = cur === 0; + + // Only focus on a pointer device: stealing focus on a phone pops the keyboard over the options. + if (matchMedia('(pointer: fine)').matches) { + const f = panes[cur].querySelector('input:not([type=hidden])'); + if (f) f.focus({ preventScroll: true }); + } + // A pane change on a phone must start the question at the top, not halfway down. + panes[cur].scrollIntoView({ block: 'nearest' }); + history.replaceState(null, '', '#q' + (cur + 1)); + paintProgress(); + } + function syncPalette(pane) { const btn = palBtns[Number(pane.dataset.i)]; if (!btn) return; @@ -48,16 +66,47 @@ btn.classList.toggle('rev', answered && review); btn.classList.toggle('skip', !answered); btn.classList.remove('cur'); + const mark = answered ? (review ? 'R' : '✓') : '·'; + btn.setAttribute('aria-label', `question ${Number(pane.dataset.i) + 1}, ${answered ? 'answered' : 'blank'}${review ? ', marked for review' : ''}`); + btn.dataset.mark = mark; + paintProgress(); + } + + function counts() { + let answered = 0, review = 0; + for (const p of panes) { + const c = collect(p); + if (c.selected !== '') answered++; + if (c.marked_review) review++; + } + return { answered, review, left: panes.length - answered }; + } + + function paintProgress() { + const { answered, review, left } = counts(); + const done = answered + review; + if (progressEl) { + progressEl.textContent = `${done} of ${panes.length} marked · question ${cur + 1}`; + } + if (fillEl) fillEl.style.width = `${panes.length ? (done / panes.length) * 100 : 0}%`; + if (countEl) { + countEl.textContent = `${answered} answered · ${left} blank${review ? ` · ${review} review` : ''}`; + } + const fabCount = document.getElementById('pal-fab-count'); + if (fabCount) fabCount.textContent = left ? `· ${left} left` : '· all answered'; + const bar = document.querySelector('.exam-bar'); + if (bar) bar.classList.toggle('complete', left === 0); } let saving = false; + let pending = false; async function save(all) { - if (saving) return; + // A second change while a save is in flight is coalesced into one follow-up write. + if (saving) { pending = all || pending; return; } saving = true; const panesToSave = all ? panes : [panes[cur]]; - const attemptId = document.getElementById('exam-form').dataset.attempt; try { - await fetch(`/attempt/${attemptId}/answer`, { + await fetch(`${form.dataset.base}/attempt/${form.dataset.attempt}/answer`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ answers: panesToSave.map(collect) }), @@ -66,6 +115,7 @@ // Server unreachable: the selection stays on screen and travels with the form submit. } saving = false; + if (pending) { const again = pending; pending = false; save(again); } } for (const pane of panes) { @@ -77,11 +127,12 @@ syncPalette(pane); save(false); }); - pane.addEventListener('click', (e) => { + pane.addEventListener('click', e => { const btn = e.target.closest('button[data-act]'); if (!btn) return; const act = btn.dataset.act; - if (act === 'save') { save(false).then(() => show(cur + 1)); } + if (act === 'prev') { show(cur - 1); } + if (act === 'save') { save(false).then(() => { if (cur < panes.length - 1) show(cur + 1); }); } if (act === 'review') { pane.dataset.review = pane.dataset.review === '1' ? '0' : '1'; btn.classList.toggle('danger', pane.dataset.review === '1'); @@ -101,6 +152,24 @@ palBtns.forEach((b, i) => b.addEventListener('click', () => show(i))); + // On a phone the palette is a drawer, so a tap there should also close it. + const toggle = document.getElementById('pal-toggle'); + const side = document.querySelector('.cbt-side'); + if (toggle && side) { + const setOpen = open => { + side.classList.toggle('open', open); + toggle.setAttribute('aria-expanded', String(open)); + // Trap nothing, but return focus to the opener when the drawer closes. + if (!open) toggle.focus({ preventScroll: true }); + }; + toggle.addEventListener('click', () => setOpen(!side.classList.contains('open'))); + side.addEventListener('click', e => { + if (e.target.closest('[data-close-palette]')) { setOpen(false); return; } + if (e.target.closest('#pal .pal') && matchMedia('(max-width: 1024px)').matches) setOpen(false); + }); + document.addEventListener('keydown', e => { if (e.key === 'Escape') setOpen(false); }); + } + document.addEventListener('keydown', e => { if (/^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return; if (e.key === 'ArrowRight') show(cur + 1); @@ -109,25 +178,35 @@ let left = Number(timerEl.dataset.secs || 0); if (left > 0) { - setInterval(() => { + const tick = setInterval(() => { left -= 1; if (left <= 0) { - clearInterval(this); - document.getElementById('exam-form').requestSubmit(); + clearInterval(tick); + form.requestSubmit(); return; } - const h = String(Math.floor(left / 3600)).padStart(2, '0'); - const m = String(Math.floor(left / 60) % 60).padStart(2, '0'); - const s = String(left % 60).padStart(2, '0'); - timerEl.textContent = `${h}:${m}:${s}`; + const h = Math.floor(left / 3600); + const m = Math.floor(left / 60) % 60; + const s = left % 60; + const pad = n => String(n).padStart(2, '0'); + timerEl.textContent = h > 0 ? `${h}:${pad(m)}:${pad(s)}` : m > 0 ? `${m}:${pad(s)}` : `${s}s`; + // Warn in the last ten minutes, and again in the last minute. + timerEl.classList.toggle('urgent', left <= 600 && left > 60); + timerEl.classList.toggle('critical', left <= 60); }, 1000); } - document.getElementById('exam-form').addEventListener('submit', e => { - if (!confirm('Submit this paper? You cannot change answers afterwards.')) e.preventDefault(); + form.addEventListener('submit', e => { + const { left: blank, review } = counts(); + const msg = blank + ? `${blank} question${blank > 1 ? 's are' : ' is'} still blank. Submit anyway?` + : review + ? `${review} question${review > 1 ? 's' : ''} marked for review. Submit anyway?` + : 'Submit this paper? You cannot change answers afterwards.'; + if (!confirm(msg)) e.preventDefault(); }); const hash = Number((location.hash.match(/q(\d+)/) || [])[1]); + panes.forEach(syncPalette); show(hash ? hash - 1 : 0); - if (cur === 0 && !hash) panes.forEach(syncPalette); })(); \ No newline at end of file diff --git a/static/style.css b/static/style.css index eddbfbe..17b6287 100644 --- a/static/style.css +++ b/static/style.css @@ -62,3 +62,139 @@ footer{margin-top:48px;border-top:1px solid var(--border);padding-top:16px;color @media(max-width:700px){.grid2,.grid3{grid-template-columns:1fr}.kv{grid-template-columns:1fr 1fr}h1{font-size:1.6rem}.nav a.hidem{display:none}.palette{grid-template-columns:repeat(5,1fr)}.cbt{gap:10px}.topbar{padding:10px 0}.brand span{display:none}} @media(max-width:420px){.kv{grid-template-columns:1fr}.palette{grid-template-columns:repeat(4,1fr)}.brand{font-size:.9rem}.btn{padding:10px 14px;min-height:44px}} @media(hover:none){.pal{min-height:48px}} + +/* ===== exam screen ===== + The clock and the palette are the two things a candidate must never lose, + so both are pinned rather than left to scroll. */ +.exam-bar{position:sticky;top:0;z-index:6;background:var(--bg);border-bottom:1px solid var(--border);padding:8px 0 0;margin:0 0 12px} +.exam-bar-inner{display:flex;align-items:center;justify-content:space-between;gap:10px;min-height:44px} +.exam-title{display:flex;flex-direction:column;min-width:0} +.exam-title b{color:#fff;font-size:.95rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.exam-clock{text-align:right;flex:none} +.exam-progress{height:3px;background:#1c1c1c;margin-top:8px} +.exam-progress i{display:block;height:100%;background:var(--cyan);width:0;transition:width .2s ease} +.exam-bar.complete .exam-progress i{background:var(--ok)} +.exam-bar .timer{font-size:1.15rem;line-height:1.1} +.timer.urgent{color:var(--amber)} +.timer.critical{color:var(--bad)} + +.cbt-side h2{margin-top:0} +.legend{list-style:none;display:flex;flex-wrap:wrap;gap:10px;margin:10px 0;font-size:.7rem;color:var(--dim)} +.legend li{display:flex;align-items:center;gap:5px} +.legend .sw{width:9px;height:9px;display:inline-block;border:1px solid var(--edge)} +.legend .sw.ans{background:#12301f;border-color:var(--ok)} +.legend .sw.rev{background:#2a1f05;border-color:var(--amber)} +.legend .sw.skip{background:var(--panel)} +.cbt-count{margin:0 0 10px;font-variant-numeric:tabular-nums} +.btn.block{display:block;width:100%;margin-top:8px} +.cbt-side [data-close-palette]{display:none} +button.pal-fab{display:none} +.pane-head{display:flex;justify-content:space-between;align-items:baseline;gap:10px;flex-wrap:wrap;font-size:.78rem} +.stem{margin:10px 0 6px;color:#fff;font-size:1rem;line-height:1.5} +.meta-line{margin-bottom:10px} +.opt-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px} +.opt-actions .btn{flex:1 1 auto;min-width:120px} + +/* The palette is a drawer under the question: it was a wall of tiles that pushed + the submit button off a phone screen. */ +@media(max-width:1024px){ + /* A translated-off drawer is still in the tab order, so it is also hidden from + assistive tech and hit testing while closed. */ + .cbt-side{position:fixed;left:0;right:0;bottom:0;background:var(--panel);border-top:1px solid var(--edge);padding:14px 16px calc(68px + env(safe-area-inset-bottom));max-height:78vh;overflow-y:auto;transform:translateY(100%);visibility:hidden;transition:transform .2s ease,visibility .2s;z-index:31} + .cbt-side.open{transform:translateY(0);visibility:visible} + .cbt-side h2{margin-top:0} + /* The button that opens the drawer cannot live inside the drawer, so it floats + above the tab bar where a thumb reaches it while reading a question. */ + /* It sits above the tab bar, so the page reserves that strip; without it the + button lands on top of the last line of text. */ + button.pal-fab{ + display:flex;position:fixed;right:14px;z-index:25; + bottom:calc(60px + env(safe-area-inset-bottom)); + margin:0;min-height:48px;padding:12px 16px; + background:var(--panel);border:1px solid var(--cyan);color:var(--cyan); + } + button.pal-fab[aria-expanded=true]{ + /* While the drawer is open the button is redundant, so it leaves the + submit row entirely rather than covering it. */ + display:none; + } + /* The drawer keeps its own close control at the top. */ + .cbt-side [data-close-palette]{display:block} + .palette{grid-template-columns:repeat(6,1fr)} + /* Room for the drawer so the last option row is never hidden behind it. */ + .cbt{padding-bottom:76px} +} + +/* Phone: a smaller type step, tighter gaps, and touch-sized controls throughout. */ +@media(max-width:700px){ + body{font-size:15px;padding:0 14px 72px} + h1{font-size:1.55rem} + h2{font-size:.7rem;margin:22px 0 10px} + .exam-bar{padding-top:6px} + .exam-title b{font-size:.85rem} + .exam-bar .timer{font-size:1.35rem} + .pane-head{font-size:.75rem} + .stem{font-size:1.02rem;line-height:1.55} + .opt-actions .btn{min-width:0;flex:1 1 44%;padding:12px 10px;min-height:48px} + .palette{grid-template-columns:repeat(5,1fr);gap:8px} + .pal{min-height:48px;font-size:.8rem} + .kv .card b{font-size:1.3rem} + /* Every control on the exam screen gets a thumb-sized target. */ + .btn{padding:11px 14px;min-height:44px} + .opt{padding:12px 12px;min-height:48px} + table{font-size:.78rem} + /* A data table cannot shrink to a phone, so it scrolls inside its own box + instead of pushing the whole page sideways. */ + .table-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;margin:0 -14px;padding:0 14px} +} + +@media(max-width:420px){ + .palette{grid-template-columns:repeat(4,1fr)} + .opt-actions .btn{flex:1 1 100%} +} + +/* Tablet: the palette drops under the question, so it needs the same drawer + treatment as a phone but with room for a wider grid. */ +@media(max-width:1024px) and (min-width:701px){ + .cbt{padding-bottom:0} + .cbt-side{max-height:70vh} +} + +/* ===== phone navigation ===== + The desktop link row has nowhere to go under 700px. Rather than hiding it, + the primary destinations move to a fixed bottom bar within thumb reach. */ +.tabbar{display:none} +@media(max-width:700px){ + .tabbar{ + display:grid;grid-auto-flow:column;grid-auto-columns:1fr; + position:fixed;left:0;right:0;bottom:0;z-index:30; + background:var(--panel);border-top:1px solid var(--edge); + padding-bottom:env(safe-area-inset-bottom); + } + .tabbar a{ + display:flex;align-items:center;justify-content:center;text-align:center; + min-height:52px;padding:6px 4px;font-size:.72rem;color:var(--muted); + border-right:1px solid var(--border);text-decoration:none;line-height:1.3; + } + .tabbar a:last-child{border-right:0} + .tabbar a:active{background:#1c1c1c;color:#fff} + .tabbar a:hover{text-decoration:none;color:#fff} + /* The bar is fixed, so the page reserves its height or the last row sits under it. */ + body{padding-bottom:calc(72px + env(safe-area-inset-bottom))} + /* Clear the floating palette button in the exam view only. */ + body.exam{padding-bottom:calc(122px + env(safe-area-inset-bottom))} + footer{margin-top:32px} +} +@media(min-width:701px){body{padding-bottom:64px}} + +/* ===== tap targets ===== + Measured at 35px on a pointer device and 13px on the chapter checkboxes, + both below the 44px minimum. Padding fixes the hit area without making the + control look heavy. */ +.btn,a.btn{min-height:44px;padding:12px 18px;display:inline-flex;align-items:center;justify-content:center} +button.btn{line-height:1.2} +input[type=text],input[type=email],input[type=number],select{min-height:44px;padding:12px} +/* The native checkbox is small by design, so the label around it carries the target. */ +.chk{min-height:44px} +.chk input[type=checkbox]{width:20px;height:20px;margin-top:2px;flex:none} +.opt input[type=radio]{width:20px;height:20px;flex:none}