Files
quiz/static/projector.css
ameer 9ea0a8b039 feat: anti-cheat + presence panel + projector view
Refinement pass on top of the validated v1.2 base.

Hardening / quick fixes
- Caddyfile.tpl: CSP / HSTS / X-Frame / Referrer-Policy / Permissions-Policy,
  1 MiB request body cap, X-Forwarded-For pass-through; stock-Caddy compatible.
- auth.py: STUDENT_MAX_AGE 1y -> 30d.
- bootstrap.sh: stage 5 prepends `git config --add safe.directory` so the
  re-bootstrap path no longer 'detected dubious ownership' faults.
- admin.js: drop the misleading `closedPayload?.state || session.state`
  shim; state derives from session only.

Anti-cheat
- New `student_events` audit table; new POST /api/session/{sid}/event for
  blur / visibility_hidden / focus / visibility_visible. quiz.js debounces
  events at 1.5s and uses sendBeacon for visibility_hidden so the event
  survives a navigation. Counts surface in admin presence + CSV export.
- First-claim-wins on join: add_participant raises DuplicateStudentId on
  PK violation; route returns 409 + records a duplicate_join audit event
  with attempted name + IP + UA. Admin dashboard surfaces a per-row red
  badge for hits on real participants and a top-of-page alert for orphan
  attempts.
- DELETE /admin/api/students/{id} as the recovery hatch: clears the
  participant + submissions, kicks active WS sockets so a stale cookie
  cannot continue submitting. quiz.js surfaces the FastAPI detail message
  in the join form so users see the 'already in use' guidance.

Presence panel
- New presence_update WS message; in-process presence map keyed on
  student_id tracks ws_count + last_seen_ms. Admin dashboard renders
  per-student rows: connected/idle/dropped dot, blur+hidden+duplicate
  badges, 'answered current Q' tick, and a clear-student button.

Projector view (public, read-only)
- /projector/?sid=..., GET /api/session/{sid}/projector, WS
  /ws/projector/{sid}. Single self-contained projector_state snapshot
  pushed on every state change. Public leaderboard strips student_id;
  QR rendered server-side as data: URL (CSP-compliant).
- Includes per-Q answer histogram, 8-bucket response-time distribution,
  10-bucket score distribution.
- static/projector.{html,css,js}: editorial-broadside design — masthead,
  registration crosses, conic-gradient countdown ring, SVG stepped-area
  score distribution with median tick, leaderboard row-stagger. Inherits
  light/dark tokens from style.css; honours prefers-reduced-motion. No
  scroll at 1366x768 / 1920x1080 / 3440x1440.

Tests
- tests/test_anti_cheat.py: blur logging + CSV count, unknown-kind 422,
  unauthenticated event 401, duplicate-join 409 + audit, admin
  clear-student happy + 404, server-side submit lockout regression.
- tests/test_projector.py: snapshot shape, leaderboard student_id
  redaction, WS push on state change, 404 for unknown sid, page redirect
  when no sid.
- Existing tests updated for the new presence_update snapshot frame +
  CSV header columns + first-claim-wins refusal of re-key.

57/57 pytest green; smoke-tested locally end-to-end.
2026-05-04 16:08:59 +08:00

1205 lines
31 KiB
CSS
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
* Projector view — front-of-room, single-screen, no-scroll.
*
* Aesthetic: an editorial broadside / front page hung at the
* front of a lecture hall. Shared style.css supplies the type
* system and color tokens; this file lays out the page like a
* printed gazette: ruled sections, folio numerals, marginalia,
* registration crosses, no chrome of any kind.
*
* Hard rules enforced here:
* - 100vh × 100vw, no scroll, at 1366×768 / 1920×1080 / 3440×1440
* - Question prose >= 3vw, options >= 1.5vw, leaderboard >= 1.4vw
* - All color via tokens (light & dark via prefers-color-scheme)
* - Honors prefers-reduced-motion (handled in style.css globally)
* ============================================================ */
/* ---------- Page shell ---------- */
.projector-body {
margin: 0;
padding: 0;
height: 100vh;
height: 100dvh;
width: 100vw;
overflow: hidden;
background:
/* faint diagonal weave that reads as paper grain on a projector */
repeating-linear-gradient(
135deg,
transparent 0 6px,
var(--bg-grain, rgba(31,29,24,0.018)) 6px 7px
),
radial-gradient(1400px 800px at 100% -10%, color-mix(in srgb, var(--primary) 6%, transparent) 0%, transparent 60%),
radial-gradient(1100px 600px at -10% 110%, color-mix(in srgb, var(--primary) 4%, transparent) 0%, transparent 55%),
var(--bg);
}
#projector-app {
height: 100vh;
height: 100dvh;
width: 100vw;
}
.projector-shell {
position: relative;
display: grid;
grid-template-rows: auto 1fr auto;
height: 100vh;
height: 100dvh;
padding: clamp(14px, 1.8vh, 24px) clamp(22px, 2.2vw, 38px) clamp(10px, 1.4vh, 18px);
gap: clamp(10px, 1.4vh, 18px);
box-sizing: border-box;
isolation: isolate;
}
/* Registration crosses in the four corners — the kind printers use for
* plate alignment. Pure decoration, hidden from a/y. */
.projector-shell::before,
.projector-shell::after {
content: "";
position: absolute;
width: 14px;
height: 14px;
pointer-events: none;
background:
linear-gradient(var(--rule), var(--rule)) center / 1px 100% no-repeat,
linear-gradient(var(--rule), var(--rule)) center / 100% 1px no-repeat;
opacity: 0.55;
}
.projector-shell::before { top: 6px; left: 6px; }
.projector-shell::after { bottom: 6px; right: 6px; }
/* The two opposite corners get separate elements so we don't need extra DOM */
.projector-shell > .reg-tr,
.projector-shell > .reg-bl {
position: absolute;
width: 14px;
height: 14px;
pointer-events: none;
background:
linear-gradient(var(--rule), var(--rule)) center / 1px 100% no-repeat,
linear-gradient(var(--rule), var(--rule)) center / 100% 1px no-repeat;
opacity: 0.55;
}
.projector-shell > .reg-tr { top: 6px; right: 6px; }
.projector-shell > .reg-bl { bottom: 6px; left: 6px; }
/* ---------- Topbar (masthead) ---------- */
.projector-topbar {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: end;
gap: clamp(16px, 2.4vw, 36px);
padding-bottom: clamp(8px, 1.2vh, 14px);
border-bottom: 1.5px solid var(--rule);
position: relative;
}
/* under-rule (double rule like a masthead) */
.projector-topbar::after {
content: "";
position: absolute;
left: 0; right: 0; bottom: -4px;
border-bottom: 1px solid var(--rule);
opacity: 0.45;
}
.topbar-left { display: grid; gap: 4px; justify-items: start; }
.topbar-mid { display: grid; gap: 4px; justify-items: center; text-align: center; }
.topbar-right { display: grid; gap: 4px; justify-items: end; text-align: right; }
.brand {
font-family: var(--font-sans);
font-size: clamp(0.62rem, 0.82vw, 0.78rem);
font-weight: 700;
letter-spacing: 0.34em;
text-transform: uppercase;
color: var(--muted-2);
display: inline-flex;
align-items: center;
gap: 10px;
}
.brand::before,
.brand::after {
content: "";
display: inline-block;
width: clamp(18px, 2vw, 28px);
height: 1px;
background: currentColor;
opacity: 0.55;
}
.topbar-title {
font-family: var(--font-display);
font-size: clamp(1.4rem, 2.2vw, 2.1rem);
font-weight: 600;
letter-spacing: -0.014em;
margin: 0;
color: var(--text);
line-height: 1.05;
font-feature-settings: "ss01";
}
.folio {
font-family: var(--font-mono);
font-size: clamp(0.68rem, 0.95vw, 0.92rem);
font-weight: 500;
font-variant-numeric: tabular-nums;
letter-spacing: 0.08em;
color: var(--muted);
text-transform: uppercase;
}
.folio b {
font-weight: 600;
color: var(--text);
letter-spacing: -0.01em;
}
/* Override the global state-badge sizing for projector scale */
.projector-shell .state-badge {
font-size: clamp(0.7rem, 0.9vw, 0.86rem);
padding: 8px 14px;
letter-spacing: 0.22em;
}
/* ---------- Main grid (one row, varies per state) ---------- */
.projector-grid {
display: grid;
gap: clamp(12px, 1.6vw, 24px);
height: 100%;
min-height: 0;
}
.projector-grid.lobby { grid-template-columns: minmax(380px, 0.9fr) 1.1fr; }
.projector-grid.question { grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr); }
.projector-grid.between,
.projector-grid.finished { grid-template-columns: 1.05fr 1fr; }
/* ---------- Card primitive ---------- */
.projector-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
box-shadow: var(--shadow);
padding: clamp(16px, 2vw, 28px);
display: grid;
align-content: start;
gap: clamp(8px, 1.2vh, 14px);
min-height: 0;
overflow: hidden;
position: relative;
}
/* Eyebrows / section labels — small caps with rule */
.card-eyebrow,
.lobby-eyebrow {
font-family: var(--font-sans);
font-size: clamp(0.66rem, 0.86vw, 0.8rem);
font-weight: 700;
letter-spacing: 0.26em;
text-transform: uppercase;
color: var(--muted);
margin: 0;
display: flex;
align-items: center;
gap: 12px;
}
.card-eyebrow::after {
content: "";
flex: 1;
height: 1px;
background: var(--border);
}
/* ============================================================
* STATE: lobby
* ============================================================ */
.join-card {
align-content: start;
justify-items: stretch;
gap: clamp(12px, 1.6vh, 18px);
grid-template-rows: auto 1fr auto;
}
.lobby-headline {
font-family: var(--font-display);
font-size: clamp(1.6rem, 2.4vw, 2.4rem);
font-weight: 600;
font-style: italic;
letter-spacing: -0.012em;
line-height: 1.1;
color: var(--text);
margin: 0;
}
.lobby-sub {
font-family: var(--font-sans);
font-size: clamp(0.85rem, 1.05vw, 1rem);
color: var(--muted);
margin: 0;
letter-spacing: 0.01em;
}
.qr-frame {
position: relative;
display: grid;
place-items: center;
width: 100%;
min-height: 0;
align-self: center;
}
.qr-big {
background: #ffffff;
padding: clamp(12px, 1.4vw, 18px);
border: 1px solid var(--border-strong);
border-radius: 2px;
width: clamp(280px, 28vw, 460px);
aspect-ratio: 1 / 1;
display: grid;
place-items: center;
box-shadow:
0 1px 0 rgba(20, 22, 28, 0.06),
0 24px 56px -28px rgba(20, 22, 28, 0.32);
position: relative;
}
.qr-big img {
width: 100%;
height: 100%;
display: block;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
/* corner braces */
.qr-frame::before,
.qr-frame::after {
content: "";
position: absolute;
width: 22px;
height: 22px;
border: 1.5px solid var(--text);
pointer-events: none;
}
.qr-frame::before { top: -10px; left: 50%; transform: translateX(calc(-50% - clamp(140px, 14vw, 230px))); border-right: 0; border-bottom: 0; }
.qr-frame::after { bottom: -10px; left: 50%; transform: translateX(calc(-50% + clamp(140px, 14vw, 230px))); border-left: 0; border-top: 0; }
.lobby-url {
font-family: var(--font-mono);
font-size: clamp(0.95rem, 1.25vw, 1.25rem);
font-weight: 500;
letter-spacing: 0.02em;
color: var(--text);
background: var(--surface-2);
padding: 10px 16px;
border: 1px solid var(--border);
border-radius: 2px;
word-break: break-all;
text-align: center;
font-variant-numeric: tabular-nums;
}
/* lobby-status (right column) */
.lobby-status {
align-content: stretch;
grid-template-rows: auto auto 1fr auto;
gap: clamp(10px, 1.4vh, 16px);
}
.participant-count {
display: grid;
grid-template-columns: auto 1fr;
align-items: end;
gap: clamp(12px, 1.6vw, 22px);
padding: clamp(8px, 1.2vh, 14px) 0;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
.participant-count b {
font-family: var(--font-mono);
font-size: clamp(4rem, 8.6vw, 8.4rem);
font-weight: 500;
font-variant-numeric: tabular-nums;
letter-spacing: -0.05em;
line-height: 0.92;
color: var(--text);
display: block;
transform-origin: left bottom;
}
.participant-count.bump b {
animation: count-bump 520ms cubic-bezier(0.22, 0.61, 0.36, 1);
}
@keyframes count-bump {
0% { transform: scale(1); color: var(--text); }
35% { transform: scale(1.08); color: var(--primary); }
100% { transform: scale(1); color: var(--text); }
}
.participant-count .label {
display: grid;
gap: 2px;
padding-bottom: clamp(8px, 1.2vh, 14px);
}
.participant-count .label .word {
font-family: var(--font-display);
font-style: italic;
font-size: clamp(1.1rem, 1.6vw, 1.7rem);
color: var(--text-soft);
line-height: 1.05;
}
.participant-count .label .meta {
font-family: var(--font-sans);
font-size: clamp(0.7rem, 0.9vw, 0.85rem);
color: var(--muted);
letter-spacing: 0.18em;
text-transform: uppercase;
font-weight: 600;
}
.constellation {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(clamp(11px, 1.1vw, 16px), 1fr));
gap: clamp(5px, 0.6vw, 9px);
align-content: start;
min-height: 0;
overflow: hidden;
align-self: stretch;
padding: 4px 0;
}
.constellation li {
width: 100%;
aspect-ratio: 1 / 1;
border-radius: 999px;
background: var(--primary);
opacity: 0.78;
animation: dot-in 0.5s cubic-bezier(0.22, 0.61, 0.36, 1) both;
animation-delay: var(--d, 0ms);
}
@keyframes dot-in {
from { opacity: 0; transform: scale(0.4); }
to { opacity: 0.78; transform: scale(1); }
}
.lobby-rule {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 12px;
font-family: var(--font-sans);
font-size: clamp(0.7rem, 0.92vw, 0.86rem);
letter-spacing: 0.22em;
text-transform: uppercase;
font-weight: 600;
color: var(--muted);
}
.lobby-rule::before,
.lobby-rule::after {
content: "";
display: block;
height: 1px;
background: var(--border);
}
.lobby-meta-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: clamp(8px, 1vw, 14px);
}
.lobby-meta-grid .cell {
display: grid;
gap: 2px;
padding: clamp(8px, 1vh, 12px) clamp(10px, 1vw, 14px);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 2px;
}
.lobby-meta-grid .cell .v {
font-family: var(--font-mono);
font-size: clamp(1.2rem, 1.7vw, 1.7rem);
font-weight: 500;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
color: var(--text);
line-height: 1.0;
}
.lobby-meta-grid .cell .k {
font-family: var(--font-sans);
font-size: clamp(0.62rem, 0.82vw, 0.74rem);
font-weight: 600;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--muted);
}
/* ============================================================
* STATE: question (open + closed/reveal)
* ============================================================ */
.question-card {
gap: clamp(10px, 1.4vh, 18px);
grid-template-rows: auto 1fr auto;
}
.question-head {
display: grid;
grid-template-columns: 1fr auto;
align-items: start;
gap: clamp(14px, 2vw, 28px);
}
.big-question {
font-family: var(--font-display);
/* Per spec: question prose >= ~3vw — go just above to be safe at 1366×768 */
font-size: clamp(1.7rem, 3vw, 3rem);
font-weight: 500;
line-height: 1.18;
letter-spacing: -0.014em;
margin: 0;
color: var(--text);
font-feature-settings: "ss01";
hyphens: auto;
}
/* Countdown ring — far more legible across a hall than a number alone.
* Uses CSS conic-gradient for the arc; text sits in the middle. */
.countdown-ring {
--size: clamp(90px, 8vw, 140px);
--stroke: clamp(6px, 0.7vw, 10px);
--pct: 100; /* 0..100 */
--hue: var(--primary);
width: var(--size);
height: var(--size);
position: relative;
flex-shrink: 0;
display: grid;
place-items: center;
background:
conic-gradient(var(--hue) calc(var(--pct) * 1%), var(--border) 0);
border-radius: 999px;
}
.countdown-ring::before {
content: "";
position: absolute;
inset: var(--stroke);
background: var(--surface);
border-radius: 999px;
}
.countdown-ring .num {
position: relative;
font-family: var(--font-mono);
font-size: clamp(1.7rem, 2.6vw, 2.6rem);
font-weight: 500;
font-variant-numeric: tabular-nums;
letter-spacing: -0.04em;
color: var(--text);
line-height: 1;
}
.countdown-ring.urgent {
--hue: var(--danger);
animation: ring-urgent 0.9s ease-in-out infinite;
}
.countdown-ring.urgent .num {
color: var(--danger);
}
@keyframes ring-urgent {
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--danger) 28%, transparent); }
50% { box-shadow: 0 0 0 8px color-mix(in srgb, var(--danger) 0%, transparent); }
}
.countdown-ring.spent { --hue: var(--muted); }
/* Options */
.big-options {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: clamp(7px, 0.95vh, 12px);
align-content: start;
}
.big-options li {
display: grid;
grid-template-columns: clamp(46px, 4.4vw, 72px) 1fr clamp(110px, 14vw, 200px) clamp(74px, 9vw, 110px);
gap: clamp(10px, 1.2vw, 18px);
align-items: center;
padding: clamp(10px, 1.4vh, 16px) clamp(14px, 1.6vw, 22px);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 2px;
position: relative;
transition:
border-color 280ms ease,
background 280ms ease,
opacity 280ms ease,
color 280ms ease;
}
.big-options li::before {
/* tiny "this is a row" tick on the left, like an editorial bullet */
content: "";
position: absolute;
left: -1px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 38%;
background: var(--border);
transition: background 280ms ease, height 280ms ease;
}
.big-options.revealed li.correct {
background: var(--correct-bg);
border-color: var(--correct-border);
border-width: 1.5px;
}
.big-options.revealed li.correct::before {
background: var(--correct-border);
height: 70%;
}
.big-options.revealed li.incorrect {
opacity: 0.45;
}
.big-options.revealed li.incorrect .opt-text {
text-decoration: line-through;
text-decoration-color: var(--muted-2);
text-decoration-thickness: 1px;
}
.opt-key {
font-family: var(--font-display);
font-weight: 600;
font-size: clamp(1.6rem, 2.4vw, 2.3rem);
color: var(--muted);
border-right: 1px solid var(--border);
padding-right: clamp(10px, 1.1vw, 16px);
letter-spacing: -0.01em;
text-align: center;
line-height: 1;
font-feature-settings: "ss01";
}
.big-options li.correct .opt-key { color: var(--correct-border); border-right-color: var(--correct-border); }
.opt-text {
font-family: var(--font-display);
/* Per spec: option text >= ~1.5vw */
font-size: clamp(1.05rem, 1.6vw, 1.6rem);
line-height: 1.28;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.opt-bar {
display: block;
height: clamp(10px, 1.5vh, 16px);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 0;
overflow: hidden;
position: relative;
}
/* tick marks at 25/50/75% — subtle, like graph paper */
.opt-bar::before {
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(to right, transparent 0 calc(25% - 0.5px), var(--border) calc(25% - 0.5px) calc(25% + 0.5px), transparent calc(25% + 0.5px)),
linear-gradient(to right, transparent 0 calc(50% - 0.5px), var(--border) calc(50% - 0.5px) calc(50% + 0.5px), transparent calc(50% + 0.5px)),
linear-gradient(to right, transparent 0 calc(75% - 0.5px), var(--border) calc(75% - 0.5px) calc(75% + 0.5px), transparent calc(75% + 0.5px));
opacity: 0.6;
pointer-events: none;
}
.opt-bar-fill {
display: block;
height: 100%;
background: var(--primary);
width: 0%;
transition: width 600ms cubic-bezier(0.22, 0.61, 0.36, 1), background 280ms ease;
position: relative;
z-index: 1;
}
.big-options li.correct .opt-bar-fill { background: var(--correct-border); }
.big-options.revealed li.incorrect .opt-bar-fill { background: var(--muted-2); }
.opt-count {
font-family: var(--font-mono);
font-size: clamp(1.15rem, 1.55vw, 1.55rem);
font-weight: 500;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
text-align: right;
color: var(--text);
display: grid;
gap: 0;
line-height: 1.05;
}
.opt-count small {
display: block;
font-size: 0.55em;
color: var(--muted);
font-weight: 500;
font-family: var(--font-sans);
letter-spacing: 0.05em;
}
/* Pre-question state: when histogram is empty, hide bars to keep layout clean */
.big-options.pre-vote .opt-bar,
.big-options.pre-vote .opt-count {
visibility: hidden;
}
.big-explanation {
font-family: var(--font-display);
font-style: italic;
font-size: clamp(1rem, 1.35vw, 1.3rem);
line-height: 1.5;
color: var(--text-soft);
border-left: 3px solid var(--correct-border);
padding: 6px 0 6px clamp(10px, 1.2vw, 18px);
margin: 0;
animation: fade-up 600ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
}
@keyframes fade-up {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
/* Submission progress strip at the bottom of the question card */
.submission-strip {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: clamp(10px, 1.2vw, 16px);
padding-top: clamp(8px, 1vh, 12px);
border-top: 1px solid var(--border);
}
.submission-strip .label {
font-family: var(--font-sans);
font-size: clamp(0.7rem, 0.9vw, 0.84rem);
font-weight: 600;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--muted);
}
.submission-strip .track {
height: clamp(6px, 0.8vh, 9px);
background: var(--surface-2);
border: 1px solid var(--border);
position: relative;
overflow: hidden;
}
.submission-strip .track .fill {
position: absolute;
inset: 0 auto 0 0;
width: var(--p, 0%);
background: var(--primary);
transition: width 600ms cubic-bezier(0.22, 0.61, 0.36, 1);
}
.submission-strip .nums {
font-family: var(--font-mono);
font-size: clamp(0.95rem, 1.2vw, 1.2rem);
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--text);
letter-spacing: -0.01em;
}
.submission-strip .nums small {
font-size: 0.66em;
color: var(--muted);
font-family: var(--font-sans);
letter-spacing: 0.06em;
margin-left: 4px;
}
/* ---------- Side card (response time + top-5) ---------- */
.side-card {
gap: clamp(8px, 1.2vh, 14px);
align-content: start;
grid-template-rows: auto auto auto 1fr;
}
.side-meta {
margin: 0;
text-align: right;
font-family: var(--font-mono);
font-size: clamp(0.78rem, 0.95vw, 0.95rem);
color: var(--muted);
font-variant-numeric: tabular-nums;
}
/* ============================================================
* Bar charts (response time, score histogram)
* ============================================================ */
.bar-chart {
--baseline: 1px;
display: grid;
grid-template-rows: 1fr auto auto;
align-items: end;
gap: 6px;
padding: 4px 0 0;
height: clamp(140px, 22vh, 240px);
position: relative;
}
.bar-chart.small { height: clamp(110px, 16vh, 180px); }
.bar-chart .bars {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
gap: clamp(4px, 0.6vw, 8px);
align-items: end;
position: relative;
height: 100%;
border-bottom: 1px solid var(--rule);
}
/* Faint horizontal gridlines at 25 / 50 / 75 / 100 % */
.bar-chart .bars::before {
content: "";
position: absolute;
inset: 0;
background-image: repeating-linear-gradient(
to top,
transparent 0 calc(25% - 0.5px),
var(--border) calc(25% - 0.5px) calc(25% + 0.5px),
transparent calc(25% + 0.5px) 25%
);
opacity: 0.4;
pointer-events: none;
}
.bar-chart .bars > * { z-index: 1; position: relative; }
.bar-cell {
display: grid;
grid-template-rows: 1fr;
height: 100%;
align-items: end;
text-align: center;
min-width: 0;
}
.bar-fill {
display: block;
background: var(--primary);
height: var(--h, 2%);
width: 100%;
border-radius: 1px 1px 0 0;
transition: height 600ms cubic-bezier(0.22, 0.61, 0.36, 1);
align-self: end;
position: relative;
}
.bar-fill::after {
/* thin top-cap, like a printed almanac bar */
content: "";
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: color-mix(in srgb, var(--text) 55%, var(--primary));
opacity: 0.85;
}
.bar-fill[data-empty="true"] { background: transparent; border-top: 1px dashed var(--border); }
.bar-fill[data-empty="true"]::after { display: none; }
.bar-num {
font-family: var(--font-mono);
font-size: clamp(0.7rem, 0.9vw, 0.92rem);
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--text);
line-height: 1;
}
.bar-label {
font-family: var(--font-mono);
font-size: clamp(0.58rem, 0.74vw, 0.74rem);
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: 0;
line-height: 1;
}
.bar-chart .nums,
.bar-chart .labels {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
gap: clamp(4px, 0.6vw, 8px);
text-align: center;
}
/* ============================================================
* Score-distribution area chart (between + finished)
* ============================================================ */
.area-chart {
position: relative;
width: 100%;
height: clamp(180px, 28vh, 320px);
display: grid;
grid-template-rows: 1fr auto;
gap: 4px;
}
.area-chart svg {
width: 100%;
height: 100%;
display: block;
overflow: visible;
}
.area-chart .grid-line {
stroke: var(--border);
stroke-width: 1;
vector-effect: non-scaling-stroke;
opacity: 0.55;
stroke-dasharray: 2 4;
}
.area-chart .axis {
stroke: var(--rule);
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.area-chart .area-fill {
fill: var(--primary);
fill-opacity: 0.14;
transition: d 700ms cubic-bezier(0.22, 0.61, 0.36, 1);
}
.area-chart .area-line {
fill: none;
stroke: var(--primary);
stroke-width: 3;
stroke-linejoin: round;
stroke-linecap: round;
transition: d 700ms cubic-bezier(0.22, 0.61, 0.36, 1);
/* tell the renderer not to scale the stroke when SVG is stretched
non-uniformly; the perceived width stays consistent */
vector-effect: non-scaling-stroke;
}
.area-chart .data-point {
fill: var(--surface);
stroke: var(--primary);
stroke-width: 2.5;
vector-effect: non-scaling-stroke;
transition: cx 700ms cubic-bezier(0.22, 0.61, 0.36, 1), cy 700ms cubic-bezier(0.22, 0.61, 0.36, 1);
}
.area-chart .data-label {
font-family: var(--font-mono);
font-size: 18px;
font-weight: 600;
font-variant-numeric: tabular-nums;
fill: var(--text);
text-anchor: middle;
dominant-baseline: hanging;
}
.area-chart .x-tick-label {
font-family: var(--font-mono);
font-size: 16px;
fill: var(--muted);
text-anchor: middle;
}
.area-chart .y-tick-label {
font-family: var(--font-mono);
font-size: 14px;
fill: var(--muted);
text-anchor: end;
dominant-baseline: middle;
}
.area-chart .axis-title {
font-family: var(--font-sans);
font-size: 14px;
font-weight: 600;
fill: var(--muted);
text-transform: uppercase;
letter-spacing: 0.18em;
}
.area-chart .median-line {
stroke: var(--text);
stroke-width: 1.5;
stroke-dasharray: 4 3;
opacity: 0.6;
vector-effect: non-scaling-stroke;
}
.area-chart .median-tag {
font-family: var(--font-mono);
font-size: 16px;
font-weight: 600;
fill: var(--text);
}
.chart-legend {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
font-family: var(--font-sans);
font-size: clamp(0.68rem, 0.86vw, 0.82rem);
color: var(--muted);
letter-spacing: 0.14em;
text-transform: uppercase;
font-weight: 600;
padding-top: 4px;
}
.chart-legend .stat {
font-family: var(--font-mono);
letter-spacing: -0.01em;
text-transform: none;
color: var(--text);
}
.chart-legend .stat b {
font-weight: 600;
}
/* ============================================================
* Big leaderboard
* ============================================================ */
.big-leaderboard {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: clamp(2px, 0.4vh, 6px);
align-content: start;
}
.big-leaderboard li {
display: grid;
grid-template-columns: clamp(40px, 3.4vw, 64px) 1fr auto;
align-items: baseline;
gap: clamp(10px, 1vw, 18px);
padding: clamp(7px, 0.95vh, 11px) clamp(10px, 1vw, 16px);
border-bottom: 1px solid var(--border);
border-left: 3px solid transparent;
position: relative;
animation: row-in 500ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
animation-delay: var(--d, 0ms);
}
.big-leaderboard li:last-child { border-bottom: 0; }
@keyframes row-in {
from { opacity: 0; transform: translateX(-8px); }
to { opacity: 1; transform: translateX(0); }
}
.big-leaderboard .rank {
font-family: var(--font-mono);
font-size: clamp(1.05rem, 1.4vw, 1.45rem);
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--muted);
letter-spacing: -0.02em;
text-align: right;
}
.big-leaderboard .name {
font-family: var(--font-display);
/* Per spec: leaderboard names >= 1.4vw */
font-size: clamp(1.05rem, 1.45vw, 1.55rem);
font-weight: 500;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-feature-settings: "ss01";
}
.big-leaderboard .score {
font-family: var(--font-mono);
font-size: clamp(1.05rem, 1.5vw, 1.5rem);
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--text);
letter-spacing: -0.02em;
}
.big-leaderboard li:nth-child(1) {
background: color-mix(in srgb, var(--medal-gold) 10%, var(--surface));
border-left-color: var(--medal-gold);
}
.big-leaderboard li:nth-child(2) {
background: color-mix(in srgb, var(--medal-silver) 10%, var(--surface));
border-left-color: var(--medal-silver);
}
.big-leaderboard li:nth-child(3) {
background: color-mix(in srgb, var(--medal-bronze) 10%, var(--surface));
border-left-color: var(--medal-bronze);
}
.big-leaderboard li:nth-child(1) .rank,
.big-leaderboard li:nth-child(1) .score { color: var(--medal-gold); }
.big-leaderboard li:nth-child(2) .rank,
.big-leaderboard li:nth-child(2) .score { color: var(--medal-silver); }
.big-leaderboard li:nth-child(3) .rank,
.big-leaderboard li:nth-child(3) .score { color: var(--medal-bronze); }
/* ============================================================
* Finished: hero banner
* ============================================================ */
.finished-banner {
display: grid;
align-content: center;
justify-items: center;
gap: clamp(8px, 1.2vh, 14px);
padding: clamp(16px, 2vh, 28px) clamp(20px, 2vw, 32px);
border: 1px solid var(--border);
background:
linear-gradient(180deg, color-mix(in srgb, var(--primary) 5%, transparent), transparent 60%),
var(--surface);
border-radius: 4px;
position: relative;
}
.finished-banner .kicker {
font-family: var(--font-sans);
font-size: clamp(0.7rem, 0.92vw, 0.86rem);
font-weight: 700;
letter-spacing: 0.32em;
text-transform: uppercase;
color: var(--primary);
}
.finished-banner h2 {
margin: 0;
font-family: var(--font-display);
font-style: italic;
font-size: clamp(2rem, 3.4vw, 3.4rem);
font-weight: 600;
letter-spacing: -0.018em;
color: var(--text);
line-height: 1.05;
border: 0;
padding: 0;
text-transform: none;
display: block;
}
.finished-banner .summary {
font-family: var(--font-display);
font-size: clamp(1rem, 1.3vw, 1.3rem);
color: var(--text-soft);
font-style: italic;
margin: 0;
text-align: center;
line-height: 1.4;
}
.finished-grid {
grid-template-rows: auto 1fr;
}
/* ============================================================
* Misc: error / empty states
* ============================================================ */
.empty-state {
display: grid;
place-items: center;
height: 100%;
text-align: center;
gap: 8px;
color: var(--muted);
}
.empty-state .glyph {
font-family: var(--font-display);
font-style: italic;
font-size: clamp(1.4rem, 2vw, 2rem);
color: var(--muted-2);
}
.empty-state p {
font-family: var(--font-sans);
font-size: clamp(0.78rem, 1vw, 0.95rem);
letter-spacing: 0.08em;
margin: 0;
}
.fatal-card {
align-content: center;
justify-items: center;
text-align: center;
gap: 12px;
height: 60vh;
align-self: center;
margin: auto;
}
/* ============================================================
* Footer (tickerline)
* ============================================================ */
.projector-foot {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: clamp(10px, 1.4vw, 18px);
padding-top: clamp(6px, 0.8vh, 10px);
border-top: 1px solid var(--rule);
font-family: var(--font-mono);
font-size: clamp(0.66rem, 0.84vw, 0.78rem);
font-variant-numeric: tabular-nums;
letter-spacing: 0.04em;
color: var(--muted);
}
.projector-foot .left,
.projector-foot .right {
display: inline-flex;
align-items: center;
gap: 10px;
}
.projector-foot .dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: var(--correct-border);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--correct-border) 18%, transparent);
}
.projector-foot .dot.dim { background: var(--muted-2); box-shadow: none; }
.projector-foot .center {
text-align: center;
font-family: var(--font-sans);
letter-spacing: 0.18em;
text-transform: uppercase;
font-weight: 600;
}
/* ============================================================
* Responsive — keep everything visible on 1366×768 and below
* ============================================================ */
@media (max-width: 1280px) {
.projector-grid.lobby { grid-template-columns: 0.95fr 1fr; }
.projector-grid.question { grid-template-columns: 1.5fr 1fr; }
}
@media (max-width: 1100px) {
.projector-grid.question { grid-template-columns: 1fr; grid-template-rows: 1fr auto; }
.side-card { display: none; }
.topbar-mid { display: none; }
}
@media (max-aspect-ratio: 5/4) {
/* Boxy aspect: stack lobby vertically */
.projector-grid.lobby { grid-template-columns: 1fr; grid-template-rows: 1fr auto; }
.qr-big { width: clamp(220px, 32vw, 380px); }
}
/* ============================================================
* Reduced motion — defer to global rule, but explicitly silence
* the ones we introduce here
* ============================================================ */
@media (prefers-reduced-motion: reduce) {
.countdown-ring.urgent { animation: none; }
.participant-count.bump b { animation: none; }
.constellation li { animation: none; opacity: 0.78; }
.big-leaderboard li { animation: none; }
.big-explanation { animation: none; }
}