`GET /study-tools/performance-over-time` returns a point per completed
session with two figures: that session's percentage, and the running
score across everything answered up to that day. The chart draws the
running line and marks the sessions along it — a single session of twelve
questions swings too far to say anything about whether a learner is
improving.
It stays shut below 40 answers or 3 sessions and says which of the two it
is waiting for, rather than drawing a line through two points and letting
the shape suggest a trend that is not there.
LineChart was in the tree unused, with a hardcoded slate palette that
vanishes on a dark page. Rewritten against the theme tokens.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Opening a tip before answering is a nudge. The answer that follows is
still right — it is counted as right, and the percentage is not docked —
but it is not the same as right, so it keeps its own arc on the donut and
its own line in the legend: "3 correct after a tip".
attempt_answers.used_hint records it. The player reports which questions
had a tip opened before the answer went in; a tip read afterwards is
revision and does not count, which is the difference two of the tests
turn on. Both endings agree about it — an explicit submit carries the
list, and an exam that runs out takes it from the saved progress, so a
tab closing cannot launder a score.
Found while wiring this: RichText declared its component overrides inline
in the render, so every one was a fresh component type and React
remounted the whole rendered tree on each render. An open tip closed
itself every time the exam clock ticked. The map is memoised now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
`{{stridor|Inspiratory stridor is extrathoracic until proven otherwise}}`
in any stem, option, explanation or article renders the phrase with a
dotted underline; clicking it opens the sentence where the word is.
Nothing is revealed until asked for, and Esc or a second click closes it.
The question already had an Attending tip — one panel, for the whole
question, reached from the toolbar. This is the other half: the point
that belongs to one word, said next to that word.
Done as a remark plugin rather than a search-and-replace on the source,
because highlights and the read-aloud cursor are stored as offsets into
the raw text; rewriting the string before it is parsed would move every
offset after the first tip. The split pieces carry line and column as
well as offset — a point with only one of the three is discarded
downstream, which cost the first version every highlight past the tip.
There is a test for exactly that.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
The same slate, two steps up (#0f172a → #1e293b), and the warm theme's
bar with it. Near-black across the top read as a band sitting on the page
rather than part of it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
A question got wrong in March and right in September is 50% by one count
and 100% by another, and both are true. The Performance tab now says
which it is answering: All attempts is every answer ever given — how much
work has been done — and Latest attempt keeps only the most recent answer
to each question — what is known now.
GET /study-tools/answer-split returns both splits plus the session and
unique-question counts, under the same exclusions as everything else that
measures: no repetitions, no course quizzes, no expired attempts. A blank
is its own slice, never folded into incorrect.
The ring itself moves out of AnalysisSessionPage into components/Donut so
the session view and the lifetime view cannot drift apart. Its legend
gains .is-answered, which the session page had been asking for without
anything defining it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
"How am I doing" and "how was I doing last month" are different questions,
and a single lifetime figure cannot answer both. Analysis now carries a
Completion panel on the Performance tab: questions answered against the
bank, how many were right, time per question, total time — over 7 days,
30 days, 3 months, or everything.
GET /study-tools/completion?days=N does the counting. It leaves out what
would not be a measurement: repetitions (you already know that answer),
course quizzes (they belong to their course), and expired attempts. A
question left blank is not a wrong answer, so the percentage is out of
what was answered, not out of what was set. Nothing answered reports
nothing rather than 0%.
The Tools workbench has no menu of its own by design, which left no way
back; it now opens onto Settings where it was reached from.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Hitting an educators-only page redirected to the dashboard in silence,
which leaves you looking at a page you did not ask for with no idea
whether the link was broken, you mistyped, or it simply is not yours. It
says which, and offers the dashboard and settings — the two places you
are actually likely to want from there. Not being signed in stays a
redirect, because there is nothing to explain.
The section row gave its name whatever width was left after a button
group that would not wrap, so "NBME 7 STEP 3" broke to one word a line
while Extract, Create Cards and Delete ran off the right of the card. On
a narrow screen the name takes the row and the buttons take the next
one. The page had a stylesheet that nothing imported, which is why it
had no layout rules of its own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
The phone had a dot grid dropped under the top bar — a different thing
in a different place doing the rail's job worse. It is a drawer holding
the same rail the desktop has, with the site's own menu on the other
tab, because the alternative is a second hamburger elsewhere for the
same purpose. The dot grid and its styles are gone.
And the extraction pipeline was run end to end against a three-question
PDF rather than reasoned about. It works: three questions, stems,
options, correct answers and explanations, landing in a draft batch and
not in the bank. But the run found a real bug on the way.
A document's text is read from the search index, not from the file. When
that index is missing — never processed, or lost to a restart — every
page is skipped and the job fails with "the AI could not find questions
with correct answers in this page range". That is the wrong diagnosis,
and it sends people to change the model, the prompt and the page range,
none of which is the problem. The two failures are now counted apart and
named apart: no stored text says so and says to re-process; a model that
found nothing says that instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Sitting the same questions again is practice, not a new measurement. You
have already seen the answers, so getting them right the second time
says nothing about whether you knew them — and it cannot be allowed to
raise a figure that means "how much of this do you know". A repeated
session is titled "(repetition)", analysed in full on its own page, and
left out of every aggregate: the overall accuracy, the per-quiz history,
the averages, and the readiness that drives recommendations.
Deleting a single session is gone — control, endpoint, tests and all. A
session is a record of work done, and removing one edits the history
every figure on the analysis is computed from, which turns a measurement
into a number somebody chose. Starting again is still offered whole,
under Settings, Your data, which takes everything rather than the parts
that flatter.
Two layout bugs behind that. The category tree kept its appearance in
QuestionBankPage.css, so it looked right on the bank and took whatever
the host page did to a label everywhere else — in the question editor
that centred the name, leaving it adrift with the count at the far
right; it owns its own stylesheet now. And the editor's grid collapsed
to `1fr` below 900px, whose automatic minimum lets one unshrinkable
child push the column past the window: the page had padding down its
left and none down its right because the right was off the screen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
The section bar is a light surface inside a dark navbar, and `.navbar`
sets a near-white text colour for that dark background. Everything in
the light bar inherited it: the study objective, its dropdown, the
counts. The section links had their own colour override and looked fine,
which is why it went unnoticed for so long.
`.navbar a`, `.navbar button` and their hovers are scoped to
`.navbar-primary` — the dark bar they were written for — rather than to
everything beneath it, which was also giving the switcher a translucent
white border on a white surface. And the section bar sets its own text
colour rather than inheriting one meant for a dark ground.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
The knowledge profile ranked topics by how much of *our* bank sat under
each one, which is a fact about us rather than about the exam. It made
cardiology and rheumatology equally worth an evening whenever we happened
to hold the same number of each. The ABP publishes that one is 5% of the
paper and the other 2%, and exam_blueprints.weight has held that since
the blueprint landed.
A domain's weight is divided among the topics beneath it in proportion
to the material each holds, so the topics under a domain add up to its
published share. 672 of our categories now carry one. A topic the
outline does not cover keeps the bank-share figure rather than reporting
nothing — and the row says which it is, because the two numbers mean
different things and should not be read as the same one.
Session analysis is a link to the last session rather than a third tab
with nothing behind it — a session's analysis is a session, and the rail
beside this page is the list of them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
`html, body { overflow-x: hidden }` makes both a scroll container, and a
`position: sticky` descendant then sticks to that rather than to the
viewport — which is to say it does not stick at all. Every sticky thing
in the app was affected: the session rail, the settings nav, the article
column, the study-plan rail. `overflow-x: clip` does the same job without
becoming a scroll container. Hidden stays as the fallback, so a browser
without `clip` still cannot be scrolled sideways and only loses
stickiness, which is the lesser fault.
Putting the rail away destroyed the layout. The collapsed grid was
`0 minmax(0, 1fr)` and the rail is `display: none`, so the content
became the *first* grid item and landed in the zero-width column —
wrapping one word per line beside an empty page. One column when there
is one thing in it.
100vh is the largest viewport on iOS — the one with the URL bar hidden —
so anything sized to it is taller than the screen really is and its
bottom sits behind the bar. Eleven files now use 100dvh, which tracks
the viewport as it changes.
The AI Mode composer was the one bottom-sticky bar with no safe-area
inset; its send button sat under the home indicator.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Striking an option through is how anybody works a five-option question:
eliminate, then choose among what is left. The control sits outside the
option so that ruling one out is never mistaken for picking it, and it
goes once the question is marked — there is nothing left to narrow down.
Not saved: it is working-out, not an answer, and should not follow you
into another sitting of the same question.
Moving on from a question you have not answered is a decision, so the
button says which one it is rather than calling both Next.
The highlighter was a 72%-opacity band across the bottom 62% of every
line — over the text rather than behind it. Descenders vanished into it,
small type became hard to read, and a link inside a highlight lost the
underline that said it was a link. It starts below the x-height now, is
light enough to read through, keeps link underlines, and dims on a dark
ground where the same yellow glared.
Create Test goes straight into the session. You chose the topics, the
count and the mode and pressed Create Test; an overview asking whether
you meant it is a second confirmation of a decision already made twice.
And the action bar has a layout of its own below 700px. With no rules it
wrapped into whatever the flex produced — a label on its own line, two
pills, then a Refresh and an outsized Create Test. Two rows: the mode
toggle is the question, Create Test is the answer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Study mode held a choice as a draft and waited for "Submit response" — a
second press to confirm something already decided, on every question.
Clicking an option marks it now, green or red, with the explanation.
Free text is the exception and keeps Enter, because typing is not
choosing.
Figures carried a generated caption: "Figure from question #3360 (from
images/doc_23/page_704_img_0.jpeg)". That describes the database, not
the picture, and showed a learner an internal file path. 341 of them are
cleared, the indexer no longer writes them, and an unlabelled figure now
says nothing rather than "Figure 1". A screen reader still gets the
label and caption when there are any, and the position when there are
not.
Suspend, Restart and Edit are gone from above the question. Three
buttons over a question nobody was looking away from to press them; Exit
is in the bar at the bottom with the session's own controls, and
restarting and editing belong to the session list and the editor.
And iOS Safari's zoom-on-focus is fixed once rather than per field.
Safari zooms the whole page in when a control smaller than 16px takes
focus and never zooms back out, leaving the layout scaled and broken. It
was being remembered at each individual field, which meant it was
forgotten at most of them — a dozen were still under 16px. One rule for
every control on a coarse pointer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Uploading, extracting and reviewing were three places and the middle one
wrote straight into the bank. They are one job, so they are one page:
the documents, the batches a run produced, and the drafts themselves,
with the counts of what is still to be read said before anything else.
The review is an editor, because that is what the staging table is for.
A draft that is not ready says why on its own row rather than on a
failed attempt to accept it, and cannot be selected until it is fixed.
Fixing happens in place, with nothing in the bank. Only the chosen,
ready ones cross over, and that is the single moment a question id is
taken — an accepted draft then says which question it became.
Nextcloud is the administrator's. It was in every learner's settings as
though each of them had a cloud to connect; it is an import path for
whoever loads the corpus.
The image work is restated in the TODO now that the ped-ai design has
actually been read rather than remembered: allow-listed widths only,
EXIF rotate, resize without enlarging, WebP q82, derivatives in the same
bucket under a thumbs/ prefix, generated best-effort. Pillow rather than
sharp — sharp is Node and this backend is Python — and no Caddy caching,
because these are behind auth and ped-ai serves its own private,
no-store for that reason.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Unanswered questions were counted as wrong in every percentage the site
reports. That made leaving an exam early look like failing it, and made
the figure say more about how far you got than about how well you did —
and how far you got is already the number sitting beside it.
An unanswered question is not a wrong answer. It is not an answer.
score_percent() and answered_counts() give the rule one definition, used
by all seven places that reported a percentage: submission, attempt
history, per-quiz history, the overall average, per-quiz stats, one
attempt's detail, and the session analysis. The list endpoints count in
one query rather than one per row.
The review dialog said unanswered questions count as incorrect, which
was true and is not any more. It now says they will not be marked wrong,
and will not be marked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
The card offered Review answers and Resume session at once on a session
still in progress, which is the muddle: there is nothing to review yet
and nothing to resume once it is done. It is one or the other now, and
what decides it is whether anything is left to answer — not whether it
was an exam or a study session, which have the same two states as each
other. A study session keeps going until every question is answered and
becomes the review at that point, without waiting to be handed in.
Repeat is offered either way. The questions worth sitting again are
worth sitting again now.
"Skipped" meant gone past, and was shown for questions in a session
still running that had not been reached. Those read "not yet answered".
And a timed block is now ninety seconds a question, set from the count
rather than asked for. Choosing a limit is a decision nobody has the
information to make — the pace belongs to the exam being rehearsed, not
to a preference — and a block sat at the wrong pace teaches the wrong
pace. Forty questions is an hour. An explicit limit is still honoured.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
The clock reaching zero submitted the attempt and replaced the exam with
an analysis, with no word about why. "It submitted itself" is the one
thing a learner must not have to infer. The answers go in immediately —
that part must not wait for anybody — and the screen holds on "Time's
up" until it is acknowledged, then goes.
The last question's control says "End block" in exam mode. It opens the
same dialog it always did, which names how many are still unanswered and
that they count as incorrect, so the warning arrives before anything is
handed in rather than after.
Four tests for the time accounting the previous commit changed, covering
what it is actually for: an exam closed with eight minutes left still
has eight minutes however long ago it was started; opening and closing
spends only the time it is open for and does eventually reach zero; an
exam closed at zero is settled on the next look, for when the tab goes
before the submit lands; and one with time on it is left alone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
It ran on a wall clock. An hour away from the tab spent an hour of the
exam on questions that were never shown, and every per-question figure
was a fiction — which is the number the whole analysis is built on.
Three things stop it now. The tab being hidden, which catches switching
away. An explicit pause. And, for the commonest case the other two miss
— the tab left open on the exam while the person is in another room —
an idle watch: three minutes with no mousemove, key, wheel, touch or
scroll and it asks "Still there?", with the clock already stopped by the
time the question appears. A stray pointer movement does not answer it;
somebody has to say they are there.
Three minutes, not one, and scrolling counts as activity: reading a long
vignette is minutes without a click, and interrupting genuine reading to
ask whether you are reading is worse than occasionally crediting a
minute nobody was there for.
The server was the other half. seconds_remaining computed from
started_at and total_time, so a paused client made no difference to what
the server thought was left. It reads the saved time_left now, which is
what the player decrements only while the exam is on screen, falling
back to the wall clock for progress saved before this existed.
And a five-minute warning, said once. An exam that ends without notice
is a scramble; one that nags is a distraction.
Reverts the exam-exit-submits rule from earlier in this branch, which
was built on the opposite premise and would have charged wall-clock time
and then graded an exam whose clock should simply have stopped. Leaving
suspends, in both modes, and the overview no longer promises a clock
that does not stop for a break.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Opening the analysis of a live attempt graded it whatever the mode. In
an exam that is a way to answer, look at whether it was right, and go
back and change it — the exam defeated rather than analysed. It reports
progress now: how many are answered, how long it is taking, and each row
as answered or not. No score, no percentage, and the donut counts how
far through it is instead of how much of it is right.
Study mode still grades live, because study mode marks each answer as it
is given; there is nothing here it has not already said.
Recommendations are withheld too, which is stricter than AMBOSS — they
show a dash for correct and then list the topics to go back to, which
says which questions were wrong by another route. A recommendation is a
verdict.
The withholding stops the moment the exam is over, submitted or expired:
settle_if_expired grades through the same function a manual submit does
and sets completed_at, and everything opens from there.
Tested on both sides, because this is an integrity rule and would come
back quietly the next time the live-analysis path was touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Start for an unsat session and Resume for a started one is a distinction
the learner does not have to make. The session exists the moment it is
created, so picking it up is resuming it whether or not a question has
been answered yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Study mode reveals each stem in the rail once you have reached it, which
is right there — it is how you find the one you want to go back to. Exam
mode inherited it, and it should not have: a paper's question-status
rail says which items are answered and which are marked, and no more.
Reading back the stems of what you have sat, or ahead to what is coming,
is not something the exam being rehearsed would allow.
So in exam mode no row shows text, including the one you are on, and the
numbers are squared off rather than circled to match. Covered by a test,
because this is an integrity rule rather than styling and would come
back the next time the two modes shared a component.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
The analytics table linked to /results/{attempt}?q=3 and the page ignored
the q entirely, so clicking the ninth row put you at the top of the
session to page through and find it again. Both the review and the
player honour it now. A session still running opens in the player at
that question, ready to be answered; a finished one opens its review
there.
The question column is pinned while the measures scroll past it. Five
columns do not fit a phone and barely fit a laptop, and the one you need
in order to know which row you are reading is the first — so it stays,
with the stem cut to a line and the whole of it on the link's title.
Repeat session is a dialog rather than a restart. Sitting all of it
again is rarely what anyone wants: the questions worth doing again are
the ones you got wrong and the ones you never reached, and mixing in
forty you already know turns twenty useful minutes into an hour of
mostly not. It asks which outcomes and how many, counts what is
available for each, and builds a session from exactly those — shuffled,
so repeating twice is not the same order twice.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Exit asked "Suspend quiz?" and offered Stay. Nobody presses Exit by
accident, nothing is lost — the answers are saved and the clock pauses,
which the dialog was explaining rather than deciding — and a
confirmation for a reversible act is a step, not a safeguard. One press
now, and if the save fails it stays put and says so, because leaving
then would lose the answers.
Resume and Start go straight in for the same reason. The overview that
asks whether you meant it belongs to opening a link to a session you
have not seen, not to a button you came to this page and chose.
The actions moved out of the page heading and into the card that holds
the result — Repeat session, Review answers, Resume session — because a
row of buttons beside the title puts the decision as far from the
figures it follows from as the layout allows.
Delete session stays, but not as a red button next to Resume. It throws
away answers the analysis is built from, and it is the one thing here
AMBOSS has no need of — sessions are made freely, so a mis-made one is
clutter worth removing. It is a quiet control at the end of the row.
The session rail was sticky at top:0 with height:100vh, which started it
underneath the 98px header and ran it past the bottom of the window, so
the end of the list and the scroll that would reach it were never on
screen. It measures from --app-header now, and a wheel over the list
moves the list rather than carrying on into the page.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Extraction wrote straight into `questions`, so a machine's first attempt
took a permanent id the moment it was produced. Ids come from a sequence
and are never reissued: every rejected draft burned one, and every draft
that needed fixing was sitting in the bank while it was being fixed.
A run now lands in a batch of drafts with their own table and their own
sequence. They are read, corrected and decided there, and `accept` is
the only place a Question is created — a copy rather than a translation,
because every field a draft holds is a field a question has, so nothing
is lost at the moment of acceptance.
Accepting is all or nothing, and everything is checked before anything
is created: a call that reports failure must not leave questions behind
from the drafts it got through first. My own test caught that — the
first question existed before the second draft was refused.
Readiness is reported for every draft rather than only on the attempt to
accept it, so a reviewer sees what needs work before opening anything.
A decided draft keeps its row and records what it became, so a batch
reads as a history of what was decided rather than emptying as it is
worked through. An acceptance cannot be undone from here: the question
exists, and deciding twice would make a second one.
No embeddings for drafts. A vector is for finding a question in the
bank, and a draft is not in the bank.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Two shapes, because a plan is asked to do two different things. Papers
are rehearsal: each block is drawn to the ABP's published weights, so
sitting one says something about how you would do on the day. Domains
are study: the board's twenty-four content areas in its own order and
carrying its own titles, each given the share of the plan the board
gives it on the exam.
Both were written, then run against the real bank, which found two bugs
a unit test on a clean fixture would not have. Domains 19 and 20 —
nephrology and genitourinary — both map to our "Nephrology & Urology",
so a question sat in two pools and was dealt twice; the deal now keeps a
record of what has gone. And chunking every question a domain has into
blocks of forty gave preventive care six blocks and the plan a hundred
and sixty, which is not a plan: blocks are shared out by weight, with at
least one per domain so nothing the board examines is left out.
Built on the live bank alongside what was already there: Boards: Full
Papers (12 × 40) and Boards: By Content Domain (27 blocks, 1069
questions). Nothing existing was touched.
Psychosocial Issues and Child Abuse and Neglect — 6% of the paper
between them — had no category of ours at all, so they could contribute
nothing. Both now exist, with sub-topics named from the board's own
subdomains, and all 24 domains map to categories.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
The number beside a folder in Topic reading was a question count while
the browser lists articles, so "Hyperinflammatory Sepsis 4" meant four
questions and opened onto no reading at all. It counts what it opens
now, rolled up over the subtree, and a branch with nothing to read in it
is not offered — a folder with a number on it is a promise.
The hover card could not be reached. Its body was pointer-events: none,
on the idea that a hint should not sit between the reader and the link —
but the card is offset below the link and never covered it, while the
pointer travelling down to Split view crossed a body it could not enter,
so no mouseenter fired and the hide timer closed it on the way. The card
takes the pointer now, with a bridge across the gap.
And clicking the words opens the card rather than the article. A
cross-reference is read mid-sentence, and navigating away to find out
whether it was worth following is the thing that breaks the thread; the
card's two controls — beside what you are reading, or a tab for later —
are how you go. That also gives touch a route, where hover has none.
Modified and middle clicks are still the browser's.
The listing sent content and sections for all 331 articles, 214KB of
prose a list never renders. It sends what a list needs, which is 21KB.
The footer sat wherever the content stopped, so a page still loading put
it halfway up the screen with background below it. The shell is a column
the height of the window.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
The tutor is handed the correct answer and the explanation and told it
may reveal them, which is why it has never been offered during a running
exam — require_question_access already refuses that, whatever anyone
sets. What was missing is the other half: an administrator can now
withhold it from study sessions too.
Enforced on the server rather than by hiding a button, because hiding a
button does not stop a request. Reviewing a finished attempt is not
"during" and is unaffected; the answers are shown by then anyway. If
Redis is unreachable the tutor stays on — nothing is revealed that study
mode does not already show, so the permissive direction is the safe one
here.
GET /teach/prompt renders the instructions against a stand-in question,
so an educator answering "why did the tutor say that?" can read them
rather than infer them.
And a handbook at /handbook, for anyone who maintains questions or
articles whatever access they hold. It answers the things that were only
in the code: that a question links to an article three different ways —
a further-reading row, a key point carrying an article and section, and
a [[id|label]] marker in prose keyed by id so renaming does not break it
— what the tutor is told, why a blueprint shapes a paper, why deleting a
question hides it, and why changing the embedding model invalidates
every vector.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
The boxing was structural, not cosmetic. AdminSection wrapped AdminPage
in a Settings panel while AdminPage brought its own .card wrappers — a
box inside a box — and SettingsPage.css patched the symptom with
.set-admin > div > .card { border: 0 }. Both sections are written as
Settings sections now, the patch is deleted, and AdminPage with it.
People: no more refetch-everything. The old page called loadData() after
every change, pulling users, models and settings together and
re-rendering the section, which is what read as a page reload — there
was never a location.reload() to find. A change now updates the one row
it touched from what the server returned. The list searches, scrolls in
a bounded box, and will not let you change or delete your own account.
Models: the selection is rebuilt rather than restyled. Seven cards of
rows, each with Default, Test, Disable and Remove, meant four buttons to
express one fact. There are two decisions, so there are two places now —
which models the site may use at all is an allow-list you set once and
fold away, and which one does a given job is a single choice made where
the job is named. A job with one model offers no choice, because there
is none to make.
The embedding model moved in with the models, since it is one, and
Search — which held nothing else — is retired.
Settings fills the window: the panel column scrolls inside itself, so a
short section no longer ends two thirds of the way down a tall screen
and a long one no longer runs off the bottom. Below 700px the page
scrolls as one, as before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Safety held one thing: rolling back the AI classification tag assignments. The
classifier is gone, so the panel had nothing left to be about and the section is
removed, along with the snapshot state and the rollback call that fed it.
Permissions — the other thing that section might have grown into — already have
a page in Access.
"Search and sign-up" carried two controls that live somewhere else: a Public
Registration toggle that Access and joining already owns, and an embedding model
field that belongs with the other models. The duplicate registration toggle is
gone and the section is now just Search.
The account menu no longer offers Administration. It pointed at /settings?s=people
— the same page the Settings entry above it opens — so it was two names for one
door. Settings already shows the site sections to an administrator.
Access: the branch tree opened its top level by default and ran to hundreds of
rows, which buried the image libraries below it. Every branch now starts closed,
opening one closes the one before it at the same depth, and the tree scrolls
inside a bounded box. Picking a different person collapses it again. A "Clear all
access" control removes every grant a person holds, including the everything
role, for starting over.
The standfirst is reworded to lead with what to do rather than with a definition.
Tests updated rather than worked around: the Settings test asserted Safety was a
section, and the Access test reached a child branch that is no longer open on
load. Both now assert the new behaviour, plus two new cases — that branches start
collapsed, and that opening one closes the previous. 346 frontend tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Four gaps, one change.
Articles could not belong to an exam at all — an article reached one only
by inference through its category, which cannot say that the same article
belongs to a basic-science step and a clinical one showing different
views in each. article_exam_links says whether it is in the group;
Exam.article_views already decided what is shown once you are there.
POST /exams/ wrote name, slug, sort order and active, and silently
dropped family, description and article views, so a new objective landed
in "Other" showing everything whatever was asked for. It writes what it
is given now, and PATCH can change it afterwards.
Membership was one link row at a time, which nobody would do for three
thousand questions. POST /exams/{id}/assign takes whole topics with
everything beneath them — questions and articles both — and is
idempotent, so widening a selection and running it again adds only what
is new.
And the point of all of it: a real paper is not a uniform draw. The ABP
publishes that 12% of a general paediatrics exam is preventive care and
2% is rheumatology; forty questions drawn evenly is forty coin flips.
exam_blueprints holds a board's published outline — its own numbering,
its headings, its weights — and blueprint_category_links maps it onto
our taxonomy rather than bending the tree to fit, because their outline
is arranged for examining and ours for studying.
The sampler uses largest-remainder, so twenty-four percentages still come
to forty questions, and a domain that cannot supply its share gives the
shortfall back to be spread over those that can — the paper keeps its
length and loses only accuracy, and the working is returned so the
shortfall is visible rather than silent.
Seeded from the ABP General Pediatrics Content Outline (Oct 2024):
structure and published weights only, no exam material. 120 lines, 22 of
24 domains mapped; Psychosocial Issues and Child Abuse and Neglect have
no category of ours and are reported rather than hidden.
Creating an objective is now an administrator's rather than a
moderator's: it appears in everyone's picker and scopes the whole bank,
which is site configuration, and it sits with the other site switches a
moderator cannot reach.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Question ids come from a sequence and are never reissued, and fourteen
tables point at them — attempts, quiz membership, exam membership,
media, article links, notes, favourites, feedback. Deleting the row took
all of that with it, so "restore" could only ever have meant typing the
text in again as a different question.
DELETE now sets deleted_at. The question leaves the bank, the builder,
search and every share path at once, because the exclusion lives in
general_question_predicate rather than at each call site. Restoring puts
back the same id, so everything that pointed at it still does. Erasing
for real requires the trash first and a moderator, and the confirmation
says what goes with it.
The trash page holds questions instead of tests. A test is a selection
you can remake in a minute; nobody wanted those back.
Used and withdrawn invite codes can be removed — an unused one is still
withdrawn rather than deleted, so it stays visible as having been issued
and stopped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
The section bar is 46px tall with overflow hidden, so an absolutely
positioned menu inside it was cropped to a strip and never appeared —
the same reason it looked wrong in a settings panel, which also clips
its own overflow. The menu is fixed now and the component measures the
button and places it, following resize and scroll.
In a settings panel there is no menu at all: the objective is stated
plainly with a Change button that opens the picker, which is an overlay
fixed to the viewport and so cannot be clipped by anything.
The taxonomy page no longer searches the question bank. Filing a
question is done where the question is; a search box on a taxonomy row
was a second, worse question bank.
The account page is gone. Settings opens on the account, so Account and
Settings were two doors to the same room; /account redirects. Admins get
an Administration entry in the person menu.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
It was still browsing the flat category list and the free-text keyword
tags, neither of which is how questions are arranged any more. A
discipline holding six hundred questions three levels down showed as
"(0)" because only what was filed directly on the node was counted.
/topics now walks the tree — discipline, sub-topic, condition — with
each node counted over its whole subtree, and any node can be quizzed
whole. /objective scopes every quiz to one exam, listing only objectives
that actually have questions. The keyword browse is gone with the tags.
Chat is where you answer, not where you read the teaching: explanations
are abridged to about a screenful, cut at a sentence, with markdown and
cross-reference markers stripped and a link to the site when there is
more. Per-option reasoning and figures are not sent — the bot never did,
and now says where they are.
Still read-only, and now says so: no account, no history, and a quiz
that lives only until it ends or /stop. `is_shared` remains the only
gate between an anonymous stranger and the bank, applied in one place.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Settings linked to a second dashboard with its own tab bar and its own
visual language. The admin sections are rendered in Settings now, under
headings that say who they are for — You, Content, The site — and each
has its own address, so People, AI models, Safety and Search are links.
/admin redirects into Settings for anyone who bookmarked it. AdminPage
takes a `section` prop and drops its tab row when embedded; it is loaded
lazily, so it is not in a learner's download.
Comments are gone: router, model, table and the half of the test file
that covered them. They were a discussion thread nobody was obliged to
answer, and feedback replaced them with a message addressed to whoever
maintains the question. The table was empty, so nothing was lost —
verified before dropping it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
The switcher opened the whole picker on the first click. Now it opens a
short menu: what you are studying for, the objectives you have been on
lately (one click to switch back), and a way through to the full list.
"All content" is gone. Studying for nothing in particular is not an
objective, and the whole bank at once makes the filters and the analysis
mean less rather than more. Saving requires an objective to be chosen.
Suspending a session now lands on that session's analysis, where what
has been answered so far is scored and Resume sits, rather than on a
list of every session you own. A course quiz still returns to its course.
lazyPage() replaces lazy() for every route and lazily-loaded component.
Each build fingerprints the chunk filenames, so a tab still holding the
previous index.html asks for a file the new image does not have and the
user meets "This page failed to load" for a page that is fine. One
reload per tab fetches the new index; a failure that survives the reload
is shown, not looped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
A copy button tells you nothing about what you are about to send. The
dialog names the session, counts its questions and shows the stem it
opens on, then offers the link with Copy and the places people actually
send one — email, WhatsApp, Telegram.
Sharing is the administrator's to allow. GET /quizzes/share-policy is
asked before the dialog offers to make a link, so a switch that has been
thrown reads as "not offered" rather than as a button that fails when
pressed. A link already issued keeps working either way.
Removes the second, lesser share block that sat inside the save panel.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
The quiz player is a box the height of the window. The question used to
scroll the whole page, which took the session rail and the navigation off
screen exactly when you wanted them; now each column scrolls on its own
and the bar — Exit session, Previous, Next, Review — stays put.
Two site-wide switches, together under Settings → Site policy because
both are the administrator's and both apply to everyone:
* Sharing can be turned off. That stops new links being made; one
already handed to somebody keeps working, since revoking it would
break something a learner has already given away.
* Sign-up can be made invite-only, with single-use codes carrying a
note of who each is for and, afterwards, who it let in. A spent code
is kept rather than deleted — that record is the point of invite-only.
The alphabet has no O/0 or I/1/l, because these get read aloud.
The registration form asks for a code only when the site needs one, via
an unauthenticated policy endpoint — it has to know before there is an
account to ask with. It never says whether a given code is valid before
the account exists, which would make it somewhere to guess them. The
first account is always allowed, or a new install would lock itself out
before an administrator existed to issue a code.
Flags fall back to their defaults when Redis is down, in the safe
direction each way: sharing keeps working, sign-up does not silently
open.
Found on the way: the registration form's three labels named nothing —
no `for`, no wrapping — so a screen reader announced unlabelled boxes.
Backend 261/261, frontend 328/328.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Comments are gone. A thread under every question was a discussion nobody
moderated, and what it was used for was telling an educator something was
wrong. That is now feedback: a private report, carrying the question id,
that someone is expected to act on.
* Give feedback sits in the question bar's new "more" menu, beside Save
and Share — occasional actions, folded away rather than each taking a
slot in a bar read on every question.
* An educator gets a badge of what is outstanding. Each row names the
question and opens its editor, where the report sits beside the field
it is about; reply, resolve, reopen or delete from there.
* Resolving keeps the report. A question with a history of the same
complaint should visibly have one; deleting is for the ones that were
never about the question.
* A granted educator sees only their own branch. The badge answers
quietly with zero for someone with no access, so the header can ask
without first working out who is asking.
The question bank is now the Qbank: create a session, and the last three
with Resume. Its facets, tag tree and create-a-quiz were a second copy of
the custom-session page; marking and folders belong in the player while
you are sitting a question. Import and export moved to the question
manager, which is the one place questions are managed, and which now has
a Preview that opens over the list instead of a page you have to come
back from.
Fixed while there: a session in progress analysed as 0/0 with an empty
table, because the analysis read attempt_answers — written on submit —
while the session list counted the saved progress. They read the same
thing now. The category trail is gone from the player: it named the
answer's own topic and led out of a session part-way through. An option's
reasoning opens on click and closes on the next one.
Backend 253/253, frontend 323/323.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
The study objective was a <select> capped at 220px, so it clipped
"Pediatrics Boards (2948)" mid-word, and it offered "USMLE Step 2 CK (0)"
as though it were ready — choosing it would have scoped the bank to
nothing. It is a dialog now: grouped by family, searchable, each option
saying how many questions stand behind it, an objective with none shown
but not selectable, and the change confirmed rather than applied the
instant the pointer crosses an option.
The section bar outran its width because four of its entries were not
about the material. Home is the signed-out landing page and is gone from
it; Dashboard, Account, Settings and signing out are about the person
and now sit behind one account control. Nine entries instead of
thirteen, which is the actual fix — the arrows added earlier make a long
bar usable, they do not make it right.
Frontend 320/320.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
`last_attempt_id` counts finished attempts only, so a session with a
live attempt and none completed was addressed as /sessions/q/{quiz} —
a route with no attempt id in it. Delete then sent
`/attempts/undefined` and nothing happened; Review answers pointed at
the same nothing. The page now uses the attempt its own analysis names,
and the rail prefers the live attempt over the last finished one, so the
address has an attempt in it to begin with.
The empty band above the footer was `padding-bottom` reserved for a save
bar that is sticky but still in flow and is the last element on the
page — so the padding sat *under* the bar rather than behind it. Gone
from the custom-test and question-edit pages. The question manager keeps
its padding: that bar is `position: fixed` and genuinely overlays.
Frontend 318/318.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
`[[403|urethritis]]` became `<ArticleLink slug="403">`, which built the
href `/articles/s/403` — the slug route — and asked the preview endpoint
to resolve "403" as a slug. Neither exists, so the hover card never
appeared and the link 404'd. Every one of the 2,150 links is written by
id, because an id survives a rename and a slug does not, so this was the
whole library and not one article.
resolve_slug now takes an id as well as a current or historical slug,
and the link addresses the article directly when it is written by id.
Also: a view of one section no longer prints a heading repeating the tab
above it. "Short" over a heading reading "In short" says the same word
twice, and hid the only content behind a chevron. No collapse control
over a single section, and no contents list of one entry.
And a horizontal-overflow guard that only half worked: `overflow-x:
hidden` was on body but not html, so the browser could still propagate
the overflow to the viewport and scroll the whole page sideways — which
is how the navbar came to be clipped mid-word. The exam name now
truncates with an ellipsis instead of clipping.
Backend 242/242, frontend 316/316.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
The section bar hid its scrollbar and faded its right edge. A fade is
not an affordance: a vertical wheel does not move a horizontal overflow,
so with a plain mouse the last links — Courses, Settings — were simply
unreachable. ScrollStrip gives it arrows that appear only while there is
somewhere to go, maps the wheel onto the axis that actually scrolls, and
brings the current page into view on arrival. It renders as whatever it
replaces, so the bar stays a <nav> landmark rather than becoming a div.
The library's column browser had two faults. Its columns were sized
`clamp(220px, 32%, 320px)` — a percentage against the *visible* width,
so they shrank as more opened and the strip understated how much there
was to scroll. And opening a category added a column off the right-hand
edge with nothing scrolling it into view. Fixed width now, the newest
column scrolls itself into view, a spacer supplies the end padding that
a scroll container does not count, and the scrollbar is visible because
here it is the only affordance there is.
Guarded scrollIntoView and scrollBy — jsdom has neither, and the first
version of this broke seven navbar tests by assuming them.
Frontend 316/316.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Three things asked for.
New question opened a cramped modal in both places it was offered — the
question bank and the question manager — while Edit opened the full
page. Both now go to /questions/new, carrying where they came from so
the back link returns there. CreateQuestionModal had no callers left and
is gone.
The bank's "Study" action is "Preview", and shows the question whole:
the correct option, the per-option reasoning, the explanation and the
key points, all at once. Making someone answer first is the right shape
for practice and the wrong one in the bank, where the question is being
inspected rather than sat. Option reasoning and the explanation render
as Markdown there now too, instead of raw text.
Option explanations get the same formatting toolbar as the stem — an
option's reasoning is prose as well, and often carries a list or table.
Frontend 309/309.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
323 of 323. It was 413 free-form sections under 242 different titles,
most of them one block called "Management" holding everything from the
presenting complaint to discharge advice. They now read Clinical paths →
Diagnosis → Management, with Prognosis and outcome where the source
actually said something about course — 95 articles earned one, the rest
were left without rather than padded.
Six batches, and the gate earned its keep: it caught a dropped
`[[206|epilepsy]]`, a duplicated "24", a repeated sentence that doubled
two numbers, and several numbered lists flattened into bullets. Two
articles were refused on a first import and passed on a second — I had
imported a file its agent was still correcting.
Then the linker re-run over the moved text: 2,150 links, 0 broken, 0
nested, 0 escaped. Instructions to the reader across the whole corpus
are down to 21 from 819 + 71.
Backend 242/242.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
The first version of the check flagged it alongside 'No protocols are
available'. The difference is whether the sentence is about the medicine
or about the source. Two false positives down to none; the two real ones
are both Pediatric Dentistry, whose source was a disclaimer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
A section that promises an answer and then says 'No presentation details
are available' is worse than a missing one. Reported rather than
refused: a one-line prognosis is often the whole honest answer, so the
length alone cannot decide it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Comparing marker and number *sets* reported 'lost -, added -' whenever a
token merely appeared a different number of times — printed on the line
explaining why the article was refused, which read as a contradiction.
It counts repeats now: 'cross-references changed (duplicated 396x1)',
which is what actually happened to Cystic Fibrosis.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN