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:
parent
c6269ad3dd
commit
14542f169a
6 changed files with 505 additions and 52 deletions
123
scripts/audit_mobile.py
Executable file
123
scripts/audit_mobile.py
Executable 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
70
scripts/shots.py
Executable 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))))
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
80
src/views.rs
80
src/views.rs
|
|
@ -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 & 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>"#,
|
||||
|
|
|
|||
135
static/exam.js
135
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);
|
||||
})();
|
||||
136
static/style.css
136
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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue