style: refinement pass for admin + student SPAs

Targeted fixes on top of the editorial-lecture-hall pass:

- Leaderboard rank columns now align across all rows; medal stripes
  reserve their 3px width on every row (no more 6px shift between
  podium and chasers). Silver bumps to higher-contrast values in both
  light and dark modes.
- Student leaderboard gains a visible "you" highlight (blue stripe,
  blue name + score, small "you" eyebrow under the name). Matches by
  display name since the server's student-facing top5 doesn't include
  student_id.
- Lobby and Finished states share an editorial state-cta treatment:
  display-serif "Ready to start." / "That's a wrap." with a numeric
  cta-stats strip that anchors the right column on a projector.
- "02 PRE-FLIGHT" eyebrow continues the "01 JOIN" sequence on the
  side panel, giving the page a magazine-spread rhythm.
- Live distribution suppresses empty bars when zero submissions and
  shows a calm italic "Bars appear once the first answer comes in."
  line instead.
- Roster orders newest-first; the top three rows light their dot
  green and the freshest row gets a soft pulsing halo, so the
  operator sees the room filling up at a glance.
- Student reveal "Your pick" tag moves to a top-edge ribbon above the
  option text so it stops colliding with the count column on phones.
This commit is contained in:
ameer
2026-05-02 22:11:55 +08:00
parent 029d0dd399
commit b40f05220c
3 changed files with 196 additions and 42 deletions

View File

@@ -178,12 +178,16 @@ function renderJoinPanel() {
function renderRosterPanel() {
const r = store.roster || [];
// Newest-first so late joiners are visible at the top of the list. The
// first three are tagged so the CSS can warm their dot — gives the
// operator a quick "yes the room is live" cue without an explicit log.
const ordered = r.slice().reverse();
return `
<div class="card panel">
<h2>Joined <span class="count">${r.length}</span></h2>
${r.length
? `<ul class="roster">${r.map((p) =>
`<li><span class="dot"></span><span class="who"><b>${escapeText(p.name)}</b><small>${escapeText(p.student_id)}</small></span></li>`
${ordered.length
? `<ul class="roster">${ordered.map((p, i) =>
`<li class="${i < 3 ? "is-fresh" : ""}"><span class="dot"></span><span class="who"><b>${escapeText(p.name)}</b><small>${escapeText(p.student_id)}</small></span></li>`
).join("")}</ul>`
: `<p class="muted">No students have joined yet. Share the QR or URL.</p>`}
</div>
@@ -200,11 +204,18 @@ function renderStatePanel(state) {
function renderLobby() {
const total = store.session.pool_meta.question_count;
const joined = (store.roster || []).length;
return `
<div class="card panel">
<div class="card panel state-cta-card">
<div class="state-cta">
<h2>Ready to start</h2>
<p>When you start, question 1 of ${total} opens for everyone in the room. Late joiners can still join after a question opens; they get whatever time remains.</p>
<p class="cta-eyebrow"><span class="cta-num">02</span> Pre-flight</p>
<h2>Ready to start.</h2>
<p>When you start, question&nbsp;1 of&nbsp;${total} opens for everyone in the room. Late joiners can still hop in mid-question; they get whatever time remains on the clock.</p>
<div class="cta-stats">
<div class="cta-stat"><span class="muted">Joined</span><b>${joined}</b></div>
<div class="cta-stat"><span class="muted">Questions</span><b>${total}</b></div>
<div class="cta-stat"><span class="muted">Per question</span><b>${store.session.pool_meta.time_limit_default}<small>s</small></b></div>
</div>
<button class="btn primary big" data-action="next">Start quiz →</button>
</div>
</div>
@@ -240,15 +251,29 @@ function renderQuestionOpen() {
}
function renderLiveHistogram() {
if (!store.histogram) return `<p class="muted small">Waiting for first submission…</p>`;
if (!store.histogram) return `<p class="muted small">Awaiting the first submission…</p>`;
const h = store.histogram;
const submitted = store.submittedCount || 0;
const total = Math.max(1, store.totalCount || 0);
// While nobody has submitted yet, suppress the bar rows — empty bars
// read as broken rather than "no data". Show a calm awaiting line.
if (submitted === 0) {
return `
<div class="hist live">
<div class="hist-summary">
<span><b>0</b> submitted</span>
${store.totalCount ? `<span class="muted">of ${store.totalCount} joined</span>` : ""}
</div>
<p class="muted small hist-awaiting">Bars appear once the first answer comes in.</p>
</div>
`;
}
return `
<div class="hist live">
<div class="hist-summary">
<span><b>${store.submittedCount}</b> submitted</span>
<span><b>${submitted}</b> submitted</span>
${store.totalCount ? `<span class="muted">of ${store.totalCount} joined</span>` : ""}
${h.pending != null ? `<span class="muted">· ${h.pending} pending</span>` : ""}
${h.pending != null && h.pending > 0 ? `<span class="muted">${h.pending} pending</span>` : ""}
</div>
<div class="hist-rows">
${["A","B","C","D"].map((k) => {
@@ -326,10 +351,13 @@ function renderQuestionClosed() {
}
function renderFinished() {
const total = store.session.pool_meta.question_count;
return `
<div class="card panel">
<h2>Quiz finished</h2>
<p class="muted">${store.session.pool_meta.question_count} questions complete. Final leaderboard below.</p>
<div class="state-cta">
<h2>That's a wrap.</h2>
<p>${total} question${total === 1 ? "" : "s"} complete. Final standings below; download the CSV when you're ready to grade.</p>
</div>
<h3>Final leaderboard</h3>
${renderLeaderboardList(store.leaderboard)}
<div class="action-row">