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.
This commit is contained in:
dev 2026-10-11 21:54:17 +05:30
parent c6269ad3dd
commit 14542f169a
6 changed files with 505 additions and 52 deletions

123
scripts/audit_mobile.py Executable file
View file

@ -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 <base-url>
"""
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")

70
scripts/shots.py Executable file
View file

@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""Screenshots each page at phone and desktop width.
scripts/shots.py <base-url> <out-dir>
"""
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))))

View file

@ -233,8 +233,17 @@ pub fn coach_notes(a: &Analysis, history: &[TestRow]) -> Vec<String> {
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 {

View file

@ -57,7 +57,19 @@ fn nav(ctx: &Ctx, user: Option<&str>) -> String {
b = ctx.base
),
};
format!("{brand}{links}</nav></header>")
// 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#"<nav class="tabbar" aria-label="primary"><a href="{b}/build">build</a><a href="{b}/history">history</a><a href="{b}/dashboard">dashboard</a><a href="{b}/logout">sign out</a></nav>"#,
b = ctx.base
),
None => format!(
r#"<nav class="tabbar" aria-label="primary"><a href="{b}/">home</a><a href="{b}/build">start a paper</a><a href="{b}/terms">terms</a></nav>"#,
b = ctx.base
),
};
format!("{brand}{links}</nav></header>{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("<body>", "<body class=\"exam\">", 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#"<a class="btn primary" href="{b}/build">build a paper</a>"#, b = ctx.base)
@ -358,20 +377,24 @@ pub fn attempt(ctx: &Ctx, user: &str, attempt_id: &str, questions: &[Question],
};
format!(
r#"<section class="card qpane" id="qpane-{i}" data-i="{i}"{hidden}>
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap">
<span class="dim">question {n} · {subj} · {ch}</span>
<div class="pane-head">
<span class="dim">question {n} of {total} · {subj}</span>
<span class="dim">{qt} · +4 / −1{revflag}</span>
</div>
<p style="margin:10px 0 4px;color:#fff">{stem}</p>
<div class="dim" style="margin-bottom:8px">{sub2} · {year} {shift} · {difficulty}</div>
<p class="stem">{stem}</p>
<div class="dim meta-line">{sub2} · {ch} · {year} {shift} · {difficulty}</div>
{opts}
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:12px">
<div class="opt-actions">
<button type="button" class="btn" data-act="prev"{prevdis}>previous</button>
<button type="button" class="btn ok" data-act="save">save &amp; next</button>
<button type="button" class="btn" data-act="review">mark for review</button>
<button type="button" class="btn" data-act="review"{revcls}>mark for review</button>
<button type="button" class="btn" data-act="clear">clear</button>
</div>
</section>"#,
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#"<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;margin:14px 0">
<div><h1 style="margin:0">{title}</h1><span class="dim">{n} questions · +4 / −1</span></div>
<div style="text-align:right"><div class="timer" id="timer" data-secs="{secs}">{dur}</div><div class="dim">remaining</div></div>
r#"<div class="exam-bar">
<div class="exam-bar-inner">
<div class="exam-title"><b>{title}</b><span class="dim" id="exam-progress">{n} questions</span></div>
<div class="exam-clock"><span class="dim">time left</span><div class="timer" id="timer" data-secs="{secs}" role="timer">{dur}</div></div>
</div>
<form method="post" action="{b}/attempt/{a}/submit" id="exam-form" data-attempt="{a}">
<div class="exam-progress"><i id="exam-fill"></i></div>
</div>
<form method="post" action="{b}/attempt/{a}/submit" id="exam-form" data-attempt="{a}" data-base="{b}">
<div class="cbt">
<div>{qs}</div>
<div><h2>palette</h2><div class="palette" id="pal">{pal}</div>
<p class="dim" style="margin-top:10px"><span class="ok-t">answered</span> · <span class="warn-t">review</span> · <span class="dim">blank</span></p>
<button class="btn primary" type="submit" style="width:100%;margin-top:12px">submit paper</button>
<aside class="cbt-side" id="pal-wrap">
<h2>palette</h2>
<div class="palette" id="pal" aria-label="question palette">{pal}</div>
<ul class="legend">
<li><i class="sw ans"></i>answered</li>
<li><i class="sw rev"></i>review</li>
<li><i class="sw skip"></i>blank</li>
</ul>
<p class="cbt-count dim" id="cbt-count">0 answered</p>
<button class="btn primary block" type="submit">submit paper</button>
<button type="button" class="btn block" data-close-palette>close palette</button>
</aside>
</div>
</div></form>
<button type="button" class="btn pal-fab" id="pal-toggle" aria-expanded="false" aria-controls="pal-wrap">palette <span id="pal-fab-count"></span></button>
</form>
<script src="{b}/static/exam.js"></script>"#,
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
<div class="card"><span class="dim">time used</span><b>{dur}</b></div>
</div>
<div class="grid2">
<div class="card"><h3>by subject</h3><table><thead><tr><th>subject</th><th>right</th><th>wrong</th><th>blank</th><th></th><th>%</th><th></th></tr></thead><tbody>{subj}</tbody></table></div>
<div class="card"><h3>by difficulty</h3><table><thead><tr><th>difficulty</th><th>right</th><th>wrong</th><th>blank</th><th></th><th>%</th><th></th></tr></thead><tbody>{diff}</tbody></table></div>
<div class="card table-wrap"><h3>by subject</h3><table><thead><tr><th>subject</th><th>right</th><th>wrong</th><th>blank</th><th></th><th>%</th><th></th></tr></thead><tbody>{subj}</tbody></table></div>
<div class="card table-wrap"><h3>by difficulty</h3><table><thead><tr><th>difficulty</th><th>right</th><th>wrong</th><th>blank</th><th></th><th>%</th><th></th></tr></thead><tbody>{diff}</tbody></table></div>
</div>
<h2>chapters that cost you marks</h2>
<div class="card"><table><thead><tr><th>subtopic</th><th>right</th><th>wrong</th><th>blank</th><th></th><th>%</th><th></th></tr></thead><tbody>{topic}</tbody></table></div>
<div class="card table-wrap"><table><thead><tr><th>subtopic</th><th>right</th><th>wrong</th><th>blank</th><th></th><th>%</th><th></th></tr></thead><tbody>{topic}</tbody></table></div>
<h2>every question</h2>
{detail}
<p style="margin-top:18px;display:flex;gap:8px;flex-wrap:wrap">
@ -557,7 +593,7 @@ pub fn history(ctx: &Ctx, user: &str, tests: &[TestRow]) -> String {
)
} else {
format!(
r#"<h1>History</h1><div class="card" style="margin-top:16px;overflow-x:auto"><table><thead><tr><th>date</th><th>paper</th><th>score</th><th>%</th><th>accuracy</th><th>time</th><th></th></tr></thead><tbody>{rows}</tbody></table></div>"#,
r#"<h1>History</h1><div class="card table-wrap" style="margin-top:16px"><table><thead><tr><th>date</th><th>paper</th><th>score</th><th>%</th><th>accuracy</th><th>time</th><th></th></tr></thead><tbody>{rows}</tbody></table></div>"#,
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#"<h2>percentage across papers</h2><div class="card"><table><thead><tr><th>date</th><th>paper</th><th>%</th><th>accuracy</th></tr></thead><tbody>"#);
let mut s = String::from(r#"<h2>percentage across papers</h2><div class="card table-wrap"><table><thead><tr><th>date</th><th>paper</th><th>%</th><th>accuracy</th></tr></thead><tbody>"#);
for t in tests {
s.push_str(&format!(
r#"<tr><td>{}</td><td>{}</td><td>{}%</td><td>{}%</td></tr>"#,
@ -616,7 +652,7 @@ pub fn dashboard(ctx: &Ctx, user: &str, tests: &[TestRow], weak: &[(Bucket, Stri
</div>
{trend}
<h2>weakest subtopics across all papers</h2>
<div class="card"><table><thead><tr><th>subtopic</th><th>seen</th><th>wrong</th><th>blank</th><th></th><th></th></tr></thead><tbody>{weak}</tbody></table></div>
<div class="card table-wrap"><table><thead><tr><th>subtopic</th><th>seen</th><th>wrong</th><th>blank</th><th></th><th></th></tr></thead><tbody>{weak}</tbody></table></div>
<h2>coach notes</h2>
<div class="card"><ul style="padding-left:18px;display:grid;gap:6px">{notes}</ul><p class="dim" style="margin-top:10px">Written from your own submitted papers. No data leaves this machine.</p></div>
<p style="margin-top:18px"><a class="btn" href="{b}/history">all papers</a></p>"#,

View file

@ -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);
})();

View file

@ -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}