UX parity round 2: compact (1)-(4) radio pills, palette section groups, answers-saved label, result nav restructure with tag-banner and chapter-subtopic table, build page mode cards + complete-syllabus + sticky quota sidebar

This commit is contained in:
spearzificial 2026-10-18 17:24:05 +05:30
parent 64056b8b74
commit 78f4ac2507
3 changed files with 458 additions and 160 deletions

View file

@ -17,6 +17,23 @@ pub fn esc(s: &str) -> String {
o o
} }
/// Strip HTML/math to a short plain string, for title attributes.
fn plain(s: &str) -> String {
let mut out = s.replace("$$", " ").replace("\\(", " ").replace("\\)", " ");
loop {
match out.find('<') {
Some(i) => match out[i..].find('>') {
Some(j) => {
out.replace_range(i..i + j + 1, "");
}
None => break,
},
None => break,
}
}
out.chars().take(80).collect()
}
/// Render question body HTML stored in the DB. The importer sanitizes at write /// Render question body HTML stored in the DB. The importer sanitizes at write
/// time; this re-sanitizes on read so a hand-edited row can never inject a /// time; this re-sanitizes on read so a hand-edited row can never inject a
/// script. Only the sanitizer's allowlist survives — see /// script. Only the sanitizer's allowlist survives — see
@ -465,7 +482,22 @@ pub fn build(ctx: &Ctx, user: &str, blocks: Vec<SubjectBlock>, _form: &BuildForm
let body = format!( let body = format!(
r#"<div class="zp-builder-page"> r#"<div class="zp-builder-page">
<h1 class="zp-page-title">Prepare a JEE Main test</h1> <h1 class="zp-page-title">Prepare a JEE Main test</h1>
<p class="zp-page-subtitle">Build a paper from the chapters you choose. {available} authentic JEE Main PYQs in the bank.</p> <p class="zp-page-subtitle">Build your own paper from the chapters you choose, or take an actual JEE Main paper year by year. {available} authentic PYQs in the bank.</p>
<!-- Mode cards -->
<div class="zp-mode-cards">
<div class="zp-mode-card active" id="mode-build">
<b>Build your own paper</b>
<span>Chapters you choose, or the complete syllabus</span>
</div>
<div class="zp-mode-card" id="mode-past">
<b>Previous year paper</b>
<span>An actual JEE Main paper: pick the year and shift</span>
</div>
</div>
<button type="button" class="zp-complete-btn" id="btn-complete-syllabus">complete syllabus</button>
<p class="zp-complete-hint">Selects every chapter in all three subjects. You can still remove any you don't want.</p>
<!-- Subject Filter Pills --> <!-- Subject Filter Pills -->
<div class="zp-subject-pills-bar"> <div class="zp-subject-pills-bar">
@ -498,6 +530,7 @@ pub fn build(ctx: &Ctx, user: &str, blocks: Vec<SubjectBlock>, _form: &BuildForm
</div> </div>
</div> </div>
<div class="zp-two-col">
<form method="post" action="{b}/build" id="build-form"> <form method="post" action="{b}/build" id="build-form">
<input type="hidden" name="chapters" id="chapters" value=""> <input type="hidden" name="chapters" id="chapters" value="">
<input type="hidden" name="subtopics" id="subtopics" value=""> <input type="hidden" name="subtopics" id="subtopics" value="">
@ -509,84 +542,60 @@ pub fn build(ctx: &Ctx, user: &str, blocks: Vec<SubjectBlock>, _form: &BuildForm
<div class="zp-chapters-container"> <div class="zp-chapters-container">
{picker} {picker}
</div> </div>
<!-- Bottom Filter Box (DIFFICULTY & YEARS) -->
<div class="zp-filter-box">
<div class="zp-filter-sec">
<span class="zp-filter-heading">DIFFICULTY</span>
<div class="zp-pill-row" id="diff-row">
<label class="zp-filter-pill active">
<input type="checkbox" class="diff-chk" value="Easy" checked>
<span>Easy</span>
</label>
<label class="zp-filter-pill active">
<input type="checkbox" class="diff-chk" value="Medium" checked>
<span>Medium</span>
</label>
<label class="zp-filter-pill active">
<input type="checkbox" class="diff-chk" value="Hard" checked>
<span>Hard</span>
</label>
</div>
</div>
<div class="zp-filter-sec" style="margin-top:20px">
<span class="zp-filter-heading">YEARS</span>
<div class="zp-pill-row" id="year-row">
<label class="zp-filter-pill active"><input type="checkbox" class="year-chk" value="2020" checked><span>2020</span></label>
<label class="zp-filter-pill active"><input type="checkbox" class="year-chk" value="2021" checked><span>2021</span></label>
<label class="zp-filter-pill active"><input type="checkbox" class="year-chk" value="2022" checked><span>2022</span></label>
<label class="zp-filter-pill active"><input type="checkbox" class="year-chk" value="2023" checked><span>2023</span></label>
<label class="zp-filter-pill active"><input type="checkbox" class="year-chk" value="2024" checked><span>2024</span></label>
<label class="zp-filter-pill active"><input type="checkbox" class="year-chk" value="2025" checked><span>2025</span></label>
<label class="zp-filter-pill"><input type="checkbox" class="year-chk" value="2019"><span>2019</span></label>
<label class="zp-filter-pill"><input type="checkbox" class="year-chk" value="2018"><span>2018</span></label>
</div>
</div>
</div>
<!-- Paper Settings & Start Bar -->
<div class="zp-settings-bar">
<div class="zp-settings-row">
<div class="zp-setting-item">
<label class="zp-setting-label">Questions</label>
<input type="number" id="count-input" name="count" value="30" min="5" max="75" style="width:110px">
</div>
<div class="zp-setting-item">
<label class="zp-toggle-label">
<input type="checkbox" id="jee-mode" checked>
<span>JEE Main 60-min timer per subject</span>
</label>
<label class="zp-toggle-label" style="margin-top:6px">
<input type="checkbox" name="unseen_only" checked>
<span>Unseen questions only</span>
</label>
</div>
<div class="zp-setting-item" id="dur-box" hidden>
<label class="zp-setting-label" for="duration">Duration (Minutes)</label>
<input type="number" id="duration" name="duration" value="60" min="5" max="180">
</div>
</div>
{e}
<div class="zp-submit-row">
<div class="zp-summary-text" id="zp-summary">
<b>0 chapters selected</b>
<span class="dim" id="zp-pool-estimate">Pick chapters above</span>
</div>
<button type="submit" class="zp-btn-start" id="submit-btn" disabled>
<span>Start Practice Paper</span>
<span class="zp-btn-desc" id="btn-desc">Select topics to begin</span>
</button>
</div>
</div>
</form> </form>
<!-- Right sticky sidebar -->
<aside class="zp-side-panel">
<div class="zp-filter-sec">
<span class="zp-filter-heading">DIFFICULTY</span>
<div class="zp-pill-row" id="diff-row">
<label class="zp-filter-pill active"><input type="checkbox" class="diff-chk" value="Easy" checked><span>Easy</span></label>
<label class="zp-filter-pill active"><input type="checkbox" class="diff-chk" value="Medium" checked><span>Medium</span></label>
<label class="zp-filter-pill active"><input type="checkbox" class="diff-chk" value="Hard" checked><span>Hard</span></label>
</div>
</div>
<div class="zp-filter-sec">
<span class="zp-filter-heading">YEARS</span>
<div class="zp-pill-row" id="year-row">
<label class="zp-filter-pill active"><input type="checkbox" class="year-chk" value="2020" checked><span>2020</span></label>
<label class="zp-filter-pill active"><input type="checkbox" class="year-chk" value="2021" checked><span>2021</span></label>
<label class="zp-filter-pill active"><input type="checkbox" class="year-chk" value="2022" checked><span>2022</span></label>
<label class="zp-filter-pill active"><input type="checkbox" class="year-chk" value="2023" checked><span>2023</span></label>
<label class="zp-filter-pill active"><input type="checkbox" class="year-chk" value="2024" checked><span>2024</span></label>
<label class="zp-filter-pill active"><input type="checkbox" class="year-chk" value="2025" checked><span>2025</span></label>
</div>
</div>
<div class="zp-filter-sec">
<span class="zp-filter-heading">YOUR PAPER</span>
<div id="zp-pool-quotas"></div>
<div class="zp-summary-text" id="zp-summary">
<b>0 chapters selected</b>
<span class="dim" id="zp-pool-estimate">Pick chapters above</span>
</div>
<div class="zp-setting-item">
<label class="zp-setting-label">Questions</label>
<input type="number" id="count-input" name="count" value="30" min="5" max="75" style="width:110px">
</div>
<label class="zp-toggle-label">
<input type="checkbox" id="jee-mode" checked>
<span>JEE Main 60-min timer per subject</span>
</label>
<label class="zp-toggle-label" style="margin-top:6px">
<input type="checkbox" name="unseen_only" checked>
<span>Unseen questions only</span>
</label>
<div class="zp-setting-item" id="dur-box" hidden>
<label class="zp-setting-label" for="duration">Duration (Minutes)</label>
<input type="number" id="duration" name="duration" value="60" min="5" max="180">
</div>
{e}
<button type="submit" form="build-form" class="zp-btn-start" id="submit-btn" disabled>
<span>Start Practice Paper</span>
<span class="zp-btn-desc" id="btn-desc">Select topics to begin</span>
</button>
</div>
</aside>
</div> </form>
</div> </div>
<script> <script>
@ -607,6 +616,20 @@ pub fn build(ctx: &Ctx, user: &str, blocks: Vec<SubjectBlock>, _form: &BuildForm
const btnDesc = document.getElementById('btn-desc'); const btnDesc = document.getElementById('btn-desc');
const searchInput = document.getElementById('zp-search-input'); const searchInput = document.getElementById('zp-search-input');
// 0. Complete syllabus: check every chapter across all subjects
const completeBtn = document.getElementById('btn-complete-syllabus');
if (completeBtn) {{
completeBtn.addEventListener('click', () => {{
document.querySelectorAll('.chap').forEach(c => {{
c.checked = true;
c.indeterminate = false;
const item = c.closest('.zp-chapter-item');
item.querySelectorAll('.subtopic').forEach(st => st.checked = true);
}});
sync();
}});
}}
// 1. Subject Switcher Pills (Mathematics, Physics, Chemistry) // 1. Subject Switcher Pills (Mathematics, Physics, Chemistry)
document.querySelectorAll('.zp-subj-pill').forEach(pill => {{ document.querySelectorAll('.zp-subj-pill').forEach(pill => {{
pill.addEventListener('click', () => {{ pill.addEventListener('click', () => {{
@ -776,10 +799,26 @@ pub fn build(ctx: &Ctx, user: &str, blocks: Vec<SubjectBlock>, _form: &BuildForm
// Calculate pool // Calculate pool
let pool = 0; let pool = 0;
const perSubj = {{ Maths: 0, Physics: 0, Chemistry: 0 }};
checkedChaps.forEach(c => {{ checkedChaps.forEach(c => {{
pool += Number(c.dataset.count || 0); const n = Number(c.dataset.count || 0);
pool += n;
perSubj[c.dataset.subj] = (perSubj[c.dataset.subj] || 0) + n;
}}); }});
// ZenithPrep-style quota badges: 20/20 MCQ + 5/5 num per subject
const quotas = document.getElementById('zp-pool-quotas');
if (quotas) {{
quotas.innerHTML = ['Maths', 'Physics', 'Chemistry']
.filter(s => (document.querySelectorAll(`.chap:checked[data-subj="${{s}}"]`).length > 0))
.map(s => {{
const n = perSubj[s] || 0;
const mcq = Math.min(20, n);
const num = Math.min(5, Math.max(0, n - 20));
return `<div class="zp-quota-row"><span>${{s === 'Maths' ? 'Mathematics' : s}}</span><span class="zp-quota-badges"><span class="zp-quota-badge">${{mcq}}/20 MCQ</span><span class="zp-quota-badge">${{num}}/5 num</span></span></div>`;
}}).join('');
}}
const nChapters = checkedChaps.length; const nChapters = checkedChaps.length;
const qCount = countField.value || 30; const qCount = countField.value || 30;
@ -808,22 +847,35 @@ pub fn build(ctx: &Ctx, user: &str, blocks: Vec<SubjectBlock>, _form: &BuildForm
} }
fn palette(questions: &[Question], answers: &[(i64, String, bool)]) -> String { fn palette(questions: &[Question], answers: &[(i64, String, bool)]) -> String {
questions // ZenithPrep groups the palette by subject + section (A = MCQ, B = NUM),
.iter() // each with its own header and grid.
.enumerate() let mut out = String::new();
.map(|(i, q)| { let mut last_key: Option<(&str, &str)> = None;
let a = answers.iter().find(|(id, _, _)| *id == q.id); for (i, q) in questions.iter().enumerate() {
let sel = a.map(|(_, s, _)| !s.trim().is_empty()).unwrap_or(false); let key = (q.subject.as_str(), q.qtype.as_str());
let rev = a.map(|(_, _, r)| *r).unwrap_or(false); if last_key != Some(key) {
let cls = match (sel, rev) { let sec = if q.qtype == "NUM" { "SECTION B" } else { "SECTION A" };
(true, true) => "pal ansrev", out.push_str(&format!(
(true, false) => "pal ans", r#"<div class="pal-sechead">{subj} · {sec}</div>"#,
_ => "pal", subj = esc(&q.subject),
}; ));
format!(r#"<button type="button" class="{cls}" data-jump="{i}">{n}</button>"#, cls = cls, n = i + 1) last_key = Some(key);
}) }
.collect::<Vec<_>>() let a = answers.iter().find(|(id, _, _)| *id == q.id);
.join("") let sel = a.map(|(_, s, _)| !s.trim().is_empty()).unwrap_or(false);
let rev = a.map(|(_, _, r)| *r).unwrap_or(false);
let cls = match (sel, rev) {
(true, true) => "pal ansrev",
(true, false) => "pal ans",
_ => "pal",
};
out.push_str(&format!(
r#"<button type="button" class="{cls}" data-jump="{i}">{n}</button>"#,
cls = cls,
n = i + 1
));
}
out
} }
pub fn attempt(ctx: &Ctx, user: &str, attempt_id: &str, questions: &[Question], answers: &[(i64, String, bool)], duration_min: i64, title: &str) -> String { pub fn attempt(ctx: &Ctx, user: &str, attempt_id: &str, questions: &[Question], answers: &[(i64, String, bool)], duration_min: i64, title: &str) -> String {
@ -847,13 +899,17 @@ pub fn attempt(ctx: &Ctx, user: &str, attempt_id: &str, questions: &[Question],
.map(|(k, letter)| { .map(|(k, letter)| {
let text = q.opts[k].clone(); let text = q.opts[k].clone();
let checked = if sel.trim().eq_ignore_ascii_case(letter) { " checked" } else { "" }; let checked = if sel.trim().eq_ignore_ascii_case(letter) { " checked" } else { "" };
// Compact ZenithPrep-style radio pill: the option text
// already sits inside the white card; the pill only
// carries the hollow radio + (1)..(4) label.
format!( format!(
r#"<label class="opt{cls}"><input type="radio" name="q-{id}" value="{letter}"{checked}><span class="opt-letter">{letter}</span><span class="opt-text">{t}</span></label>"#, r#"<label class="opt{cls}" title="{title}"><input type="radio" name="q-{id}" value="{letter}"{checked}><span class="opt-letter">{n}</span></label>"#,
cls = if sel.trim().eq_ignore_ascii_case(letter) { " sel" } else { "" }, cls = if sel.trim().eq_ignore_ascii_case(letter) { " sel" } else { "" },
title = esc(&plain(&text)),
id = q.id, id = q.id,
letter = letter, letter = letter,
checked = checked, checked = checked,
t = rich(&text) n = k + 1
) )
}) })
.collect() .collect()
@ -945,7 +1001,7 @@ pub fn attempt(ctx: &Ctx, user: &str, attempt_id: &str, questions: &[Question],
<div class="exam-cand"> <div class="exam-cand">
<span class="dim">candidate</span> <span class="dim">candidate</span>
<b>{user}</b> <b>{user}</b>
<span class="saved dim" id="save-dot" title="autosave state"></span> <span class="save-state" id="save-state">answers saved</span>
</div> </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 class="exam-clock"><span class="dim">time left</span><div class="timer" id="timer" data-secs="{secs}" role="timer">{dur}</div></div>
</div> </div>
@ -1059,6 +1115,27 @@ fn bucket_rows(items: &[Bucket]) -> String {
.collect() .collect()
} }
/// "Chapter › Subtopic" rows for the chapters-that-cost-you-marks table,
/// with the chapter as a cyan link like ZenithPrep.
fn topic_rows(items: &[(Bucket, String)]) -> String {
items
.iter()
.map(|(b, chapter)| {
format!(
r#"<tr><td><span class="chap-link">{ch}</span> › {sub}</td><td>{r}</td><td>{w}</td><td>{s}</td><td style="width:38%">{seg}</td><td>{p}%</td><td class="{st}">{st}</td></tr>"#,
ch = esc(chapter),
sub = esc(&b.label),
r = b.right,
w = b.wrong,
s = b.skipped,
seg = seg(b),
p = b.pct(),
st = b.status()
)
})
.collect()
}
pub fn result(ctx: &Ctx, user: &str, attempt_id: &str, share_token: &str, a: &Analysis, rows: &[RowView]) -> String { pub fn result(ctx: &Ctx, user: &str, attempt_id: &str, share_token: &str, a: &Analysis, rows: &[RowView]) -> String {
let base = ctx.base.clone(); let base = ctx.base.clone();
let mistakes_pending = rows let mistakes_pending = rows
@ -1123,7 +1200,6 @@ pub fn result(ctx: &Ctx, user: &str, attempt_id: &str, share_token: &str, a: &An
) )
}) })
.collect(); .collect();
let topic_buckets: Vec<Bucket> = a.by_topic.iter().map(|(b, _)| b.to_owned()).collect();
let neg = rows.iter().filter(|r| !r.correct && !r.selected.trim().is_empty()).count() as i32; let neg = rows.iter().filter(|r| !r.correct && !r.selected.trim().is_empty()).count() as i32;
// Time analysis: where the clock went, from per-question times. // Time analysis: where the clock went, from per-question times.
let avg_for = |filt: &dyn Fn(&RowView) -> bool| -> String { let avg_for = |filt: &dyn Fn(&RowView) -> bool| -> String {
@ -1186,35 +1262,38 @@ pub fn result(ctx: &Ctx, user: &str, attempt_id: &str, share_token: &str, a: &An
let body = format!( let body = format!(
r##"<div class="result-layout"> r##"<div class="result-layout">
<nav class="result-nav" aria-label="result sections"> <nav class="result-nav" aria-label="result sections">
<b>{title_}</b> <b>test analysis</b>
<span class="dim nav-meta">{user} · {title_}</span>
<a href="#r-overview">overview</a> <a href="#r-overview">overview</a>
<a href="#r-subjects">subject-wise</a> <a href="#r-questions">tag mistakes{mtag}</a>
<a href="#r-difficulty">difficulty</a> <a href="#r-subjects">performance analysis</a>
<a href="#r-chapters">chapters that cost you marks</a>
<a href="#r-time">time analysis</a> <a href="#r-time">time analysis</a>
<a href="#r-questions">per-question analysis{mtag}</a> <a href="#r-difficulty">difficulty analysis</a>
<a href="#r-questions">per question analysis</a>
<a class="btn" href="{b}/build">back to paper</a>
<a class="btn" href="{b}/history">all papers</a> <a class="btn" href="{b}/history">all papers</a>
<a class="btn" href="{b}/dashboard">dashboard</a>
<a class="btn primary" href="{b}/build">new paper</a> <a class="btn primary" href="{b}/build">new paper</a>
{share_nav}
</nav> </nav>
<div class="result-main"> <div class="result-main">
<div id="r-overview" class="kv"> <div id="r-overview" class="kv">
<div class="card"><span class="dim">score</span><b>{score} <span class="dim">/ {max}</span></b></div> <div class="card"><span class="dim">score</span><b>{score} <span class="dim">/ {max}</span></b></div>
<div class="card"><span class="dim">accuracy</span><b class="ok-t">{acc}%</b></div> <div class="card"><span class="dim">accuracy</span><b class="ok-t">{acc}%</b><span class="dim sub">{att} of {n} attempted</span></div>
<div class="card"><span class="dim">negative marks</span><b class="bad-t">−{neg}</b></div> <div class="card"><span class="dim">negative marks</span><b class="bad-t">−{neg}</b><span class="dim sub">{wr} wrong answer{wrs}</span></div>
<div class="card"><span class="dim">time used</span><b>{dur}</b></div> <div class="card"><span class="dim">time used</span><b>{dur}</b></div>
</div> </div>
<div class="card encourage" role="status"> <div class="card encourage" role="status">
<b>{eh}</b> <b>{eh}</b>
<p>{eb}</p> <p>{eb}</p>
</div> </div>
{share_box} {tag_banner}
<div id="r-subjects" class="grid2"> <div id="r-subjects" class="grid2">
<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>subject-wise result</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 id="r-difficulty" 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 id="r-difficulty" 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> </div>
<h2 id="r-chapters">chapters that cost you marks</h2> <h2 id="r-chapters">chapters that cost you marks</h2>
<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> <p class="dim">subtopics with the most wrong or skipped questions in this test.</p>
<div class="card table-wrap"><table><thead><tr><th>chapter › subtopic</th><th>right</th><th>wrong</th><th>blank</th><th></th><th>%</th><th></th></tr></thead><tbody>{topic}</tbody></table></div>
{time_table} {time_table}
<h2 id="r-questions">every question</h2> <h2 id="r-questions">every question</h2>
{detail} {detail}
@ -1248,10 +1327,23 @@ pub fn result(ctx: &Ctx, user: &str, attempt_id: &str, share_token: &str, a: &An
dur = fmt_dur(a.used_secs), dur = fmt_dur(a.used_secs),
eh = enc_head, eh = enc_head,
eb = enc_body, eb = enc_body,
share_box = share_box, share_nav = if share_token.is_empty() { String::new() } else {
format!(r#"<div class="nav-share">{share_box}</div>"#, share_box = share_box)
},
tag_banner = if mistakes_pending > 0 {
format!(
r##"<div class="card rec-banner"><b>recommended: tag your {n} mistakes</b><p>Pick why you lost each question (calculation slip, formula, ran out of time...). It takes a couple of minutes and adds mistake-type charts and sharper advice to your dashboard.</p><a class="btn primary" href="#r-questions">tag mistakes</a></div>"##,
n = mistakes_pending
)
} else { String::new() },
user = esc(user),
att = a.right + a.wrong,
n = rows.len(),
wr = a.wrong,
wrs = if a.wrong == 1 { "" } else { "s" },
subj = bucket_rows(&a.by_subject), subj = bucket_rows(&a.by_subject),
diff = bucket_rows(&a.by_difficulty), diff = bucket_rows(&a.by_difficulty),
topic = bucket_rows(&topic_buckets), topic = topic_rows(&a.by_topic),
time_table = time_table, time_table = time_table,
mtag = if mistakes_pending > 0 { format!(r#" <span class="badge">{}</span>"#, mistakes_pending) } else { String::new() }, mtag = if mistakes_pending > 0 { format!(r#" <span class="badge">{}</span>"#, mistakes_pending) } else { String::new() },
title_ = esc(&title), title_ = esc(&title),

View file

@ -16,7 +16,7 @@
const box = p.querySelector('input'); const box = p.querySelector('input');
if (box) entered.set(p.dataset.i, Number(box.dataset.t || 0)); if (box) entered.set(p.dataset.i, Number(box.dataset.t || 0));
} const visited = new Set(); } const visited = new Set();
const saveDot = document.getElementById('save-dot'); const saveDot = document.getElementById('save-state');
// Per-question stopwatch: accrues only while the pane is on screen, so // Per-question stopwatch: accrues only while the pane is on screen, so
// time spent elsewhere never counts against a question. // time spent elsewhere never counts against a question.
@ -133,7 +133,7 @@
// A second change while a save is in flight is coalesced into one follow-up write. // A second change while a save is in flight is coalesced into one follow-up write.
if (saving) { pending = all || pending; return; } if (saving) { pending = all || pending; return; }
saving = true; saving = true;
if (saveDot) saveDot.classList.add('busy'); if (saveDot) saveDot.textContent = 'saving…';
const panesToSave = all ? panes : [panes[cur]]; const panesToSave = all ? panes : [panes[cur]];
try { try {
await fetch(`${form.dataset.base}/attempt/${form.dataset.attempt}/answer`, { await fetch(`${form.dataset.base}/attempt/${form.dataset.attempt}/answer`, {
@ -141,13 +141,13 @@
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ answers: panesToSave.map(collect) }), body: JSON.stringify({ answers: panesToSave.map(collect) }),
}); });
if (saveDot) { saveDot.classList.remove('busy'); saveDot.classList.add('ok'); } if (saveDot) { saveDot.textContent = 'answers saved'; saveDot.classList.remove('fail'); }
} catch (_) { } catch (_) {
// Server unreachable: the selection stays on screen and travels with the form submit. // Server unreachable: the selection stays on screen and travels with the form submit.
if (saveDot) { saveDot.classList.remove('busy'); saveDot.classList.add('fail'); } if (saveDot) { saveDot.textContent = 'save failed'; saveDot.classList.add('fail'); }
} }
saving = false; saving = false;
if (saveDot && !saving) setTimeout(() => saveDot.classList.remove('ok', 'fail'), 1200);
if (pending) { const again = pending; pending = false; save(again); } if (pending) { const again = pending; pending = false; save(again); }
} }

View file

@ -1175,27 +1175,26 @@ footer {
color: #111827; color: #111827;
} }
/* Multiple Choice Option Cards */ /* Multiple Choice Option Pills (compact: text lives inside the white card) */
.opt { .opt {
display: flex; display: inline-flex;
gap: 14px; gap: 8px;
align-items: center; align-items: center;
background: var(--panel); background: var(--panel);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 12px 18px; padding: 10px 16px;
margin: 10px 0; margin: 6px 10px 6px 0;
font-size: 1rem; font-size: 0.95rem;
color: #e2e8f0; color: #e2e8f0;
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: all 0.15s ease;
min-height: 52px; min-height: 44px;
} }
.opt:hover { .opt:hover {
background: var(--card-hover); background: var(--card-hover);
border-color: #3b82f6; border-color: #3b82f6;
transform: translateX(2px);
} }
.opt.sel { .opt.sel {
@ -1213,33 +1212,22 @@ footer {
} }
.opt-letter { .opt-letter {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius-md);
background: var(--border);
color: #f8fafc;
font-weight: 700; font-weight: 700;
font-size: 0.85rem; font-size: 0.9rem;
flex-shrink: 0; flex-shrink: 0;
transition: all 0.15s ease;
} }
.opt.sel .opt-letter { .opt.sel .opt-letter {
background: #2563eb;
color: #ffffff; color: #ffffff;
} }
.opt-text { /* Option-image questions still need room; pills with images go wide */
flex: 1; .opt:has(img) {
min-width: 0; display: flex;
line-height: 1.5; max-width: 100%;
} }
/* Images inside an option (organic structures, graphs): shrink to fit the pill */ .opt:has(img) img {
.opt-text img {
display: inline-block; display: inline-block;
max-height: 120px; max-height: 120px;
max-width: 100%; max-width: 100%;
@ -1344,24 +1332,26 @@ footer {
font-size: 0.825rem; font-size: 0.825rem;
} }
#save-dot { #save-state {
width: 9px; display: inline-flex;
height: 9px; align-items: center;
gap: 6px;
font-size: 0.75rem;
color: #6ee7b7;
white-space: nowrap;
}
#save-state::before {
content: "✓";
display: inline-grid;
place-items: center;
width: 16px;
height: 16px;
border-radius: 50%; border-radius: 50%;
background: var(--border); background: rgba(16, 185, 129, 0.18);
transition: background 0.2s ease; color: #10b981;
} font-size: 0.65rem;
font-weight: 700;
#save-dot.busy {
background: #f59e0b;
}
#save-dot.ok {
background: #10b981;
}
#save-dot.fail {
background: #ef4444;
} }
/* Share box on the result page */ /* Share box on the result page */
@ -1441,6 +1431,29 @@ footer {
white-space: nowrap; white-space: nowrap;
} }
.result-nav .nav-meta {
font-size: 0.7rem;
color: var(--text-muted);
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
overflow-wrap: anywhere;
}
.result-nav .nav-share {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border);
}
.result-nav .nav-share .share-box {
margin: 0;
}
.result-nav .nav-share .share-row code {
font-size: 0.65rem;
}
.result-nav a { .result-nav a {
font-size: 0.875rem; font-size: 0.875rem;
color: var(--text-muted); color: var(--text-muted);
@ -1507,7 +1520,22 @@ footer {
display: grid; display: grid;
grid-template-columns: repeat(5, 1fr); grid-template-columns: repeat(5, 1fr);
gap: 6px; gap: 6px;
margin: 12px 0; margin: 8px 0;
}
/* Section header row inside the palette (MATHEMATICS · SECTION A) */
.pal-sechead {
grid-column: 1 / -1;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
margin: 10px 0 2px;
}
.pal-sechead:first-child {
margin-top: 0;
} }
.pal { .pal {
@ -2591,3 +2619,181 @@ img.brand-logo {
@media (max-width: 560px) { @media (max-width: 560px) {
.ch-list { columns: 1; } .ch-list { columns: 1; }
} }
#save-state.fail {
color: #fca5a5;
}
#save-state.fail::before {
content: "!";
background: rgba(239, 68, 68, 0.18);
color: #ef4444;
}
/* Stat-card subtext ("6 of 25 attempted", "1 wrong answer") */
.kv .card .sub {
display: block;
font-size: 0.7rem;
margin-top: 4px;
}
/* "Recommended: tag your N mistakes" banner */
.card.rec-banner {
margin: 14px 0;
border-left: 3px solid #3b82f6;
}
.card.rec-banner b {
display: block;
font-size: 0.95rem;
margin-bottom: 6px;
color: #ffffff;
}
.card.rec-banner p {
margin: 0 0 10px;
font-size: 0.85rem;
color: var(--text-dim);
line-height: 1.5;
}
/* Chapter name in the chapters table (cyan like ZenithPrep links) */
.chap-link {
color: #38bdf8;
font-weight: 600;
}
/* Build page: mode cards, complete-syllabus, two-column layout */
.zp-mode-cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 16px;
}
.zp-mode-card {
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--panel);
padding: 14px 16px;
cursor: pointer;
transition: border-color 0.15s ease;
}
.zp-mode-card b {
display: block;
font-size: 0.95rem;
color: #ffffff;
margin-bottom: 4px;
}
.zp-mode-card span {
font-size: 0.8rem;
color: var(--text-muted);
}
.zp-mode-card.active {
border-color: #3b82f6;
background: rgba(59, 130, 246, 0.08);
}
.zp-mode-card.active b {
color: #93c5fd;
}
.zp-complete-btn {
background: none;
border: none;
color: #93c5fd;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
padding: 0;
margin-bottom: 4px;
text-decoration: underline;
text-underline-offset: 3px;
}
.zp-complete-btn:hover {
color: #bfdbfe;
}
.zp-complete-hint {
font-size: 0.75rem;
color: var(--text-muted);
margin: 0 0 18px;
}
.zp-two-col {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 18px;
align-items: start;
}
.zp-side-panel {
position: sticky;
top: 76px;
display: flex;
flex-direction: column;
gap: 18px;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
padding: 16px;
}
.zp-side-panel .zp-filter-heading {
display: block;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--text-muted);
margin-bottom: 8px;
}
.zp-side-panel .zp-btn-start {
width: 100%;
margin-top: 4px;
}
#zp-pool-quotas {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 10px;
}
.zp-quota-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 0.8rem;
color: var(--text-dim);
}
.zp-quota-row .zp-quota-badges {
display: flex;
gap: 6px;
}
.zp-quota-badge {
font-size: 0.7rem;
font-weight: 700;
color: #6ee7b7;
background: rgba(16, 185, 129, 0.12);
border: 1px solid rgba(16, 185, 129, 0.3);
border-radius: 999px;
padding: 2px 8px;
white-space: nowrap;
}
@media (max-width: 1024px) {
.zp-two-col {
grid-template-columns: 1fr;
}
.zp-side-panel {
position: static;
}
}