feat: the objective is enforced, folders are made where you are, and the

landing page describes this product

An objective is now required to build a session, not only asked for in
the interface — the interface asks, and this is the same rule where it
cannot be walked past. Only where there is something to choose: a
deployment with no exams, and the first administrator of a fresh one,
must still be able to build a session. A rule that locks an empty site is
not a rule, it is a fault.

Saving a question into a folder is one box that searches what you have
and offers to make what you do not. It used to say "make one in the
question bank" and leave you to go and do it, which means leaving the
question you were reading and coming back to find your place. A name
that already exists exactly is not offered twice; a partial match offers
both, because wanting a narrower folder called "cardio" is not the same
as wanting the one called "Cardiology misses".

The landing page is rebuilt. Its copy described a product from months ago
— "upload a PDF, AI extracts questions", which is one feature of many now
— and it was 568 lines of inline style objects, which cannot express a
hover, a media query or a keyframe. The figures come from
/api/public/stats and count up; a failed fetch renders the section
without them rather than showing noughts, which would be a lie about an
empty bank. Motion is CSS and SVG, and prefers-reduced-motion turns all
of it off — including forcing the scroll-revealed elements visible,
since a hidden element with its animation removed is how respecting that
setting turns into a blank page.

Two smaller ones from the screenshots: the collections shelf is boxed
rather than scrolling past everything else on the page, and its rows no
longer carry the entire stem — lab tables and all — in a native tooltip
that covered half the screen and could not be dismissed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-12 06:50:10 +02:00
parent 77c09db057
commit 2b5262d255
11 changed files with 1173 additions and 321 deletions

View file

@ -356,7 +356,31 @@ def adaptive_select(db, user, count, category_ids, state, difficulty):
return selected
def require_objective(db, user) -> None:
"""Refuse to build a session for somebody who has not said what they study.
The objective decides which questions exist, how relevance is weighted and
what readiness is measured against. No objective quietly means the whole
bank a reasonable default, and a poor thing to arrive at by accident,
which is what was happening: the interface asks now, and this is the same
rule where it cannot be walked past.
Only where there is something to choose. A deployment with no exams
configured, and the first administrator of a fresh one, must still be able
to build a session; a rule that locks an empty site is not a rule, it is a
fault.
"""
if getattr(user, "active_exam_id", None):
return
from app.models.exam import Exam
if not db.query(Exam.id).filter(Exam.is_active == 1).first():
return
raise HTTPException(400, "Choose what you are studying for before building a session")
def generate_test(db, user, data):
require_objective(db, user)
if data.algorithm == "blueprint":
return _blueprint_test(db, user, data)
if data.algorithm == "adaptive":

View file

@ -453,3 +453,43 @@ class ExamClockTests(unittest.TestCase):
time_limit_minutes = 15
self.assertEqual(exam_minutes(Ask(), 40), 15)
class ObjectiveRequiredTests(unittest.TestCase):
"""A session cannot be built by somebody who has not said what they study.
The objective decides which questions exist, how relevance is weighted and
what readiness measures against. The interface asks; this is the same rule
where it cannot be walked past.
"""
def setUp(self):
self.bank = BuilderTests()
self.bank.setUp()
self.client = self.bank.client
self.db = self.bank.db
def tearDown(self):
self.bank.tearDown()
def offer(self):
from app.models.exam import Exam
self.db.add(Exam(id=1, slug='pediatrics-boards', name='Pediatrics Boards', is_active=1))
self.db.commit()
def test_with_an_objective_on_offer_and_none_chosen_it_refuses(self):
self.offer()
response = self.bank.generate(is_shared=True, category_ids=[1])
self.assertEqual(response.status_code, 400, response.text)
self.assertIn('studying for', response.json()['detail'])
def test_choosing_one_lets_it_through(self):
self.offer()
self.bank.owner.active_exam_id = 1
self.db.commit()
self.assertEqual(self.bank.generate(is_shared=True, category_ids=[1]).status_code, 200)
def test_a_site_with_no_objectives_is_not_locked(self):
# A rule that locks an empty deployment is not a rule, it is a fault:
# the first administrator has nothing to choose from yet.
self.assertEqual(self.bank.generate(is_shared=True, category_ids=[1]).status_code, 200)

View file

@ -332,12 +332,12 @@ Analysis**, which has three tabs.
### Recommendations
- [ ] **Your knowledge profile** — topics ranked by score, lowest marked
- [x] **Your knowledge profile** — topics ranked by score, lowest marked
**FOCUS AREA**, under three tabs: **Articles, Systems, Disciplines**.
Columns: Topic, score, **Relevance**, Status, Action. A row expands to
questions completed with a bar, answered-correctly with a
correct/hints/incorrect bar, and a Start Qbank button for that topic.
- [ ] **Relevance is the ABP content specification weight.** This is the part
correct/hints/incorrect bar, and a Start Qbank button for that topic. — done 2026-09-12 — paginated, with the three axes and expandable rows.
- [x] **Relevance is the ABP content specification weight.** This is the part
we can do properly and AMBOSS cannot explain: a topic's relevance is the
share of the real paper its domain accounts for, which
`exam_blueprints.weight` already holds. Cardiology at 5% and
@ -345,7 +345,7 @@ Analysis**, which has three tabs.
say so from the board's own numbers rather than from a guess.
### Session Analysis
— done — exam_blueprints.weight drives the Relevance column and now adaptive selection too.
- [x] **A rail of latest sessions** — done. `AnalysisShell` owns the rail and
the session list for both views. The session's recommendations now carry
the Articles / Disciplines / Systems switch too, from
@ -368,13 +368,13 @@ Analysis**, which has three tabs.
The whole pipeline from a PDF to a question in the bank lives on one page,
and nothing reaches the bank until an administrator has read it.
- [ ] **Move the PDFs into Tools.** Upload, the document list and extraction
- [x] **Move the PDFs into Tools.** Upload, the document list and extraction
all move off their own pages and onto the Tools page, because they are
one job and were three places.
- [ ] **Nextcloud is the administrator's alone.** Nobody else connects an
one job and were three places. — done — upload, the document list and extraction all live on /tools.
- [x] **Nextcloud is the administrator's alone.** Nobody else connects an
account; it is an import path for whoever loads the corpus, not a
per-learner integration. It moves out of everyone's Settings.
- [ ] **Review and promote, on the same page.** A run lands as a batch of
per-learner integration. It moves out of everyone's Settings. — done.
- [x] **Review and promote, on the same page.** A run lands as a batch of
drafts — already built, `draft_batches` / `draft_questions`, with their
own sequence so nothing takes a question id early. What is missing is
the screen: read them, fix them, reject the rubbish, then select the
@ -382,7 +382,7 @@ and nothing reaches the bank until an administrator has read it.
moment a `Question` is created.
## Collections, as shown 2026-09-11 (evening)
— done — a batch is read and accepted in place, and the question id is taken only on promotion.
- [x] **A collections page** — done 2026-09-12. `/collections`, in the section
bar beside Qbank. Card and Table views (the choice is remembered), sort
by last used / created / name / size with a direction control, a count
@ -513,25 +513,25 @@ needs are `conversations` and `messages`.
The reference is UWorld-style for the exam player and AMBOSS for analysis.
### Exam player chrome
- [ ] **Top bar**: `Item: n of m` / `Block: 1 of 1` at the left, Previous / n of m
- [x] **Top bar** — done 2026-09-12.: `Item: n of m` / `Block: 1 of 1` at the left, Previous / n of m
/ Next in the middle, Lab Values · Notes · Calculator · Settings at the
right.
- [ ] **Bottom bar**: `Block Time Remaining: 00:01:29` at the left, Pause and
- [x] **Bottom bar** — done 2026-09-12.: `Block Time Remaining: 00:01:29` at the left, Pause and
Lock in the middle, End Block at the right.
- [ ] **One button, not two.** A question with no answer offers Skip *and*
- [x] **One button, not two.** — done 2026-09-12. A question with no answer offers Skip *and*
Next today. It is one boxed control: one button.
- [ ] **Proceed to Next Item on the last question ends the block** rather than
- [x] **Proceed to Next Item on the last question ends the block** — done 2026-09-12 — End Block replaced three controls under two names. rather than
doing nothing.
- [ ] **Cross out an option** from a per-option control beside it (the `ab`
- [x] **Cross out an option** — already there — the `ab` control per option. from a per-option control beside it (the `ab`
strike icon), not only from a menu.
- [ ] **Pause says "Exam Paused" and nothing else** — a title and a Return to
- [x] **Pause says "Exam Paused" and nothing else** done 2026-09-12. — a title and a Return to
exam button. No warning about real exams; that is AMBOSS's disclaimer,
not ours.
- [ ] **End Session is a plain confirmation** — "Are you sure you want to end
- [x] **End Session is a plain confirmation** done 2026-09-12. — "Are you sure you want to end
this session?", End Session / Cancel.
- [ ] **Right-click removes a highlight**, and the yellow itself needs fixing.
- [ ] **The rail shows numbers while sitting an exam and shortened stems in
review.** It is numbers in both today.
- [x] **Right-click removes a highlight** — done 2026-09-12 — the yellow is a solid band now, not a gradient stripe., and the yellow itself needs fixing.
- [x] **The rail shows numbers while sitting an exam and shortened stems in
review.** — done 2026-09-12. It is numbers in both today.
### Player review mode — specified 12 Sep from a finished exam
Reviewing a block that has ended looks like study mode, **whether or not
@ -542,20 +542,20 @@ other option struck red with the peer percentage beside it, KEY INFO /
ATTENDING TIP / LABS, SHOW ALL EXPLANATIONS and HIDE STATS along the foot, and
PREVIOUS / SKIP to move between them.
- [ ] **"Finished" means the attempt is closed, not that every question was
- [x] **"Finished" means the attempt is closed — done 2026-09-12., not that every question was
answered.** The flag in QuizPage is `answeredCount >= totalCount`, which
is wrong for exactly this case: a block that timed out with nothing
answered is over, and it still shows numbers and hides explanations.
- [ ] **The player opens a completed attempt in review** rather than sending
- [x] **The player opens a completed attempt in review** — done 2026-09-12. rather than sending
people to `/results/:id`, which makes that page redundant.
### Analysis
- [ ] **Session Analysis is the third tab**, inside the Analysis page with the
- [x] **Session Analysis is the third tab** — done 2026-09-12., inside the Analysis page with the
session rail beside it — not a link away. Four figures, the donut, and
Study recommendations under Articles / Disciplines / Systems.
- [ ] **An unfinished session shows Resume**, with the figures reading `--`
- [x] **An unfinished session shows Resume** — done 2026-09-12., with the figures reading `--`
rather than a blank card.
- [ ] **Knowledge profile paginates** (`1 10 of 20`) and each row expands to
- [x] **Knowledge profile paginates** — done 2026-09-12. (`1 10 of 20`) and each row expands to
two bars — questions completed, answered correctly split
correct / correct-using-hints / incorrect — with Read article and Start
Qbank beside them.
@ -563,7 +563,7 @@ PREVIOUS / SKIP to move between them.
### Elsewhere
- [x] **Cap replaces Turnstile**, everywhere Turnstile was wired — one
shared `Captcha` component, one `captcha` service, keys blank until issued.
- [ ] **The session rail scrolls** once it holds more than a screenful.
- [x] **The session rail scrolls** — already worked — measured in headless Chrome at three viewports rather than assumed. once it holds more than a screenful.
## Done this session

View file

@ -93,7 +93,13 @@
/* What is on one shelf
Opened in place: there is no browsable question list to send anyone to,
and a link that goes nowhere is worse than no link. */
.col-shelf { list-style: none; margin: 12px 0 0; padding: 10px 0 0; border-top: 1px solid var(--border); }
.col-shelf {
list-style: none; margin: 12px 0 0; padding: 10px 0 0;
border-top: 1px solid var(--border);
/* Boxed. Sixty-seven saved questions inside a card is a page that scrolls
past everything else on it before it ends. */
max-height: 320px; overflow-y: auto; overscroll-behavior: contain;
}
.col-shelf li { display: flex; align-items: flex-start; gap: 8px; padding: 5px 0; font-size: 0.82rem; }
.col-shelf-stem {
flex: 1; min-width: 0; color: var(--text-muted); line-height: 1.5;

View file

@ -26,9 +26,11 @@ function Shelf({ rows, onDrop }) {
<ul className="col-shelf">
{rows.map(question => (
<li key={question.id}>
<span className="col-shelf-stem" title={question.question_text}>
{question.question_text}
</span>
{/* No title attribute. It carried the whole stem lab tables and
all into a native tooltip that covered half the page and could
not be dismissed or scrolled. Two lines is the excerpt; the
question itself is a session away. */}
<span className="col-shelf-stem">{question.question_text}</span>
<button type="button" className="col-shelf-drop" aria-label={`Take question ${question.id} out`}
onClick={() => onDrop(question.id)}></button>
</li>

View file

@ -0,0 +1,347 @@
/* The signed-out splash. Everything here used to be an inline style object,
which is why the page could not hover, could not respond to a narrow
window, and could not move: none of those three can be expressed in the
`style` attribute at all.
Colours are theme tokens without exception. The site ships a cool theme and
a warm one, and a literal hex here would look deliberate in one of them and
broken in the other. The only literals are white and black at low alpha,
used exclusively over --navbar-bg, which is dark in both themes. */
.lp-page { min-height: 100dvh; background: var(--bg); color: var(--text); }
.lp-page .navbar { margin-bottom: 0; }
/* Sections alternate between the page ground and the card surface so the eye
has somewhere to rest between them, rather than one unbroken column. */
.lp-section { padding: 88px 24px; }
.lp-section-raised { background: var(--card-bg); border-block: 1px solid var(--border); }
.lp-section-dark { background: var(--navbar-bg); color: var(--navbar-fg); }
.lp-section-dark + .lp-section-dark { border-top: 1px solid rgba(255,255,255,0.08); }
.lp-inner { max-width: 1080px; margin: 0 auto; }
.lp-inner-narrow { max-width: 720px; margin: 0 auto; }
.lp-eyebrow {
display: inline-block; margin-bottom: 20px; padding: 4px 13px;
border: 1px solid var(--primary); border-radius: 999px;
font-size: 0.72rem; font-weight: 700; letter-spacing: 0.09em;
text-transform: uppercase; color: var(--primary);
}
.lp-section-dark .lp-eyebrow { border-color: rgba(255,255,255,0.28); color: inherit; opacity: 0.8; }
.lp-head { text-align: center; margin-bottom: 48px; }
.lp-head h2 { font-size: clamp(1.5rem, 3vw, 2.1rem); font-weight: 800; letter-spacing: -0.025em; margin: 0 0 10px; }
.lp-head p { margin: 0 auto; max-width: 560px; color: var(--text-muted); font-size: 1rem; line-height: 1.65; }
.lp-section-dark .lp-head p { color: rgba(255,255,255,0.66); }
/* ── Hero ─────────────────────────────────────────────────────────── */
.lp-hero { position: relative; overflow: hidden; padding: 104px 24px 92px; text-align: center; }
/* Two soft blooms of the theme's own accent, drifting slowly behind the
text. Tinting with --primary rather than a fixed blue is what keeps the
warm theme warm; the blur is what keeps it from reading as a shape. */
.lp-hero::before,
.lp-hero::after {
content: ''; position: absolute; z-index: 0; border-radius: 50%;
background: var(--primary); opacity: 0.13; filter: blur(90px);
width: 460px; height: 460px; pointer-events: none;
}
.lp-hero::before { top: -180px; left: -120px; animation: lp-drift 22s ease-in-out infinite alternate; }
.lp-hero::after { bottom: -220px; right: -140px; animation: lp-drift 26s ease-in-out infinite alternate-reverse; }
@keyframes lp-drift {
from { transform: translate3d(0, 0, 0) scale(1); }
to { transform: translate3d(70px, 40px, 0) scale(1.18); }
}
.lp-hero-inner { position: relative; z-index: 1; max-width: 760px; margin: 0 auto; }
.lp-hero h1 {
margin: 0 0 20px; font-size: clamp(2.1rem, 5.4vw, 3.4rem);
font-weight: 800; line-height: 1.12; letter-spacing: -0.035em;
}
/* The second clause carries the accent. Both stops are theme tokens, so the
sweep is legible on the warm ground and the cool one. */
.lp-hero h1 em {
font-style: normal;
background: linear-gradient(100deg, var(--primary), var(--text), var(--primary));
background-size: 220% 100%;
-webkit-background-clip: text; background-clip: text;
-webkit-text-fill-color: transparent; color: transparent;
animation: lp-sweep 9s linear infinite;
}
@keyframes lp-sweep {
from { background-position: 0% 50%; }
to { background-position: -220% 50%; }
}
.lp-lead {
margin: 0 auto 26px; max-width: 620px;
font-size: 1.08rem; line-height: 1.7; color: var(--text-muted);
}
/* The one line on the page that moves of its own accord. It cycles the four
rules the adaptive picker actually follows, so the motion is carrying
information rather than decorating the absence of any. */
.lp-rotator {
display: flex; justify-content: center; align-items: baseline;
gap: 8px; flex-wrap: wrap; margin: 0 0 32px;
font-size: 0.95rem; color: var(--text-muted);
}
.lp-rotator-label { font-weight: 600; color: var(--text); }
.lp-rotator-word {
display: inline-block; padding: 3px 12px; border-radius: 999px;
background: var(--option-sel-bg); border: 1px solid var(--option-sel-bd);
color: var(--text); font-weight: 600;
animation: lp-swap 0.45s ease both;
}
@keyframes lp-swap {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: none; }
}
.lp-cta { display: flex; justify-content: center; gap: 12px; flex-wrap: wrap; }
.lp-cta .btn { padding: 12px 26px; font-size: 1rem; border-radius: 10px; text-decoration: none; }
.lp-cta-ghost {
background: transparent; color: var(--text);
border: 1px solid var(--border);
transition: border-color 0.18s, transform 0.18s;
}
.lp-cta-ghost:hover { border-color: var(--primary); transform: translateY(-1px); }
/* ── Figures ──────────────────────────────────────────────────────── */
.lp-figures {
list-style: none; margin: 0; padding: 0;
display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 14px;
}
.lp-figure {
background: var(--card-bg); border: 1px solid var(--border);
border-radius: var(--card-radius, 12px); padding: 20px 18px; text-align: center;
}
.lp-section-raised .lp-figure { background: var(--bg); }
.lp-figure-value {
display: block; font-size: clamp(1.7rem, 4vw, 2.3rem);
font-weight: 800; line-height: 1.1; letter-spacing: -0.03em;
font-variant-numeric: tabular-nums;
}
.lp-figure-label {
display: block; margin-top: 6px; font-size: 0.73rem; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.07em; color: var(--text-muted);
}
.lp-figures-missing {
text-align: center; color: var(--text-muted); font-size: 0.92rem;
border: 1px dashed var(--border); border-radius: var(--card-radius, 12px); padding: 20px;
}
/* ── Feature cards ────────────────────────────────────────────────── */
.lp-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr)); gap: 18px; }
.lp-card {
background: var(--card-bg); border: 1px solid var(--border);
border-radius: var(--card-radius, 12px); padding: 24px;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.lp-section-raised .lp-card { background: var(--bg); }
.lp-card:hover { transform: translateY(-4px); border-color: var(--primary); box-shadow: var(--card-shadow); }
.lp-card h3 { margin: 0 0 8px; font-size: 1.02rem; font-weight: 700; }
.lp-card p { margin: 0; color: var(--text-muted); font-size: 0.9rem; line-height: 1.7; }
.lp-card-mark {
display: flex; align-items: center; justify-content: center;
width: 38px; height: 38px; margin-bottom: 14px;
border-radius: 10px; background: var(--option-sel-bg);
border: 1px solid var(--option-sel-bd); color: var(--primary);
}
.lp-card-mark svg { width: 20px; height: 20px; }
.lp-card ul { margin: 12px 0 0; padding: 0; list-style: none; }
.lp-card li {
position: relative; padding-left: 16px; margin-top: 6px;
color: var(--text-muted); font-size: 0.86rem; line-height: 1.6;
}
.lp-card li::before {
content: ''; position: absolute; left: 0; top: 0.62em;
width: 5px; height: 5px; border-radius: 50%; background: var(--primary);
}
/* ── The dark honesty band ────────────────────────────────────────── */
.lp-claims { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); gap: 18px; }
.lp-claim {
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1);
border-radius: 12px; padding: 20px; transition: background 0.2s ease, transform 0.2s ease;
}
.lp-claim:hover { background: rgba(255,255,255,0.09); transform: translateY(-3px); }
.lp-claim h3 { margin: 0 0 8px; font-size: 0.98rem; font-weight: 700; }
.lp-claim p { margin: 0; font-size: 0.87rem; line-height: 1.7; color: rgba(255,255,255,0.66); }
/* ── Clinical tools ───────────────────────────────────────────────── */
.lp-split {
display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
gap: 40px; align-items: center;
}
.lp-split h2 { font-size: clamp(1.5rem, 3vw, 2rem); font-weight: 800; letter-spacing: -0.025em; margin: 0 0 14px; }
.lp-split a.btn { text-decoration: none; }
.lp-split p { margin: 0 0 22px; font-size: 0.94rem; line-height: 1.75; color: rgba(255,255,255,0.66); }
.lp-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 140px), 1fr)); gap: 10px; }
.lp-tile {
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1);
border-radius: 10px; padding: 14px; transition: background 0.2s ease, transform 0.2s ease;
}
.lp-tile:hover { background: rgba(255,255,255,0.1); transform: translateY(-2px); }
.lp-tile-label { font-weight: 700; font-size: 0.85rem; }
.lp-tile-sub { font-size: 0.75rem; color: rgba(255,255,255,0.5); }
/* ── Contact ──────────────────────────────────────────────────────── */
.lp-form { display: flex; flex-direction: column; gap: 14px; }
.lp-form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr)); gap: 12px; }
.lp-field label { display: block; margin-bottom: 5px; font-size: 0.82rem; font-weight: 600; color: var(--text-muted); }
.lp-field input,
.lp-field textarea {
width: 100%; padding: 9px 12px; box-sizing: border-box;
border: 1px solid var(--border); border-radius: 8px;
background: var(--input-bg); color: var(--text);
font-size: 0.9rem; font-family: inherit;
transition: border-color 0.15s ease;
}
.lp-field textarea { resize: vertical; }
.lp-message { margin-top: 10px; }
.lp-field input:focus,
.lp-field textarea:focus { outline: none; border-color: var(--primary); }
.lp-choice { display: flex; gap: 10px; }
.lp-choice button {
flex: 1; padding: 8px 12px; border-radius: 8px; cursor: pointer;
font-size: 0.85rem; background: var(--bg); color: var(--text);
border: 1px solid var(--border); transition: background 0.15s ease, border-color 0.15s ease;
}
.lp-choice button:hover { border-color: var(--primary); }
.lp-choice button[aria-pressed='true'] {
background: var(--primary); color: var(--primary-fg);
border-color: var(--primary); font-weight: 700;
}
.lp-hint { margin-top: 6px; font-size: 0.78rem; color: var(--text-muted); }
.lp-error { padding: 8px 12px; border-radius: 6px; background: var(--wrong-bg); color: var(--wrong-fg); font-size: 0.85rem; }
.lp-sent { text-align: center; padding: 32px 0; }
.lp-sent h3 { margin: 0 0 8px; font-size: 1.2rem; }
.lp-sent p { margin: 0; color: var(--text-muted); font-size: 0.9rem; }
.lp-sent-mark { font-size: 2.5rem; margin-bottom: 12px; }
.lp-card-lg {
background: var(--card-bg); border: 1px solid var(--border);
border-radius: var(--card-radius, 12px); padding: 30px;
}
/* ── Auth modal ───────────────────────────────────────────────────── */
.lp-modal-backdrop {
position: fixed; inset: 0; z-index: 1000; padding: 16px;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.55);
animation: lp-fade 0.18s ease both;
}
.lp-modal {
position: relative; width: 100%; max-width: 400px; padding: 28px 24px;
background: var(--card-bg); border-radius: var(--card-radius, 14px);
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
animation: lp-rise 0.22s ease both;
}
@keyframes lp-fade { from { opacity: 0; } to { opacity: 1; } }
@keyframes lp-rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
.lp-modal-close {
position: absolute; top: 12px; right: 14px;
background: none; border: none; cursor: pointer;
color: var(--text-muted); font-size: 1.2rem;
}
.lp-tabs { display: flex; margin-bottom: 20px; border-bottom: 2px solid var(--border); }
.lp-tabs button {
flex: 1; padding: 10px 0; margin-bottom: -2px;
background: none; border: none; border-bottom: 2px solid transparent;
cursor: pointer; font-size: 0.95rem; color: var(--text-muted);
}
.lp-tabs button[aria-pressed='true'] { font-weight: 700; color: var(--primary); border-bottom-color: var(--primary); }
.lp-modal-note { text-align: center; padding: 12px 0; }
.lp-modal-note h3 { margin: 0 0 8px; }
.lp-modal-note p { margin: 0 0 16px; font-size: 0.875rem; color: var(--text-muted); }
.lp-modal-mark { font-size: 2.5rem; margin-bottom: 12px; }
.lp-unverified {
margin-bottom: 14px; padding: 12px 14px; border-radius: 8px;
background: var(--expl-bg); border: 1px solid var(--expl-bd);
}
.lp-unverified p { margin: 0 0 8px; font-size: 0.85rem; font-weight: 500; color: var(--text); }
.lp-modal .btn-block { width: 100%; }
.lp-modal .alert { margin-bottom: 14px; }
.lp-forgot { margin-top: 12px; text-align: center; font-size: 0.82rem; }
.lp-forgot a { color: var(--text-muted); }
.lp-mismatch { color: var(--wrong-fg); font-size: 0.8rem; }
.lp-invite { text-transform: uppercase; letter-spacing: 0.08em; }
/* ── Footer ───────────────────────────────────────────────────────── */
.lp-footer { background: var(--navbar-bg); color: var(--navbar-fg); padding: 30px 24px; }
.lp-footer-inner {
max-width: 1080px; margin: 0 auto;
display: flex; justify-content: space-between; align-items: center;
flex-wrap: wrap; gap: 16px;
}
.lp-footer-brand { font-weight: 700; font-size: 0.9rem; opacity: 0.62; }
.lp-footer-brand span { font-weight: 400; margin-left: 14px; }
.lp-footer-links { display: flex; gap: 22px; font-size: 0.85rem; }
.lp-footer-links a,
.lp-footer-links button {
background: none; border: none; padding: 0; cursor: pointer;
font: inherit; text-decoration: none;
color: rgba(255,255,255,0.5); transition: color 0.15s ease;
}
.lp-footer-links a:hover,
.lp-footer-links button:hover { color: rgba(255,255,255,0.85); }
/* ── Scroll reveal ────────────────────────────────────────────────── */
/* Applied by the observer in the page component. The starting state lives
here rather than in a style attribute so that the reduced-motion block
below can cancel it a hidden element with no animation to un-hide it is
how "respecting the setting" turns into a blank page. */
.lp-reveal { opacity: 0; transform: translateY(18px); transition: opacity 0.55s ease, transform 0.55s ease; }
.lp-reveal.is-in { opacity: 1; transform: none; }
/* A card both arrives on scroll and lifts under the cursor, which means one
element with two reasons to transition and one `transition` property to say
so in. Spelled out together here, rather than letting whichever rule comes
last silently discard the other's. */
.lp-reveal.lp-card,
.lp-reveal.lp-claim {
transition: opacity 0.5s ease, transform 0.35s ease,
box-shadow 0.2s ease, border-color 0.2s ease, background 0.2s ease;
}
/* Read by a screen reader, never seen. The rotating line is decoration for
anyone who can watch it change; this is the same four rules as one
sentence, for anyone who cannot. */
.lp-sr {
position: absolute; width: 1px; height: 1px; overflow: hidden;
clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap;
}
/* ── Reduced motion ───────────────────────────────────────────────── */
/* Everything above that moves, stopped and the two states that motion was
meant to travel between resolved to the visible one. The rotating line
stops cycling in the component itself, because a timer is not a CSS
animation and this block cannot reach it. */
@media (prefers-reduced-motion: reduce) {
.lp-page *,
.lp-page *::before,
.lp-page *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
.lp-reveal { opacity: 1 !important; transform: none !important; }
.lp-hero h1 em { animation: none !important; }
}
@media (max-width: 640px) {
.lp-section { padding: 60px 20px; }
.lp-hero { padding: 72px 20px 64px; }
.lp-cta .btn { width: 100%; }
}

View file

@ -1,49 +1,290 @@
import { useState, useEffect } from 'react'
import { useEffect, useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import api from '../api/client'
import { useAuth } from '../context/AuthContext'
import useMediaQuery from '../hooks/useMediaQuery'
import Navbar from '../components/Navbar'
import Captcha, { captchaSiteKey } from '../components/Captcha'
import './LandingPage.css'
/**
* The page a stranger lands on.
*
* It describes the product as it is now: a question bank you sit as sessions,
* an analysis that says where you stand, and an assistant that is only allowed
* to answer out of your own library. The previous copy described the one
* feature the site launched with extracting questions out of a PDF which
* had stopped being the point some months ago.
*
* Nothing here is decorative motion for its own sake. The line under the
* heading cycles because it has four things to say and room for one; the
* figures count because they are real and worth looking at; the cards arrive
* on scroll because a page this long otherwise lands all at once. Every one of
* those stops dead under `prefers-reduced-motion`, and nothing on the page
* needs movement to be read.
*/
// The four rules of the adaptive picker, in the order it applies them. Kept
// deliberately close to the wording in docs/adaptive-sessions.md: a landing
// page that promises something subtly different from what the algorithm does
// is a landing page that will be wrong the first time anybody checks.
const ADAPTIVE_STEPS = [
'what you have never seen',
'then your weakest topic',
'then what you got wrong, oldest first',
'all of it weighted by the real paper',
]
const FIGURES = [
['questions', 'Questions'],
['topics', 'Topics'],
['systems', 'Systems'],
['articles', 'Articles'],
['exams', 'Exams'],
]
// Feature cards data
const FEATURES = [
{
icon: '📄',
title: 'Quiz from Any PDF',
desc: 'Upload board review material, textbook chapters, or lecture slides. AI extracts questions with answers and explanations — no formatting required.',
icon: 'timer',
title: 'A player built for how the paper asks',
desc: 'Sit a session as study or as an exam block. Rule an option out and it stays out, highlight the part of the stem that matters, open an attending tip when you are stuck.',
points: [
'Per-question timing that only runs while the screen is actually open',
'A warning before the block ends, not after',
'Study mode marks as you go; exam mode waits until the end',
],
},
{
icon: '🎓',
title: 'AI Tutor Built In',
desc: 'Ask anything mid-study. The AI tutor knows the current question, the correct answer, and pulls in related questions from your bank for context.',
icon: 'target',
title: 'Sessions that decide themselves',
desc: 'Ask for twenty questions and the bank works out which twenty. Never-seen first, then the topic you are weakest in, then the ones you got wrong, oldest first.',
points: [
'Scaled by the share of the real exam each topic is published as carrying',
'A topic the blueprint does not cover takes the median weight, never zero',
'It picks what to study, not what the paper is made of',
],
},
{
icon: '🩺',
title: 'Clinical Assistant',
desc: 'Access pediatric workflows, bedside calculators, dosing support, and clinical references alongside your study tools.',
icon: 'chart',
title: 'An analysis that says where you stand',
desc: 'Readiness, score over time, how much of the bank you have covered, and a split of correct, correct after a tip, and incorrect — because those are three different things.',
points: [
'A knowledge profile across Articles, Systems and Disciplines',
'Relevance taken from the ABP content outline, not from our own proportions',
'Every session reviewable question by question, long after it is closed',
],
},
{
icon: '🔊',
title: 'Audio Mode',
desc: 'Every question read aloud with natural-sounding voices. Study during rounds, commuting, or on a run — no screen needed.',
icon: 'spark',
title: 'AI Mode, and what it is not allowed to do',
desc: 'Ask it anything and it answers out of your library alone. Retrieval builds the shortlist, the shortlist is the whole prompt, and any citation retrieval did not find is deleted before you see the answer.',
points: [
'Every claim arrives with a source you can open and check',
'Turn any answer straight into a practice session on the same material',
'No library, no answer — it says so rather than inventing one',
],
},
{
icon: '🏦',
title: 'Question Bank',
desc: 'All questions in one searchable place. Filter by category, topic, or search semantically — "hyponatremia in neonates" just works.',
icon: 'book',
title: 'Articles that read like a reference',
desc: 'Written material with cross-references between them and tips set inline in the prose, so the explanation is where the question left you rather than three clicks away.',
points: ['Flashcards', 'Courses with modules and lessons', 'Study plans you can hold yourself to'],
},
{
icon: '📊',
title: 'Track Your Progress',
desc: 'Every attempt saved. Review past scores, see where you struggled, and focus your study time where it counts.',
},
{
icon: '⚡',
title: 'Fast by Design',
desc: 'Study mode loads instantly. The AI works in the background — it never holds up the quiz. No spinners on the critical path.',
icon: 'shield',
title: 'A sign-up that reports you to nobody',
desc: 'The challenge in front of registration is self-hosted. It runs here, on this server, and no third party is told that you visited, tried to sign up, or how long you thought about it.',
},
]
const CLAIMS = [
{
title: 'Where the model writes',
body: 'AI Mode, and the drafting of explanations and tips. In AI Mode its sources are fixed before it starts and checked after it stops — a model cannot cite something it was not given.',
},
{
title: 'Where it does not',
body: 'Choosing your next session is arithmetic on your own answers and a published exam blueprint. It is inspectable, it is the same every time, and no model is asked to guess at it.',
},
{
title: 'Why the difference matters',
body: 'A tool that says "powered by AI" and stops there is asking to be believed. The point of every constraint above is that you can check the claim instead.',
},
]
const CLINICAL_TILES = [
['Well visits', '2wk → 18yr'],
['Milestones', '2mo → 5yr'],
['Vaccines', 'Full schedule'],
['Dosing', 'Weight-based'],
['References', 'Plans + pathways'],
['Bedside', 'Emergency care'],
]
const ICONS = {
timer: <><circle cx="12" cy="13" r="8" /><path d="M12 9v4l2.5 2.5M9 1h6" /></>,
target: <><circle cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="4" /><circle cx="12" cy="12" r="0.6" /></>,
chart: <><path d="M3 21h18M7 17V9M12 17V4M17 17v-6" /></>,
spark: <><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z" /><path d="M18 16.5l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8z" /></>,
book: <><path d="M4 4.5A1.5 1.5 0 015.5 3H19v18H5.5A1.5 1.5 0 014 19.5z" /><path d="M8 7h7M8 11h7" /></>,
shield: <><path d="M12 3l7 3v6c0 4.2-2.9 7.9-7 9-4.1-1.1-7-4.8-7-9V6z" /><path d="M9 12l2 2 4-4" /></>,
}
function Icon({ name }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7"
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{ICONS[name]}
</svg>
)
}
/** True when the visitor has asked their system for less movement. */
function useCalmMotion() {
return useMediaQuery('(prefers-reduced-motion: reduce)')
}
/**
* Adds `is-in` the first time the element is scrolled into view.
*
* Where there is no IntersectionObserver an older browser, or jsdom under
* test the element is revealed immediately rather than never. Content that
* depends on an optional API to become visible is content that disappears.
*/
function useReveal() {
const ref = useRef(null)
const [shown, setShown] = useState(typeof IntersectionObserver === 'undefined')
useEffect(() => {
if (shown || !ref.current) return undefined
const observer = new IntersectionObserver(entries => {
if (entries.some(entry => entry.isIntersecting)) setShown(true)
}, { rootMargin: '0px 0px -60px 0px' })
observer.observe(ref.current)
return () => observer.disconnect()
}, [shown])
return [ref, shown ? 'lp-reveal is-in' : 'lp-reveal']
}
function Reveal({ children, className = '' }) {
const [ref, revealClass] = useReveal()
return <div ref={ref} className={`${revealClass} ${className}`.trim()}>{children}</div>
}
/**
* Counts from nothing up to the figure it was given.
*
* The count is animation, so the number the page reports is the target from
* the first render an assistive reader is given the finished figure, and
* anyone who has asked for calm motion simply sees it.
*/
function useCountUp(target, animate) {
const [shown, setShown] = useState(animate ? 0 : target)
useEffect(() => {
if (!animate || typeof requestAnimationFrame !== 'function') { setShown(target); return undefined }
const started = Date.now()
let frame = requestAnimationFrame(function step() {
const progress = Math.min(1, (Date.now() - started) / 1100)
// Eased out, so it arrives at the figure rather than stopping at it.
setShown(Math.round(target * (1 - Math.pow(1 - progress, 3))))
if (progress < 1) frame = requestAnimationFrame(step)
})
return () => cancelAnimationFrame(frame)
}, [target, animate])
return shown
}
function Figure({ value, label, animate }) {
const shown = useCountUp(value, animate)
// The name is on the item and the digits inside it are hidden, so a screen
// reader hears "2,924 questions" once instead of a number ticking upwards.
return (
<li className="lp-figure" aria-label={`${value.toLocaleString()} ${label.toLowerCase()}`}>
<span className="lp-figure-value" aria-hidden="true">{shown.toLocaleString()}</span>
<span className="lp-figure-label" aria-hidden="true">{label}</span>
</li>
)
}
/**
* How much there is, asked of the thing that knows.
*
* When the count cannot be fetched the section keeps its heading and loses its
* figures. Rendering zeros instead would be a claim, and a false one: the bank
* is not empty, we just could not reach it.
*/
function PublicFigures() {
const calm = useCalmMotion()
const [stats, setStats] = useState(null)
const [failed, setFailed] = useState(false)
useEffect(() => {
let live = true
api.get('/public/stats')
.then(res => { if (live) setStats(res.data || null) })
.catch(() => { if (live) setFailed(true) })
return () => { live = false }
}, [])
const figures = FIGURES
.map(([key, label]) => [label, Number(stats?.[key])])
.filter(([, value]) => Number.isFinite(value) && value > 0)
return (
<section className="lp-section lp-section-raised">
<div className="lp-inner">
<div className="lp-head">
<h2>What is in the bank today</h2>
<p>Counted at the moment you loaded this page, not typed into it last spring.</p>
</div>
<Reveal>
{figures.length > 0 && (
<ul className="lp-figures" aria-label="What is in the bank today">
{figures.map(([label, value]) => (
<Figure key={label} value={value} label={label} animate={!calm} />
))}
</ul>
)}
{failed && (
<p className="lp-figures-missing">
The counts could not be reached just now. Rather than show you a number we
have not checked, here is none.
</p>
)}
</Reveal>
</div>
</section>
)
}
/**
* One line, four things to say.
*
* Under calm motion it settles on the first and stays there; the whole
* sentence is in the document either way, for anyone reading it rather than
* watching it.
*/
function Rotator({ calm }) {
const [index, setIndex] = useState(0)
useEffect(() => {
if (calm) return undefined
const timer = setInterval(() => setIndex(i => (i + 1) % ADAPTIVE_STEPS.length), 2600)
return () => clearInterval(timer)
}, [calm])
return (
<p className="lp-rotator">
<span className="lp-rotator-label">Your next session:</span>
<span className="lp-sr">{ADAPTIVE_STEPS.join(', ')}.</span>
<span className="lp-rotator-word" key={index} aria-hidden="true">{ADAPTIVE_STEPS[index]}</span>
</p>
)
}
// Contact form
function ContactForm() {
const [form, setForm] = useState({ name: '', email: '', type: 'question', message: '' })
@ -69,10 +310,10 @@ function ContactForm() {
}
if (sent) return (
<div style={{ textAlign: 'center', padding: '32px 0' }}>
<div style={{ fontSize: '2.5rem', marginBottom: 12 }}></div>
<h3 style={{ fontSize: '1.2rem', marginBottom: 8 }}>Message received</h3>
<p style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>
<div className="lp-sent">
<div className="lp-sent-mark" aria-hidden="true"></div>
<h3>Message received</h3>
<p>
{form.type === 'moderator'
? "We'll review your application and get back to you."
: "We'll get back to you shortly."}
@ -81,65 +322,44 @@ function ContactForm() {
)
return (
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 200px), 1fr))', gap: 12 }}>
<div>
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: 600, marginBottom: 5, color: 'var(--text-muted)' }}>Name</label>
<input
value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
placeholder="Your name" required maxLength={120}
style={{ width: '100%', padding: '9px 12px', borderRadius: 8, border: '1px solid var(--border)', background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.9rem', boxSizing: 'border-box' }}
/>
<form onSubmit={submit} className="lp-form">
<div className="lp-form-row">
<div className="lp-field">
<label htmlFor="lp-contact-name">Name</label>
<input id="lp-contact-name" value={form.name} required maxLength={120}
placeholder="Your name"
onChange={e => setForm(f => ({ ...f, name: e.target.value }))} />
</div>
<div>
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: 600, marginBottom: 5, color: 'var(--text-muted)' }}>Email</label>
<input
type="email" value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
placeholder="you@example.com" required
style={{ width: '100%', padding: '9px 12px', borderRadius: 8, border: '1px solid var(--border)', background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.9rem', boxSizing: 'border-box' }}
/>
<div className="lp-field">
<label htmlFor="lp-contact-email">Email</label>
<input id="lp-contact-email" type="email" value={form.email} required
placeholder="you@example.com"
onChange={e => setForm(f => ({ ...f, email: e.target.value }))} />
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: 600, marginBottom: 5, color: 'var(--text-muted)' }}>Type</label>
<div style={{ display: 'flex', gap: 10 }}>
<div className="lp-field">
<label htmlFor="lp-contact-message">Message</label>
<div className="lp-choice">
{[['question', '💬 Question'], ['moderator', '🛡 Apply as Moderator']].map(([val, label]) => (
<button
key={val} type="button"
onClick={() => setForm(f => ({ ...f, type: val }))}
style={{
flex: 1, padding: '8px 12px', borderRadius: 8, fontSize: '0.85rem', cursor: 'pointer',
fontWeight: form.type === val ? 700 : 400,
background: form.type === val ? 'var(--primary)' : 'var(--bg)',
color: form.type === val ? 'var(--primary-fg)' : 'var(--text)',
border: `1px solid ${form.type === val ? 'var(--primary)' : 'var(--border)'}`,
transition: 'all 0.15s',
}}
>{label}</button>
<button key={val} type="button" aria-pressed={form.type === val}
onClick={() => setForm(f => ({ ...f, type: val }))}>{label}</button>
))}
</div>
{form.type === 'moderator' && (
<p style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: 6 }}>
Moderators can upload PDFs and manage quiz content. Tell us about your background and how you'd like to contribute.
<p className="lp-hint">
Moderators can upload material and manage the question bank. Tell us about your
background and how you would like to contribute.
</p>
)}
</div>
<div>
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: 600, marginBottom: 5, color: 'var(--text-muted)' }}>Message</label>
<textarea
value={form.message} onChange={e => setForm(f => ({ ...f, message: e.target.value }))}
placeholder={form.type === 'moderator' ? "Tell us about your background, specialty, and how you'd like to help..." : 'Your question or message...'}
required rows={4} maxLength={2000}
style={{ width: '100%', padding: '9px 12px', borderRadius: 8, border: '1px solid var(--border)', background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.9rem', resize: 'vertical', fontFamily: 'inherit', boxSizing: 'border-box' }}
/>
<textarea id="lp-contact-message" className="lp-message" value={form.message} required rows={4} maxLength={2000}
placeholder={form.type === 'moderator'
? 'Tell us about your background, specialty, and how you would like to help…'
: 'Your question or message…'}
onChange={e => setForm(f => ({ ...f, message: e.target.value }))} />
</div>
<Captcha onVerify={setCaptchaToken} />
{error && <div style={{ color: 'var(--wrong-fg)', fontSize: '0.85rem', background: 'var(--wrong-bg)', padding: '8px 12px', borderRadius: 6 }}>{error}</div>}
<button
type="submit" disabled={loading || !canSubmit}
className="btn btn-primary"
style={{ alignSelf: 'flex-start', minWidth: 140 }}
>
{error && <div className="lp-error">{error}</div>}
<button type="submit" disabled={loading || !canSubmit} className="btn btn-primary">
{loading ? 'Sending…' : 'Send Message'}
</button>
</form>
@ -230,39 +450,23 @@ function AuthModal({ mode, onClose, onSwitch }) {
const isLogin = mode === 'login'
return (
<div onClick={onClose} style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 1000,
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16,
}}>
<div onClick={e => e.stopPropagation()} style={{
background: 'var(--card-bg)', borderRadius: 'var(--card-radius, 14px)',
padding: '28px 24px', maxWidth: 400, width: '100%',
boxShadow: '0 20px 60px rgba(0,0,0,0.3)', position: 'relative',
}}>
<button onClick={onClose} style={{
position: 'absolute', top: 12, right: 14, background: 'none',
border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.2rem',
}}></button>
<div onClick={onClose} className="lp-modal-backdrop">
<div onClick={e => e.stopPropagation()} className="lp-modal">
<button onClick={onClose} className="lp-modal-close" aria-label="Close"></button>
{/* Tabs */}
<div style={{ display: 'flex', gap: 0, marginBottom: 20, borderBottom: '2px solid var(--border)' }}>
<div className="lp-tabs">
{[['login', 'Sign In'], ['register', 'Register']].map(([m, label]) => (
<button key={m} onClick={() => switchMode(m)} style={{
flex: 1, padding: '10px 0', background: 'none', border: 'none', cursor: 'pointer',
fontWeight: mode === m ? 700 : 400, fontSize: '0.95rem',
color: mode === m ? 'var(--primary)' : 'var(--text-muted)',
borderBottom: mode === m ? '2px solid var(--primary)' : '2px solid transparent',
marginBottom: -2,
}}>{label}</button>
<button key={m} onClick={() => switchMode(m)} aria-pressed={mode === m}>{label}</button>
))}
</div>
{/* Registration success */}
{registered && (
<div style={{ textAlign: 'center', padding: '12px 0' }}>
<div style={{ fontSize: '2.5rem', marginBottom: 12 }}>📧</div>
<h3 style={{ marginBottom: 8 }}>Check your email</h3>
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', marginBottom: 16 }}>
<div className="lp-modal-note">
<div className="lp-modal-mark" aria-hidden="true">📧</div>
<h3>Check your email</h3>
<p>
We sent a verification link to <strong>{email}</strong>. Click it to activate your account.
</p>
<button className="btn btn-primary" onClick={() => { setRegistered(false); switchMode('login') }}>
@ -275,17 +479,15 @@ function AuthModal({ mode, onClose, onSwitch }) {
{isLogin && !registered && (
<>
{unverified && !resendSent && (
<div style={{ background: '#fffbeb', border: '1px solid #fcd34d', borderRadius: 8, padding: '12px 14px', marginBottom: 14 }}>
<p style={{ margin: '0 0 8px', fontSize: '0.85rem', color: '#92400e', fontWeight: 500 }}>
Email not verified. Check your inbox.
</p>
<div className="lp-unverified">
<p>Email not verified. Check your inbox.</p>
<button className="btn btn-sm btn-primary" onClick={resendVerification} disabled={resending}>
{resending ? 'Sending…' : 'Resend verification email'}
</button>
</div>
)}
{resendSent && <div className="alert alert-success" style={{ marginBottom: 14 }}>Verification email sent.</div>}
{error && <div className="alert alert-error" style={{ marginBottom: 14 }}>{error}</div>}
{resendSent && <div className="alert alert-success">Verification email sent.</div>}
{error && <div className="alert alert-error">{error}</div>}
<form aria-label="Sign in" onSubmit={handleLogin}>
<div className="form-group">
<label htmlFor="modal-login-email">Email</label>
@ -295,12 +497,12 @@ function AuthModal({ mode, onClose, onSwitch }) {
<label htmlFor="modal-login-password">Password</label>
<input id="modal-login-password" type="password" autoComplete="current-password" value={password} onChange={e => setPassword(e.target.value)} required />
</div>
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
<button className="btn btn-primary btn-block" disabled={loading}>
{loading ? 'Signing in…' : 'Sign In'}
</button>
</form>
<div style={{ textAlign: 'center', marginTop: 12, fontSize: '0.82rem' }}>
<Link to="/forgot-password" onClick={onClose} style={{ color: 'var(--text-muted)' }}>Forgot password?</Link>
<div className="lp-forgot">
<Link to="/forgot-password" onClick={onClose}>Forgot password?</Link>
</div>
</>
)}
@ -308,7 +510,7 @@ function AuthModal({ mode, onClose, onSwitch }) {
{/* Register form */}
{!isLogin && !registered && (
<>
{error && <div className="alert alert-error" style={{ marginBottom: 14 }}>{error}</div>}
{error && <div className="alert alert-error">{error}</div>}
<form onSubmit={handleRegister}>
{/* Labelled by id: these two were bare <label>s that neither
wrapped their input nor named it, so a screen reader met two
@ -333,9 +535,7 @@ function AuthModal({ mode, onClose, onSwitch }) {
<input id="modal-reg-confirm" type="password" value={confirm} required minLength={8}
autoComplete="new-password" onChange={e => setConfirm(e.target.value)} />
{confirm && password !== confirm && (
<small style={{ color: 'var(--wrong-fg)', fontSize: '0.8rem' }}>
These do not match yet.
</small>
<small className="lp-mismatch">These do not match yet.</small>
)}
</div>
{inviteRequired && (
@ -345,16 +545,13 @@ function AuthModal({ mode, onClose, onSwitch }) {
whether a code is valid before the account is made
that would be a place to guess them. */}
<input id="modal-invite-code" value={inviteCode} required autoComplete="off"
placeholder="From whoever invited you"
style={{ textTransform: 'uppercase', letterSpacing: '0.08em' }}
placeholder="From whoever invited you" className="lp-invite"
onChange={e => setInviteCode(e.target.value.toUpperCase())} />
<small style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>
This site is invite only.
</small>
<small className="lp-hint">This site is invite only.</small>
</div>
)}
<Captcha onVerify={setCaptchaToken} />
<button className="btn btn-primary" style={{ width: '100%' }}
<button className="btn btn-primary btn-block"
disabled={loading || !password || password !== confirm
|| (captchaSiteKey() && !captchaToken)
|| (inviteRequired && !inviteCode.trim())}>
@ -372,12 +569,12 @@ function AuthModal({ mode, onClose, onSwitch }) {
export default function LandingPage() {
const { user } = useAuth()
const calm = useCalmMotion()
const [authModal, setAuthModal] = useState(null) // null | 'login' | 'register'
return (
<div style={{ minHeight: '100dvh', background: 'var(--bg)' }}>
<div className="lp-page">
{/* Auth modal overlay */}
{authModal && (
<AuthModal
mode={authModal}
@ -386,179 +583,130 @@ export default function LandingPage() {
/>
)}
{/* ── Shared Navbar (handles auth state) ─────────────────────────────── */}
<style>{`.navbar { margin-bottom: 0 !important; }`}</style>
<Navbar onSignIn={() => setAuthModal('login')} onRegister={() => setAuthModal('register')} />
{/* ── Hero ───────────────────────────────────────────────────────────── */}
<section style={{
background: 'var(--navbar-bg)',
color: 'var(--navbar-fg)',
padding: '96px 24px 88px',
textAlign: 'center',
position: 'relative',
overflow: 'hidden',
}}>
{/* subtle grid */}
<div style={{
position: 'absolute', inset: 0, opacity: 0.04,
backgroundImage: 'linear-gradient(#fff 1px, transparent 1px), linear-gradient(90deg, #fff 1px, transparent 1px)',
backgroundSize: '48px 48px',
}} />
<div style={{ position: 'relative', maxWidth: 720, margin: '0 auto' }}>
<div style={{ display: 'inline-block', background: 'rgba(96,165,250,0.15)', border: '1px solid rgba(96,165,250,0.3)', borderRadius: 20, padding: '4px 14px', fontSize: '0.78rem', fontWeight: 600, color: '#93c5fd', marginBottom: 28, letterSpacing: '0.04em', textTransform: 'uppercase' }}>
Pediatric Learning Platform
</div>
<h1 style={{ fontSize: 'clamp(2.2rem, 5vw, 3.4rem)', fontWeight: 800, lineHeight: 1.15, margin: '0 0 22px', letterSpacing: '-0.03em' }}>
Turn your textbooks<br />
<span style={{ background: 'linear-gradient(90deg, #60a5fa, #a78bfa)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>
into tests
</span>
</h1>
<p style={{ fontSize: '1.15rem', color: 'rgba(226,232,240,0.75)', lineHeight: 1.7, marginBottom: 40, maxWidth: 560, margin: '0 auto 40px' }}>
Upload any PDF board review material, lecture notes, textbook chapters.
AI extracts questions, reads them aloud, and explains every answer.
<section className="lp-hero">
<div className="lp-hero-inner">
<span className="lp-eyebrow">Pediatric board preparation</span>
<h1>Sit the paper<br /><em>before you sit the paper.</em></h1>
<p className="lp-lead">
A pediatric question bank you take as timed blocks or as study, an analysis
that tells you where you actually stand, and an assistant that is only
permitted to answer out of your own library.
</p>
<div style={{ display: 'flex', gap: 14, justifyContent: 'center', flexWrap: 'wrap' }}>
<Rotator calm={calm} />
<div className="lp-cta">
{user ? <>
<Link to="/" className="btn btn-primary" style={{ padding: '12px 28px', fontSize: '1rem', borderRadius: 10 }}>Dashboard</Link>
<Link to="/sessions" className="btn" style={{ padding: '12px 28px', fontSize: '1rem', borderRadius: 10, background: 'rgba(255,255,255,0.08)', color: 'var(--navbar-fg)', border: '1px solid rgba(255,255,255,0.18)', textDecoration: 'none' }}>My Quizzes</Link>
<Link to="/question-bank" className="btn" style={{ padding: '12px 28px', fontSize: '1rem', borderRadius: 10, background: 'rgba(255,255,255,0.08)', color: 'var(--navbar-fg)', border: '1px solid rgba(255,255,255,0.18)', textDecoration: 'none' }}>Question Bank</Link>
<Link to="/" className="btn btn-primary">Dashboard</Link>
<Link to="/study/new" className="btn lp-cta-ghost">Start a session</Link>
<Link to="/sessions" className="btn lp-cta-ghost">Your analysis</Link>
</> : <>
<button onClick={() => setAuthModal('login')} className="btn btn-primary" style={{ padding: '12px 28px', fontSize: '1rem', borderRadius: 10 }}>Sign In</button>
<button onClick={() => setAuthModal('register')} className="btn" style={{ padding: '12px 28px', fontSize: '1rem', borderRadius: 10, background: 'rgba(255,255,255,0.08)', color: 'var(--navbar-fg)', border: '1px solid rgba(255,255,255,0.18)' }}>Register</button>
<button onClick={() => setAuthModal('register')} className="btn btn-primary">Create an account</button>
<button onClick={() => setAuthModal('login')} className="btn lp-cta-ghost">Sign in</button>
</>}
</div>
</div>
</section>
{/* ── Features ───────────────────────────────────────────────────────── */}
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '80px 24px' }}>
<div style={{ textAlign: 'center', marginBottom: 56 }}>
<h2 style={{ fontSize: 'clamp(1.6rem, 3vw, 2.2rem)', fontWeight: 800, letterSpacing: '-0.02em', margin: '0 0 12px' }}>
Everything you need to study smarter
</h2>
<p style={{ color: 'var(--text-muted)', fontSize: '1.05rem', maxWidth: 480, margin: '0 auto' }}>
Built specifically for pediatric board prep and clinical learning.
</p>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 280px), 1fr))', gap: 20 }}>
{FEATURES.map((f, i) => (
<div key={i} className="card" style={{ padding: '24px', transition: 'transform 0.2s, box-shadow 0.2s' }}
onMouseEnter={e => { e.currentTarget.style.transform = 'translateY(-3px)'; e.currentTarget.style.boxShadow = '0 8px 32px rgba(0,0,0,0.12)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'none'; e.currentTarget.style.boxShadow = '' }}
>
<div style={{ fontSize: '2rem', marginBottom: 12 }}>{f.icon}</div>
<h3 style={{ fontWeight: 700, fontSize: '1rem', margin: '0 0 8px' }}>{f.title}</h3>
<p style={{ color: 'var(--text-muted)', fontSize: '0.9rem', lineHeight: 1.65, margin: 0 }}>{f.desc}</p>
</div>
))}
<PublicFigures />
{/* ── What it does ───────────────────────────────────────────────────── */}
<section className="lp-section">
<div className="lp-inner">
<div className="lp-head">
<h2>What you get</h2>
<p>Six things, each of which is doing something specific about a real problem with revising.</p>
</div>
<div className="lp-grid">
{FEATURES.map(feature => (
<Reveal key={feature.title} className="lp-card">
<span className="lp-card-mark"><Icon name={feature.icon} /></span>
<h3>{feature.title}</h3>
<p>{feature.desc}</p>
{feature.points && (
<ul>{feature.points.map(point => <li key={point}>{point}</li>)}</ul>
)}
</Reveal>
))}
</div>
</div>
</section>
{/* ── Clinical tools section ─────────────────────────────────────────── */}
<section style={{ background: 'var(--navbar-bg)', color: 'var(--navbar-fg)', padding: '80px 24px' }}>
<div style={{ maxWidth: 1000, margin: '0 auto', display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 320px), 1fr))', gap: 40, alignItems: 'center' }}>
<div>
<div style={{ display: 'inline-block', background: 'rgba(96,165,250,0.15)', border: '1px solid rgba(96,165,250,0.3)', borderRadius: 20, padding: '4px 14px', fontSize: '0.78rem', fontWeight: 600, color: '#93c5fd', marginBottom: 20, letterSpacing: '0.04em', textTransform: 'uppercase' }}>
Also from PedsHub
</div>
<h2 style={{ fontSize: 'clamp(1.6rem, 3vw, 2rem)', fontWeight: 800, letterSpacing: '-0.02em', margin: '0 0 16px', color: '#f1f5f9' }}>
Pediatric Clinical Tools
</h2>
<p style={{ color: '#94a3b8', lineHeight: 1.7, marginBottom: 28, fontSize: '0.95rem' }}>
A pediatric clinical assistant for daily practice: well visit workflows,
developmental milestones, vaccine schedules, catch-up planning,
bedside calculators, dosing support, emergency pathways, and concise
clinical references in one place, powered by Peds-AI clinical assistant tools.
{/* ── Where the AI is ────────────────────────────────────────────────── */}
<section className="lp-section lp-section-dark">
<div className="lp-inner">
<div className="lp-head">
<span className="lp-eyebrow">Yes, it is AI</span>
<h2>And here is exactly where</h2>
<p>
The interesting part of a claim is what would show it to be wrong. So: this
is which parts of the site a language model touches, and which parts it is
kept out of.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 32, overflow: 'hidden' }}>
{[
{ text: '🩺 Well visit planner from newborn through adolescence' },
{ text: '📋 Developmental milestone tracking across 4 domains' },
{ text: '💉 Full AAP/ACIP vaccine schedule with catch-up planner' },
{ text: '💊 Weight-based dosing and pediatric calculators' },
{ text: '🤖 Peds-AI clinical assistant for quick pediatric guidance' },
{ text: '🚨 Emergency pathways for sepsis, status epilepticus, RSI, burns, and anaphylaxis', badge: 'NEW' },
{ text: '🎙 Optional voice-to-note support for structured documentation' },
].map((item, i) => (
<div key={i} style={{ fontSize: '0.88rem', color: '#cbd5e1', display: 'flex', gap: 8, alignItems: 'flex-start', overflowWrap: 'break-word', wordBreak: 'break-word', minWidth: 0 }}>
<span>{item.text}</span>
{item.badge && (
<span style={{ background: 'rgba(34,197,94,0.18)', color: '#86efac', border: '1px solid rgba(34,197,94,0.35)', borderRadius: 10, padding: '1px 7px', fontSize: '0.64rem', fontWeight: 700, letterSpacing: '0.05em', flexShrink: 0 }}>
{item.badge}
</span>
)}
</div>
))}
</div>
<a
href="https://app.pedshub.com"
target="_blank"
rel="noopener noreferrer"
className="btn btn-primary"
style={{ display: 'inline-block', textDecoration: 'none', padding: '11px 24px', borderRadius: 10 }}
>
Open Clinical Tools
</div>
<div className="lp-claims">
{CLAIMS.map(claim => (
<Reveal key={claim.title} className="lp-claim">
<h3>{claim.title}</h3>
<p>{claim.body}</p>
</Reveal>
))}
</div>
</div>
</section>
{/* ── Clinical tools ─────────────────────────────────────────────────── */}
<section className="lp-section lp-section-dark">
<div className="lp-inner lp-split">
<div>
<span className="lp-eyebrow">Also from PedsHub</span>
<h2>Pediatric clinical tools</h2>
<p>
The same material, pointed at the ward instead of the exam: well visit
planning from newborn to adolescence, developmental milestones, the full
AAP/ACIP schedule with a catch-up planner, weight-based dosing, and
emergency pathways for sepsis, status epilepticus, RSI, burns and
anaphylaxis.
</p>
<a href="https://app.pedshub.com" target="_blank" rel="noopener noreferrer"
className="btn btn-primary">
Open clinical tools
</a>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 130px), 1fr))', gap: 10 }}>
{[
{ icon: '📅', label: 'Well Visits', sub: '2wk → 18yr' },
{ icon: '🧠', label: 'Milestones', sub: '2mo → 5yr' },
{ icon: '💉', label: 'Vaccines', sub: 'Full schedule' },
{ icon: '💊', label: 'Dosing', sub: 'Weight-based' },
{ icon: '📋', label: 'Clinical Assistant', sub: 'References + plans' },
{ icon: '🚨', label: 'Bedside', sub: 'Dosing + pathways' },
].map((item, i) => (
<div key={i} style={{
background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: 10, padding: '14px',
transition: 'background 0.2s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'rgba(255,255,255,0.09)'}
onMouseLeave={e => e.currentTarget.style.background = 'rgba(255,255,255,0.05)'}
>
<div style={{ fontSize: '1.5rem', marginBottom: 6 }}>{item.icon}</div>
<div style={{ fontWeight: 700, fontSize: '0.85rem', color: '#e2e8f0' }}>{item.label}</div>
<div style={{ fontSize: '0.75rem', color: '#64748b' }}>{item.sub}</div>
<div className="lp-tiles">
{CLINICAL_TILES.map(([label, sub]) => (
<div key={label} className="lp-tile">
<div className="lp-tile-label">{label}</div>
<div className="lp-tile-sub">{sub}</div>
</div>
))}
</div>
</div>
</section>
{/* ── Contact form ───────────────────────────────────────────────────── */}
<section id="contact" style={{ maxWidth: 680, margin: '0 auto', padding: '80px 24px' }}>
<div style={{ textAlign: 'center', marginBottom: 40 }}>
<h2 style={{ fontSize: 'clamp(1.5rem, 3vw, 2rem)', fontWeight: 800, letterSpacing: '-0.02em', margin: '0 0 12px' }}>
Get in touch
</h2>
<p style={{ color: 'var(--text-muted)', fontSize: '0.95rem' }}>
Have a question? Want to contribute as a moderator? We'd love to hear from you.
</p>
</div>
<div className="card" style={{ padding: '32px' }}>
<ContactForm />
{/* ── Contact ────────────────────────────────────────────────────────── */}
<section id="contact" className="lp-section lp-section-raised">
<div className="lp-inner-narrow">
<div className="lp-head">
<h2>Get in touch</h2>
<p>A question, or an offer to help write and review the bank. Both are welcome.</p>
</div>
<div className="lp-card-lg">
<ContactForm />
</div>
</div>
</section>
{/* ── Footer ─────────────────────────────────────────────────────────── */}
<footer style={{
background: 'var(--navbar-bg)',
padding: '32px 24px',
borderTop: '1px solid rgba(255,255,255,0.05)',
}}>
<div style={{ maxWidth: 1100, margin: '0 auto', display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 16 }}>
<span style={{ fontWeight: 700, color: 'rgba(226,232,240,0.6)', fontSize: '0.9rem' }}>
🏥 PedsHub
<span style={{ fontWeight: 400, marginLeft: 16 }}>© {new Date().getFullYear()}</span>
<footer className="lp-footer">
<div className="lp-footer-inner">
<span className="lp-footer-brand">
🏥 PedsHub<span>© {new Date().getFullYear()}</span>
</span>
<div style={{ display: 'flex', gap: 24, fontSize: '0.85rem' }}>
<button onClick={() => setAuthModal('login')} style={{ color: 'rgba(226,232,240,0.45)', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'inherit' }}>Sign In</button>
<a href="#contact" style={{ color: 'rgba(226,232,240,0.45)', textDecoration: 'none' }}>Contact</a>
<a href="https://app.pedshub.com" target="_blank" rel="noopener noreferrer" style={{ color: 'rgba(226,232,240,0.45)', textDecoration: 'none' }}>Clinical Tools</a>
<div className="lp-footer-links">
<button onClick={() => setAuthModal('login')}>Sign In</button>
<a href="#contact">Contact</a>
<a href="https://app.pedshub.com" target="_blank" rel="noopener noreferrer">Clinical Tools</a>
</div>
</div>
</footer>

View file

@ -0,0 +1,149 @@
import { act, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn() } }))
// Stands in for the real bar only to expose the two callbacks the page hands
// it; what the navbar itself renders is its own tests' business.
vi.mock('../components/Navbar', () => ({
default: ({ onSignIn, onRegister }) => (
<nav>
<button onClick={onSignIn}>Open login</button>
<button onClick={onRegister}>Open registration</button>
</nav>
),
}))
import LandingPage from './LandingPage'
import { AuthProvider } from '../context/AuthContext'
import api from '../api/client'
// What /api/public/stats returned the day this was written. The point of the
// endpoint is that these move, so nothing here asserts a particular size
// only that whatever came back is what appears.
const STATS = { questions: 2924, topics: 673, systems: 15, articles: 8, exams: 2 }
const realMatchMedia = window.matchMedia
/** Answers `reduce` for the motion query and no for everything else. */
function stubReducedMotion() {
window.matchMedia = vi.fn(query => ({
matches: query.includes('prefers-reduced-motion'),
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}))
}
function mount() {
render(
<MemoryRouter>
<AuthProvider><LandingPage /></AuthProvider>
</MemoryRouter>,
)
}
beforeEach(() => {
vi.resetAllMocks()
localStorage.clear()
api.get.mockImplementation(url => Promise.resolve({
data: url === '/public/stats' ? STATS : {},
}))
api.post.mockResolvedValue({ data: {} })
})
afterEach(() => {
window.matchMedia = realMatchMedia
vi.useRealTimers()
})
describe('the figures', () => {
it('reports the counts the endpoint gave it', async () => {
mount()
expect(api.get).toHaveBeenCalledWith('/public/stats')
// Named on the item rather than read off the digits, which are still
// counting upwards at this point and are hidden from assistive readers
// for exactly that reason.
const figures = within(await screen.findByRole('list', { name: /in the bank today/i }))
expect(figures.getByRole('listitem', { name: '2,924 questions' })).toBeInTheDocument()
expect(figures.getByRole('listitem', { name: '673 topics' })).toBeInTheDocument()
expect(figures.getByRole('listitem', { name: '15 systems' })).toBeInTheDocument()
expect(figures.getByRole('listitem', { name: '8 articles' })).toBeInTheDocument()
expect(figures.getByRole('listitem', { name: '2 exams' })).toBeInTheDocument()
// And the digits do arrive at the real number rather than stopping
// somewhere short of it. Longer than the default wait, because the count
// itself takes about a second.
await waitFor(() => expect(screen.getByText('2,924')).toBeInTheDocument(), { timeout: 4000 })
})
it('shows nothing at all rather than a nought when the count cannot be fetched', async () => {
api.get.mockImplementation(url => (url === '/public/stats'
? Promise.reject(new Error('unreachable'))
: Promise.resolve({ data: {} })))
mount()
expect(await screen.findByText(/counts could not be reached/i)).toBeInTheDocument()
// A nought here would be a claim that the bank is empty, which it is not.
expect(screen.queryByText('0')).not.toBeInTheDocument()
expect(screen.queryByRole('list', { name: /in the bank today/i })).not.toBeInTheDocument()
})
it('omits a figure the endpoint did not send instead of inventing one', async () => {
api.get.mockImplementation(url => Promise.resolve({
data: url === '/public/stats' ? { questions: 2924 } : {},
}))
mount()
const figures = within(await screen.findByRole('list', { name: /in the bank today/i }))
expect(figures.getByRole('listitem', { name: '2,924 questions' })).toBeInTheDocument()
expect(figures.getAllByRole('listitem')).toHaveLength(1)
})
})
describe('reduced motion', () => {
it('lands on the finished figures and stops the line cycling', async () => {
stubReducedMotion()
vi.useFakeTimers({ shouldAdvanceTime: true })
mount()
// No count: the number is simply there, first paint.
expect(await screen.findByText('2,924')).toBeInTheDocument()
const first = 'what you have never seen'
expect(screen.getByText(first)).toBeInTheDocument()
// Three full turns of the carousel it would otherwise have run.
await act(async () => { vi.advanceTimersByTime(9000) })
expect(screen.getByText(first)).toBeInTheDocument()
})
it('still puts the whole of the rotating line in the document to be read', async () => {
stubReducedMotion()
mount()
// The four rules as one sentence, for anyone who is reading the page
// rather than watching it.
expect(await screen.findByText(/what you have never seen, then your weakest topic/i))
.toBeInTheDocument()
})
})
describe('the auth modal', () => {
it('still opens from the navbar and switches between its two tabs', async () => {
mount()
await userEvent.click(screen.getByRole('button', { name: 'Open login' }))
expect(screen.getByRole('form', { name: 'Sign in' })).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Register' }))
expect(screen.getByLabelText('Confirm password')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Sign Up' })).toBeInTheDocument()
expect(screen.queryByRole('form', { name: 'Sign in' })).not.toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(screen.queryByRole('form', { name: 'Sign in' })).not.toBeInTheDocument()
expect(screen.queryByLabelText('Confirm password')).not.toBeInTheDocument()
})
it('opens straight onto registration from the navbar', async () => {
mount()
await userEvent.click(screen.getByRole('button', { name: 'Open registration' }))
expect(screen.getByLabelText('Confirm password')).toBeInTheDocument()
expect(api.get).toHaveBeenCalledWith('/auth/signup-policy')
})
})

View file

@ -482,6 +482,9 @@ export default function QuizPage() {
const [note, setNote] = useState('')
const [noteSaved, setNoteSaved] = useState(true)
const [folders, setFolders] = useState([])
// Searching the folders you have and naming one you do not, in the same box.
const [folderQuery, setFolderQuery] = useState('')
const [folderBusy, setFolderBusy] = useState(false)
const [showReview, setShowReview] = useState(false)
const [responseStats, setResponseStats] = useState(null)
const [statsError, setStatsError] = useState('')
@ -1056,6 +1059,26 @@ const timerStarted = timeLeft !== null
? { ...f, question_ids: [...(f.question_ids || []), questionId] } : f)))
} catch { /* the row stays unticked, which is the honest signal */ }
}
/**
* Make the folder and put the question in it, in one press.
*
* The panel used to say "make one in the question bank" and leave you to go
* and do it which means leaving the question you were reading, and coming
* back to find your place. Naming a folder is the whole of making one.
*/
const createFolderWith = async (title, questionId) => {
const name = title.trim()
if (!name) return
setFolderBusy(true)
try {
const made = await api.post('/collections/', { title: name })
await api.put(`/collections/${made.data.id}/questions/${questionId}`)
setFolders(list => [...list, { ...made.data, question_ids: [questionId] }])
setFolderQuery('')
} catch { /* nothing is added, and the field keeps what was typed */ }
finally { setFolderBusy(false) }
}
/**
* Choosing is answering.
*
@ -1850,28 +1873,64 @@ const timerStarted = timeLeft !== null
</div>
)}
{panel === 'save' && (
<div className="quiz-panel">
<p className="quiz-panel-title">Save to a folder</p>
{folders.length === 0 ? (
<p className="quiz-panel-empty">No folders yet make one in the question bank.</p>
) : (
<ul className="quiz-folders">
{folders.map(folder => {
const inIt = (folder.question_ids || []).includes(current.id)
return (
<li key={folder.id}>
<button type="button" className={inIt ? 'is-in' : ''} disabled={inIt}
onClick={() => saveToFolder(folder.id, current.id)}>
{inIt ? '✓ ' : '+ '}{folder.title}
</button>
</li>
)
})}
</ul>
)}
</div>
)}
{panel === 'save' && (() => {
const needle = folderQuery.trim().toLowerCase()
const found = folders.filter(f => f.title.toLowerCase().includes(needle))
// Offered only when nothing you already have is called this
// two folders of the same name is a filing system nobody can use.
const canCreate = needle
&& !folders.some(f => f.title.trim().toLowerCase() === needle)
return (
<div className="quiz-panel">
<p className="quiz-panel-title">Save question in folder</p>
{/* One box for both. Searching what you have and naming
what you do not are the same act you type the name of
the folder you want and it either exists or it doesn't. */}
<input className="quiz-folder-find" value={folderQuery}
placeholder="Create or find folder" aria-label="Create or find folder"
onChange={e => setFolderQuery(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && canCreate) {
e.preventDefault()
createFolderWith(folderQuery, current.id)
}
}} />
{canCreate && (
<>
<p className="quiz-folder-heading">Create folder</p>
<button type="button" className="quiz-folder-new" disabled={folderBusy}
onClick={() => createFolderWith(folderQuery, current.id)}>
<span>{folderQuery.trim()}</span>
<span aria-hidden="true">+</span>
</button>
</>
)}
{found.length > 0 && (
<ul className="quiz-folders">
{found.map(folder => {
const inIt = (folder.question_ids || []).includes(current.id)
return (
<li key={folder.id}>
<button type="button" className={inIt ? 'is-in' : ''} disabled={inIt}
onClick={() => saveToFolder(folder.id, current.id)}>
{inIt ? '✓ ' : '+ '}{folder.title}
</button>
</li>
)
})}
</ul>
)}
{!found.length && !canCreate && (
<p className="quiz-panel-empty">
{folders.length ? 'No folder by that name.' : 'Type a name to make your first folder.'}
</p>
)}
</div>
)
})()}
{/* Figures are rows now. The legacy single path is still shown
for anything that was never backfilled. */}

View file

@ -728,6 +728,61 @@ describe('quiz player', () => {
}
})
it('finds a folder and makes one from the same box', async () => {
api.get.mockImplementation(url => {
if (url === '/collections/') return Promise.resolve({ data: [{ id: 4, title: 'Cardiology misses', question_ids: [] }] })
if (url.startsWith('/quizzes/10')) return Promise.resolve({ data: {
id: 10, title: 'Personal test', mode: quizModeVar, questions_count: 2, user_id: 1,
time_limit_minutes: timeLimit, attempt_mode: url.includes('attempt_id=') ? mode : null,
questions,
} })
if (url === '/attempts/progress') return Promise.resolve({ data: null })
return Promise.resolve({ data: [] })
})
api.put.mockResolvedValue({ data: {} })
await begin()
// It folds away behind the control, with the other occasional actions.
await userEvent.click(screen.getByRole('button', { name: 'More options' }))
await userEvent.click(screen.getByRole('button', { name: /Save to a folder/ }))
const box = screen.getByLabelText('Create or find folder')
await userEvent.type(box, 'cardio')
// What you already have, found by the same typing and still the offer to
// make one called exactly that, because wanting a narrower folder named
// "cardio" is not the same as wanting the one called "Cardiology misses".
expect(screen.getByRole('button', { name: /Cardiology misses/ })).toBeInTheDocument()
expect(screen.getByText('Create folder')).toBeInTheDocument()
await userEvent.clear(box)
await userEvent.type(box, 'Neonatology')
// Nothing by that name, so the same box offers to make it.
expect(screen.getByText('Create folder')).toBeInTheDocument()
api.post.mockResolvedValue({ data: { id: 9, title: 'Neonatology' } })
await userEvent.click(screen.getByRole('button', { name: /^Neonatology/ }))
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/collections/', { title: 'Neonatology' }))
// Made and filled in one press, without leaving the question.
await waitFor(() => expect(api.put).toHaveBeenCalledWith('/collections/9/questions/1'))
})
it('will not offer to make a folder that already exists', async () => {
api.get.mockImplementation(url => {
if (url === '/collections/') return Promise.resolve({ data: [{ id: 4, title: 'Saved', question_ids: [] }] })
if (url.startsWith('/quizzes/10')) return Promise.resolve({ data: {
id: 10, title: 'Personal test', mode: quizModeVar, questions_count: 2, user_id: 1,
time_limit_minutes: timeLimit, attempt_mode: url.includes('attempt_id=') ? mode : null,
questions,
} })
if (url === '/attempts/progress') return Promise.resolve({ data: null })
return Promise.resolve({ data: [] })
})
await begin()
await userEvent.click(screen.getByRole('button', { name: 'More options' }))
await userEvent.click(screen.getByRole('button', { name: /Save to a folder/ }))
await userEvent.type(screen.getByLabelText('Create or find folder'), 'saved')
// Two folders of the same name is a filing system nobody can use.
expect(screen.queryByText('Create folder')).not.toBeInTheDocument()
})
it('starts timed quizzes in exam mode without a mode prompt', async () => {
quizModeVar = 'timed'
await begin(false)

View file

@ -497,3 +497,25 @@ body:has(.quiz-player.is-boxed) .site-footer { display: none; }
/* Two buttons in a dialog sit side by side, the one that acts first. */
.quiz-away-actions { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
/* Saving a question into a folder
One box for finding and naming: you type the folder you want, and it
either exists already or the panel offers to make it. */
.quiz-folder-find {
width: 100%; padding: 9px 12px; margin-bottom: 10px;
font: inherit; font-size: 0.88rem;
border: 1px solid var(--border); border-radius: 9px;
background: var(--input-bg); color: var(--text);
}
.quiz-folder-heading {
margin: 0 0 6px; font-size: 0.68rem; font-weight: 700;
letter-spacing: 0.07em; text-transform: uppercase; color: var(--text-subtle);
}
.quiz-folder-new {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
width: 100%; padding: 10px 12px; margin-bottom: 10px;
font: inherit; font-size: 0.88rem; font-weight: 600; cursor: pointer;
background: var(--correct-bg); color: var(--correct-fg);
border: 1px solid var(--correct-bd); border-radius: 9px;
}
.quiz-folder-new:disabled { opacity: 0.6; cursor: default; }