Commit graph

298 commits

Author SHA1 Message Date
Daniel
52ef7acdea feat: articles are collectible, and Summary is a toggle
A library holds both articles and questions now. It held only questions, so the
bookmark on an article had nowhere to write and stood in for the questions
filed under the topic instead — which is not what a reader who saved the
reading asked for, and left a topic with no questions unsaveable. Its own
table rather than a nullable column beside `question_id`: that shape allows a
row with both or neither, and every read then has to say which kind it is
looking at.

Which libraries already hold an article is now asked of the server, as one
question. It was kept on the device because the API could not answer, which was
wrong on the second machine and silently so. Putting one back is the same
control rather than an undo somewhere else.

"Short" is called Summary, because that is what the section is called, and it
is a toggle rather than one tab of three — the whole topic, or the part of it
worth revising, which is a different kind of choice from Long versus Clinical.
It names its own state, so a reader can tell why two thirds of the contents are
not there. The stored variant stays `short`: renaming it would be a data
migration to change a word on a button.

Also: `litellm==1.28.13` has been withdrawn from PyPI, so requirements.txt
could not be edited at all without the pip layer failing to rebuild — which is
what blocked pinning Pillow. Repinned to 1.53.1, the nearest still published;
the three things we use are unchanged in it, and both suites pass on the new
set. Pillow is pinned properly now rather than arriving through PyMuPDF.

One consequence, handled: `litellm.utils.get_valid_models()` now returns
nothing unless a provider's own API key is in the environment, and ours is a
proxy. That branch is only reached when no proxy is configured, and it now says
so instead of answering with an empty list that reads as "this site has no
models".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 14:52:20 +02:00
Daniel
cedab6e91e feat: thumbnails for uploaded images, at two widths and no others
A question's stem image is two to four megabytes of scanned radiograph, and a
media grid is forty of those pulled at full size to draw forty postage stamps.
`?w=256` and `?w=640` now serve a WebP copy instead, made on the first ask and
kept beside the original under `thumbs/{width}/{key}` — same bucket, so nothing
new has to be configured for them to be backed up or thrown away.

Three rules, all about not making this a way to spend the server's afternoon.
Those two widths and no others: any other `?w=` is refused with a 400, because
an endpoint that resizes to whatever the query string asks for is a CPU sink
anybody can point at. Never enlarged: a 180px image asked for at 640 is served
as it is, since scaling up invents detail and charges bytes for it. And best
effort throughout — a PDF, an SVG, a truncated upload or a file that is not the
image its name claims all serve their original rather than failing, because a
preview must never take down the page that wanted it.

Authorisation is unchanged and still runs first: a thumbnail of a file you may
not read is a file you may not read. They stay `private, no-store` like
everything else here — they are behind authentication, so there is nothing for
a shared cache to do with them, and the win is the byte count.

EXIF rotation is read before anything measures the image. Every phone stores a
portrait photograph sideways with a flag; a thumbnail made without reading it
is a sideways thumbnail.

Pillow rather than sharp, which is Node. It is not pinned in requirements: the
pin invalidates the pip layer, and that layer no longer builds because
litellm==1.28.13 has been withdrawn from PyPI. Re-pinning litellm is a
deliberate upgrade of the AI layer, not something to slip into this. Noted in
the TODO.

Also: the article hover-card excerpt was printing `[[288|eczema]]` at readers.
The generic markdown-link rule does not know our own cross-reference syntax, so
it left the brackets and the id behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 08:49:20 +02:00
Daniel
f94bdddaf5 feat: the reading page full width, with split view as a column rather than a page in a page
The article was capped at 1080px and centred, so most of a wide window was
margin while the prose ran as a thin ribbon down the middle — and following a
cross-reference opened a whole second reader, rail and all, inside half of
that. Each further click started further right.

The rail is flush against the left edge of the window now, sticky and full
height with its own scroll, and the article takes the rest. Split view is a
third column of the same grid at a fixed width, so the article reflows once
when the pane opens and never again however deep you go. The pane renders the
reader bare — no rail, no depth switch, sections already open — because a pane
is a column to read, not a page to navigate. It keeps a trail: following a
reference from inside it replaces its contents and offers a way back, since
the only way back before was the browser, which takes the article with it.

The offset the rail starts at is measured off the navbar rather than written
down, because that bar collapses as you scroll and a hard-coded number would
leave a band of page above the rail or hide its first line.

The depth switch moved up beside the sections, where it reads as what it is —
our high-yield — and the rail follows it: a contents list naming sections the
body is not showing is worse than no contents list. A breadcrumb in the sticky
bar tracks the section under your eye, the rail is collapsible and remembers
it, and the toolbar carries text size and save-to-a-library.

And the summary no longer prints `[[288|eczema]]` at the reader. At the head of
an article it is prose in context, so it renders with its references live; in a
card or a list row the card is itself a link, and a link inside a link swallows
the click that was meant to open the article — so there it is flattened to its
labels.

Full-bleed and the session chrome are now separate claims. A reader wants the
window; a reader does not want the section strip taken away, because every link
on it is somewhere they may legitimately go mid-article. That is not true
inside a session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 08:43:04 +02:00
Daniel
5d59e00144 refactor: remove per-question sharing
`Question.is_shared` defaulted to 1 and was only ever set by a route nothing
called, so in practice it divided the bank into "everything" and "everything,
plus your own private ones" — a distinction that cost every recommendation
denominator a join and never changed an answer. Who may reach the bank is the
site's own access rules; who may manage a question is the category grant tree.

So the two predicates the whole bank was built on are now the same thing, and
say what they actually mean: a question is out of reach if it has been deleted
or belongs to a course. Nothing else. The column is dropped, the route that set
it is gone, the bulk "share" action with it, and the Private tile and pill go
from the question manager.

The tests that turned on it have been rewritten rather than deleted, because
the rule they were really about survives: revoking a question still revokes
every session carrying it — by deleting it, which is the only revocation left.
Several others named a category holding exactly two reachable questions and
then answered two particular ids; that category holds four now, so they name
the pair instead. A session's own sharing flag is untouched — that is a
different thing, and it is still how a session is handed to somebody.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 08:42:51 +02:00
Daniel
1df520c95d chore: remove QuestionEditModal
Nothing had imported it since editing moved to the full page; the only thing
that still referred to it was its own test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 08:18:12 +02:00
Daniel
1d55b76998 feat: browse the library as columns rather than an expanding tree
A tree you expand in place grows downwards, so opening a branch six deep
pushes everything under it off the screen and the thing you were looking at
ends up somewhere you have to hunt for. Columns grow sideways instead: each
level is its own list, the branch you opened stays lit where you left it, and
the path you took is readable straight across the headings.

The strip scrolls so the column just opened is at the right edge with the one
before it still beside it — two levels of context, and the ancestors clipped
off the left with their headings legible, so going back is a click on
something visible rather than a Back button. The strip is the scroller, not
the page, so descending never moves the page under the reader.

Not wired into a page yet; the reading library is being rebuilt alongside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 08:13:24 +02:00
Daniel
260e04e77b fix: a block bar you can find, and a rail that says which session this is
Exit, Pause and End Block are the loudest decisions on the screen and were
three grey pills the same size as everything else. Each is an icon and a word
now, bold enough to reach for without reading, and on the exam's navy they are
white with End Block in red.

The rail said "Session questions" on every session ever sat. It says which one
this is instead — the mode, the title, how far through — so a repetition, which
is titled as one when it is built, is visibly practice rather than a score that
counts. The Review badge moved there with it; two of them on one screen saying
the same word about the same block was one too many.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 08:10:36 +02:00
Daniel
b06560c515 fix: the ear to ENT, gynecology to its own branch, and Surgery back into medicine
Three decisions the owner made about where content lives.

Otitis media was split across two roots — the acute form and mastoiditis under
Infectious Disease, effusion and the recurrent form under Otolaryngology — so
somebody looking for "the ear" found half of it twice. All of it is in ENT now,
with the three forms as children of Otitis Media rather than four siblings
saying nearly the same thing. The questions keep a link back to Infectious
Disease; they are still infections.

Surgery was a root with 34 questions, no children and no ABP domain, so that
content was invisible to the blueprint entirely. Nothing about those questions
is "surgery" in the sense the outline uses: they are bowel obstruction in a
neonate, a burn, a torsion, a swallowed coin. Each is now filed under the topic
that names its condition — read off the stem and the keyed answer one at a
time, because no rule derives that and a rule that guessed would be worse than
the root they are leaving — and every one keeps Surgery as a subject tag, which
is the axis a discipline belongs on.

Gynecology is its own branch, mapped to domain 20 as the outline has it at
20.B. It was living under Adolescent Care, so Genitourinary drew from a pool
missing a third of what it should hold. The sexually transmitted infections
beside it stay where they are: those are 3.B, and the outline means them there.

Four topics were made for questions that had nowhere honest to go — Burns,
Foreign Body Ingestion, Procedural Sedation, Chest Wall Deformity.

569 topics, 28 roots, 2,924 questions, none orphaned, none without an organ
system, and no two ABP domains sharing a category.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 08:06:59 +02:00
Daniel
e2e558c294 fix: exam chrome you can read, a lab panel you can use, and Next that ends the block
The exam bar had a navy background and dark navy text on it, so the two
numbers it exists for — which item, which block — were invisible, and the
rail's own hide handle was a white disc with a white chevron inside it. The
round arrows were drawn by the chrome while the labels still carried chevrons
of their own, giving every control two. All of it is flush now: the player's
page gutters were still there, framing a full-window exam in white.

The block's tools are the block's. Labs moved to the bar across the top, so the
copy of it under the stem is gone, and reading a paper aloud is not something
an exam offers — Listen, Listen through and the highlight clear are study's.

Next carries on being Next at the last item. There is nowhere further to go, so
where it goes is out: it ends the block. Disabling it there left the hand that
had pressed it four times with nothing under it. End Block stays where it is —
two controls, one outcome, because what you reach for at the end of a block is
not always what you have been pressing all the way through it.

The lab panel was two components sharing one class name, so the list's layout
leaked onto the column holding it and its controls — written for a dialog —
came out as bare browser buttons. Now it is its own thing, wide enough that
the reference range no longer runs off the right-hand edge, and stacked so
every value is legible at any width. The disclaimer about ringing your own
laboratory is gone; nobody sitting a board question is going to.

And nobody edits the reference table from inside a session. An educator
answering a question was one keystroke from changing a range every learner
reads. It lives in Settings → Tools now.

Also: the full-window session shell is claimed by the player rather than
guessed from the URL — /study/42 is also the screen that asks whether to start
one, and that screen is an ordinary card that was being left marooned in an
empty window with its own navigation stripped. And "Multiple choice" above five
lettered options labelled the obvious.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 08:01:01 +02:00
Daniel
b2a353cf58 fix: one topic per idea, and one ABP domain per topic
The complaint was that biostatistics and research ethics were the same thing
filed twice. They were — and under them the topic tree had no home for
research, biostatistics, ethics, patient safety or quality improvement at all.
Domains 23 (Ethics, 2%) and 24 (Patient Safety, QI and Research Methods, 2%)
both pointed at the single row "Professional Topics", and 19 (Nephrology, 3%)
and 20 (Genitourinary, 3%) both claimed "Nephrology & Urology". Six per cent of
the paper rested on two rows that could not be told apart, so an adaptive
session could not weight either half of any of those pairs.

Every domain now owns its own topics, with none shared: 19 takes the kidney
topics and 20 the genital and urological ones, and Professional Topics gains
Ethics, Patient Safety & Quality Improvement, and Research & Biostatistics. A
blueprint-shaped paper draws from all 24 domains with no shortfall; two of
them — Psychosocial Issues and Child Abuse and Neglect, 3% each — had a pool of
literally zero before.

And 93 topics that were one idea under two names are now one: 25 "Pediatric X"
leaves under a root already called X, on a site that is paediatric throughout;
66 plain synonyms (Nocturnal Enuresis and Enuresis, Down Syndrome and Trisomy
21, Eczema and Atopic Dermatitis, Chlamydia filed once per discipline that
mentioned it); and two abuse topics onto the ABP's own headings. Every merge is
written out by id *and* name and refuses to run if either has moved — no
normaliser, no fuzzy key, because a key that ignores digits puts Trisomy 18
into Trisomy 21. Questions move before any row is deleted, and a cross-filing
that would have been lost becomes a link instead.

566 topics, 2,924 questions, none without a primary topic, none without an
organ system. Backups either side in backups/topics_*_reorg_*.sql.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 07:42:00 +02:00
Daniel
6556d25a11 fix: the exam's top bar stays put while the question scrolls
Only the question scrolls in the middle column now. The bar carrying the item
counter and the tools was inside the scrolling area, so it went up with a long
stem — exactly when you wanted it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 07:38:58 +02:00
Daniel
bb72aa5f60 feat: AMBOSS exam chrome, review as a study session, and no silent submissions
The exam player takes the window. The shell was sized against the header with
a number that did not include the navbar's own 32px of margin, so the block bar
— the one thing on the screen that must always be reachable — sat below the
fold and had to be scrolled to. Exam mode now hides the site chrome entirely
and is the viewport, which makes the arithmetic honest and matches what a board
looks like: item and block in a box at the left, the two arrows in the middle,
the tools at the right, the question-status rail down the side, and the clock,
Pause and End Block along the bottom.

Shortcuts is gone from the bar, and the labs open into the column beside the
question in both modes rather than a box over it.

Nothing is handed in behind the learner's back. The clock reaching zero stops
the block and says so; closing Time's Up submits, and the player stays put
showing the answers, which is the review. The server no longer settles an
expired attempt at all — listing sessions used to mark any paper whose clock
had run out, so opening a page could score a block the learner had walked away
from, and the first they knew of it was a result.

Reviewing an attempt is now the player with the answers in, not a dropdown and
a card. Same rail, same layout, same labs, same way out — and on a phone the
same burger opens the same question list, from one shared rule about which
routes are a session.

Also: the rule-out toggle sits beside its option instead of pinned to the far
edge of the card, so an option box is as wide as its own words; the voice
picker leaves the player, since a reader's voice is a setting and not a
decision to retake every session; figures carry no invented "Figure 1" — a
label is what prose refers to, and the backfill knew of no prose, so 346 of
them said only that an image was an image; and the landing page shows the two
modes happening rather than promising six things in a sentence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 07:37:58 +02:00
Daniel
f8a81ef937 feat: the question gets the room — a rail that closes, labs beside it,
three controls at the foot

The rail is a sidebar now rather than furniture: a long stem wants the
width, and the list is one press away on a handle that sits where the
rail was. Remembered, because somebody who put it away meant it.

Labs moved off the top toolbar onto the question's own bar, beside the
attending tip, and open as a column next to the case instead of a dialog
over it — a reference range is read while re-reading the stem, and a
dialog covers the thing it is for. Below 1150px there is no room for a
third column and it goes back to being a dialog.

The calculator is the exam's alone. A study session has no clock to beat
and no arithmetic to do under one; both it and the old Lab values button
were in that toolbar because the exam player and the study player were
one screen.

And the bar at the foot is three things: leave, back, on. The question
count and the list behind it are in the rail, and on a narrow screen
behind the site's own menu button — repeating them crowded the one row
that has to stay legible under every question.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 07:11:15 +02:00
Daniel
acb954972a feat: the session takes the window, and favourites leave collections
The section bar is gone while a session is open. Every link on it leaves
the session you are sitting, and the 46px it takes is 46px the question
wanted — which is the whole argument for the player being a fixed-height
box in the first place. It comes back when you leave. The page's gutters
and its 1200px cap go with it: those are for reading, and a player is a
box whose columns scroll inside it.

Skip and Next are two words again. They are not the same decision —
moving on from a question you have not answered is a choice — and the
only reason to merge them was a pair of arrows at the top of the screen
that said Next for both. Those arrows are gone instead: the bar at the
foot is sticky and carries Prev and Next already.

Favourites are out of Collections. They are marked while sitting a
session and sat again from the builder, filtered by system or discipline
like anything else, so a card in Collections was a third place to meet
the same star and the one furthest from where it is used. Mark is called
Favourite everywhere now, including the builder's own filter.

AI Mode is a page: a rail grouped by age in whole local days rather than
elapsed hours — a chat from 23:00 last night reads as yesterday — a
centred ask box, and starting prompts that are all questions a library
can answer. Attach was left out rather than drawn: there is no endpoint
behind it, and a button that does nothing is worse on a page whose whole
argument is that it only says what it can source.

Two real defects turned up in that work. A brand-new conversation's first
question was wiped by the fetch that followed creating it, which was a
flicker before and fatal for a question arriving from the search overlay.
And `updated_at` is serialised naive, which JavaScript reads as local
time and can slide a chat a whole offset into the wrong day; handled in
the page, but the honest fix is timezone-aware timestamps on the API.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 07:07:44 +02:00
Daniel
5f121533e9 feat: one box that either searches the corpus or asks the model
Ctrl+K anywhere. Searching and asking are the same act from where the
learner sits — they have a question and want it answered — so which one
happens is a toggle rather than two destinations to choose between before
typing. It opens over whatever is on screen, because a question arrives
while you are reading something and having to leave that page to ask is
how the question gets dropped.

Search keeps its furniture: suggestions, the history of what was asked,
and the keys spelled out. Space fills the box with a suggestion rather
than following it, so a near-miss is edited instead of retyped. AI Mode
drops all of that — a list of half-remembered searches is not help while
writing a question — and gains a clear button and a submit arrow.

Submitting in AI Mode does not show results. It hands the question to the
conversation at /ai?ask=, which is where the follow-up will be asked from
anyway.

The header's search box becomes the door to it rather than a second box
beside it: two fields that do nearly the same thing is how people learn
to trust neither.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 06:56:34 +02:00
Daniel
2b5262d255 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
2026-09-12 06:50:10 +02:00
Daniel
77c09db057 feat: the objective is not optional, and the site can count itself
Choosing what you are studying for has no way past it now but to answer.
It decides which questions exist, how relevance is weighted and what
readiness measures against, so an account that never answered it was
being shown the whole bank by accident rather than by choice.

What is guarded instead is asking a question that cannot be answered: if
the list of objectives fails to load, or there are none, nothing is shown
at all. A modal with no options in it is not a question, it is a locked
door.

GET /api/public/stats, unauthenticated, so the landing page can state what
there is rather than what someone typed into the markup months ago — a
number written into a page goes stale the week after and nothing breaks
to say so. Counts only, and only of published material: how much there
is, never what it is, so there is nothing here to walk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 06:40:48 +02:00
Daniel
16aed6b6b0 feat: Cap on its own host, hints per topic, and an objective is asked for
Cap moved from /cap/ under this app to cap.pedshub.com, so anything else
on this machine can use the same instance. Caddy terminates it, the
backend keeps verifying over the compose network rather than going out
and back, and the widget endpoint is configuration rather than a path
baked into the component. Verified: a challenge is issued on the
subdomain, and a token that was never issued is still refused.

"Correct using hints" is now a per-topic figure. The knowledge profile's
accuracy bar was two-tone because /study-tools/recommendations carried
only `answered` and `correct`; the hint count existed lifetime-wide but
never per topic, and inferring one from the other would have been a
different set of answers drawn as though it were this one. The column
was already on attempt_answers, so it is a group-by, and the bar is
three-tone as the reference has it.

And the objective is asked for. It decides which questions exist, how
relevance is weighted, and what readiness measures against — and it was
possible to sit a whole board paper without ever being asked, because no
objective quietly means the entire bank. That is a reasonable default and
a poor thing to arrive at by accident. Five of six accounts here had
never set one.

It can be declined: "everything" is a real answer, and trapping somebody
behind a modal because a list failed to load would be worse than the gap
it closes. Declining is still a choice made, which is the point.

Also in this commit, from the exam-player work: Show answer in study mode
that reveals without recording an answer, review keyed on the attempt
being closed rather than every question being answered — a block that
timed out with nothing answered is over too — and the exam top and bottom
bars. That work found something worth knowing: the exam player is *served*
questions with no correct answer and no explanation, so review cannot
un-hide what it never had, and the player refetches the marked version
once the attempt closes. Nothing is revealed while a block is running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 06:24:23 +02:00
Daniel
4ca7f6b1f2 feat: Cap replaces hCaptcha, self-hosted beside the app
Proof-of-work rather than a puzzle, and — the reason for it — nothing
about the person signing up is described to a third party in order to let
them in. Turnstile and then hCaptcha were both here; both told Cloudflare
who was at the door.

The `cap` service runs on the compose network with its own Redis
database, kept apart from the app's so a flush of one cannot clear the
other's challenges. The widget talks to /cap/ on this origin, proxied by
the frontend's nginx, so the browser reaches nobody else either. Caddy
passes the whole host through to that container, so it needed no change.

Two things that had to be found rather than read:

Cap's key API is undocumented. The routes are `/auth/login` and
`/server/keys`, and the Bearer value is base64 JSON of `{token, hash}` —
not the session token itself, which is why the obvious call returns
"Malformed session token". The site key and secret were created that way
rather than by hand in a dashboard.

And an nginx proxy_pass whose target is a variable passes the URI through
untouched: the trailing slash that strips a location prefix on a literal
target does nothing. Cap was being asked for /cap/<key>/challenge and
answering NOT_FOUND until the prefix was stripped by an explicit rewrite.

Verified end to end against the running service: a challenge is issued
through the public path, and a token that was never issued is refused
rather than waved through.

Also here: the register modal's Name and Email were bare labels that
neither wrapped their input nor named it, so a screen reader met two
boxes with no names and clicking the word did nothing.

And the knowledge profile paginates ten to a page and expands each row to
its two bars beside the next step. "Correct using hints" is missing from
that bar because /study-tools/recommendations does not carry it per
topic — inferring it from the lifetime figure would be a different set of
answers, so the bar is honestly two-tone until the backend offers it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 06:14:14 +02:00
Daniel
91b8e24d6b feat: hCaptcha replaces Turnstile
One verifier, backend/app/services/captcha.py, and one widget,
components/Captcha.jsx. There were two copies of each and they had
drifted: the register widget loaded the script itself while the landing
one relied on a page-level effect elsewhere in its file, and on the
backend auth failed *open* on an unreachable Turnstile while contact
failed *shut*.

Both failure modes were kept rather than one quietly chosen, as an
explicit `fail_open` argument with the reason written down: an outage that
stops people creating accounts costs the site its users, while an outage
that bounces a contact message costs the sender one retry.

An unconfigured secret still skips verification entirely, as before, so a
site with no keys keeps working.

The keys in .env are empty. The Cloudflare ones there were live and are
now dead, so **there is no captcha on register or contact until hCaptcha
keys are issued** — this is not a state to leave a public site in.

Also corrected on the way: docs/frontend.md still documented
`login(email, password, turnstileToken)`, whose third argument had
already gone from AuthContext.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 05:56:44 +02:00
Daniel
dd71bed184 feat: adaptive weighs the paper as well as the learner, and Session
analysis is a real tab

Adaptive selection knew what you were weak at and nothing about what the
exam is made of, so being weak at something worth 5% of the paper ranked
the same as being weak at something worth 1%. Every score is now
multiplied by the weight the board publishes for that topic's domain —
the same `exam_blueprints.weight` behind the Relevance column.

A topic the blueprint does not cover takes the median published weight. A
zero would make unmapped material unreachable and the highest would make
it the priority; neither is a claim the blueprint supports. With no study
objective the multiplier is absent and selection is about weakness alone,
exactly as before.

Weight scales weakness, it does not replace it: a topic you are certain of
does not surface because it is worth 5% of the paper, because (1 −
accuracy) is near zero and no multiplier rescues that. docs/adaptive-
sessions.md says all of this, including what is still open.

Session analysis is the third tab rather than a link out of the page —
two of the three used to change what you were reading and the third took
you somewhere else. The tab bar is one component both routes wear,
AnalysisSessionPage's body is a component the tab renders in place, and
the tab lives in the address so a link opens where it says.

Two things that were wrong turned up in that work: a session nobody had
sat showed 0% in the figures and "0% correct" in the donut — two separate
statements of a score on a session that had none — and the old third tab
disappeared entirely for anyone with no attempts, so the strip silently
changed shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 05:54:09 +02:00
Daniel
7c41dd20ac docs: record player review mode from the finished-exam screenshot
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 05:47:59 +02:00
Daniel
3a94d89e7c feat: a solid highlight, a five-minute warning, and the adaptive algorithm
written down

The yellow was a gradient stripe under the x-height, which reads as an
underline rather than a highlight and all but vanishes on a wrapped line.
It is a solid band now, the way a highlighter leaves one.

A block that ends without warning ends on whatever question you happened
to be reading. Five minutes out it says so — once, because a warning that
returns every second is a warning nobody reads.

And docs/adaptive-sessions.md, because "prioritised by impact" was a
phrase with no written meaning. It says where the code is, what the three
rules are — unanswered first, weakest topic among those, then wrong ones
oldest first with the category damped so twenty questions do not all come
from your worst subject — and, as plainly, what it does not do: it is not
weighted by the exam blueprint. Weakest and most-of-the-paper are
different questions and we answer only the first. The refinement that
would combine them is written down too, with the column that already
holds the weights.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 05:47:30 +02:00
Daniel
7ba37561e4 feat: the exam player as the reference has it
The incomplete-block warning is the one from the screenshot: a red
heading that says the block is incomplete, the count of unanswered items,
the sentence about resuming not matching exam day, and End Block against
Remain in Block. My version asked the question in my own words and led
with the wrong button.

Pausing says "Exam Paused" and offers Return to exam. Nothing else — the
warning about real exams is somebody else's disclaimer, not ours.

Exit session asks "Are you sure you want to end this session?" before it
goes, rather than going.

Time's Up says what it is and the button says Close, which is the only
thing left to do: it is already handed in and marked, and Close lands on
the session's analysis.

One name for one action: the bottom button read Skip on an unanswered
question and Next on an answered one, while the arrow an inch above it
said Next for both.

And the rail shows stems again once the block is handed in. Numbers while
it is being sat — reading ahead is not something the exam being rehearsed
allows — but there is nothing left to protect afterwards, so the review
reads like study mode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 05:43:25 +02:00
Daniel
1959534754 docs: record the 12 Sep screenshot specs
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 05:39:56 +02:00
Daniel
6240a1c9da fix: put the disciplines and diseases back, and order Performance as shown
Retiring three vocabularies at once was my call and the wrong one. Keyword
had to go — it was the old route to an organ system, which a topic now
carries, and that took Systems from half the bank to all of it. Subject
and disease went with it on the argument that the topic tree says the same
thing. It mostly does, and "mostly" is not a reason to remove the
vocabulary people had learned to filter by.

203 subjects and 2,275 diseases are back, with their 14,029 links, and the
Disciplines and Diseases pickers with them. Keywords stay retired.

The backup I wrote before deleting was not where I said it was:
`./backups` is mounted on db-backup, not on backend, so the file went with
the next container rebuild. The rows came from the nightly dump instead,
which is what that dump is for. scripts/restore_subject_disease_tags reads
a pg_dump extract, is idempotent, and resets the sequence afterwards so
the next tag created by hand does not collide with a restored one.

/tags serves subjects and diseases from their own links again, and systems
through the topics that carry them.

The Performance tab is ordered as the reference has it: the trend beside
the split it is a trend in, and Completion's four figures underneath.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 05:39:22 +02:00
Daniel
a16e45a7f1 fix: user deletion, a real end-block dialog, and the axes people pick by
Deleting a user failed with a not-null violation from quiz_attempts.
Every foreign key to users is already CASCADE or SET NULL in Postgres,
but the ORM relationships had no passive_deletes, so SQLAlchemy insisted
on emptying each one itself by writing NULL into columns that refuse it.
passive_deletes leaves it to the database, which knows. The two tables
that genuinely cannot forget a user — question_categories and
quiz_categories are NOT NULL and NO ACTION — hand their rows to the
administrator doing the deleting: the taxonomy is the site's, not the
author's.

/tags counted through question_tag_links, which is now empty, so every
organ system read zero and an active exam hid them entirely. It counts
through the topics that carry them instead: 2,919 of 2,924 questions, all
sixteen systems with real numbers. The session builder's Disciplines and
Symptoms pickers were over the retired vocabularies and are gone —
Topics is the same axis said once and said better, 673 against 203.

The end-block dialog offered one button. A confirmation with one button
is not a confirmation: it now leads with the way back into the block,
says how many are unanswered as a sentence rather than a grid to count
by eye, and the unanswered are numbers you can press to go there.

Registration asks for the password twice, on both forms — a password you
cannot see is one you can mistype into an account you then cannot open.

The public pages had no footer, so signing in meant losing the way to
About, Contact and the clinical disclaimer. They sit in a plain layout
that keeps it.

Draft batches can be filed from the workbench: the topic they file into
is a picker at the top, and nothing crosses over until it is set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 05:28:16 +02:00
Daniel
ebcc11ae12 feat: retire the tags, and stop settings from hiding from the page that
claims to hold them

The scaffolding is down. 203 subject, 2,275 disease and 4,281 keyword
tags, and 25,356 links, deleted — backed up first to a 1.9MB JSON of
replayable rows, because "we can always put it back" should be true
rather than said. The 16 system rows stay: categories point at them.

With them go the things that only existed to feed them — the
classify_questions task, its snapshot helpers, POST /tags/classify and
its status poll — and the three Taxonomy tabs that would now always read
zero. A tab showing 0 forever teaches people the page is broken.

The organ-system filter in the session builder moved onto categories with
the rest, including everything beneath a matched topic, so it groups the
way the analysis does.

Registration: `settings:registration_enabled` was set to false, and there
was no switch anywhere on the site to set it back. The API had always
accepted it; the Site policy page had never shown it. So the site could
be closed to new members with the admin looking at three switches, all
correct, and no way to see the one that was actually refusing them. It is
now the first switch on that page, and says plainly that the ones below
it have nothing to act on while it is off. The SSO-only flag was hidden
the same way and is shown when SSO is configured.

Deleting a topic no longer silently unfiles its questions. It asks where
they go, and says how many are waiting, unless the topic is empty — the
same rule promotion now follows. Its extra category links move too,
minus any that would duplicate a pair the destination already has.

Back links: Trash, Extraction jobs, Taxonomy and the Handbook had none at
all, and Access pointed at the wrong section. They are one component now,
each returning one step to the section it was opened from. Editorial has
its own entry in the section bar, so its Tools card is gone rather than
being a second door to the same room.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 05:06:58 +02:00
Daniel
8614792bdf fix: the other registration form, our own transcriber first, and a rail
that stops answering the wrong question

Invite-only was set and the sign-up form had nowhere to type a code.
There are two registration forms — /register and the modal on the landing
page — and only the first had been taught about invite codes. The modal
is the one most people meet, so turning the gate on failed everybody with
"an invite code is required" and no field to satisfy it. It now asks the
same signup-policy question and shows the same field.

Dictation records to our own transcriber first and falls back to the
browser's recogniser only where recording is unavailable. It was the
other way round for speed, but the browser's speech stack announces
itself to the user in ways we do not control — Firefox interrupts the
page with a warning about a missing Speech Dispatcher library, which is
alarming and is not about us.

A draft question could be promoted into the bank with no category. That
question would reach nothing: no discipline, no organ system, no
relevance, no row on any tab of the analysis — in the bank and invisible
to every page that counts. Promotion now refuses, before an id is spent.

And "Your overall analysis" is out of the session rail. It put lifetime
figures one click away while you were standing in front of a single
session, which is the thing that was supposed to have moved to the
Performance tab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 04:41:27 +02:00
Daniel
13d5f50c58 feat: an organ system is a fact about a topic, not about a keyword
A question reached a system through a symptom keyword it happened to
mention — question → keyword → parent system — and only 726 of 4,281
keywords had ever been given a parent. The Systems tab saw 1,492 of 2,924
questions while Disciplines saw all of them.

The system now sits on the category: question_categories.system_id. Every
question has a category, so every question reaches a system. 2,919 of
2,924, and all sixteen buckets have real content.

It stays a third way of asking rather than the discipline tree relabelled
because a topic's system is assigned separately from where it sits in the
tree. scripts/assign_category_systems takes the discipline as a default
and lets the topic's own name overrule it, which is exactly the case that
makes the axis worth having: conjunctivitis is filed under Infectious
Disease and is an eye, osteomyelitis is filed there and is a bone. 110 of
660 topics were decided that way.

Two regex traps caught in the dry run and fixed before applying:
"adRENAL" matched the kidney rule, and "Abnormal Uterine Bleeding" matched
the bleeding rule. Both now have a specific rule above the general one.

I first tried to fix this by parenting the orphan keywords to systems,
deriving each keyword's system from the questions carrying it. The dry run
showed why that was the wrong shape: it reached only 534 of 3,555 orphans,
and inherited every coarse edge of the discipline map — conjunctivitis came
out as Multisystem because conjunctivitis questions are filed under
Infectious Disease. That script is left in place, unapplied, as the record
of a measurement worth keeping.

No ForeignKey on system_id in the model: question_tags is a raw-SQL table
with no ORM class, and declaring one leaves every metadata build unable to
resolve it. The constraint is real in Postgres.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 04:32:25 +02:00
Daniel
56ecbb0889 fix: the session owns the menu button, and study loses the exam's review
Four things, all from one screenshot pair.

The Review button in a study session was inherited from the exam player.
Reviewing a block before handing it in is an exam idea; a study session
has nothing to hand in — it keeps going until every question is answered
and at that point it *is* the review. The review link, the top-bar
button and the rail button are exam-only now, and a study session whose
questions are all answered says "Finish session" and submits rather than
opening a dialog to ask a second time.

The drawer's "Qbank" pointed at /questions, which has never been a route
— /questions/:id is the editor. It went nowhere. It points at
/question-bank, and Collections and AI Mode join the list.

While a session is open on a narrow screen, the navbar burger now opens
that session's questions instead of the site menu, which is a tab inside
the same drawer. Two menu buttons an inch apart, one of which leaves the
session you are sitting, is the wrong offer. The player claims the button
only while it has no rail, and hands it back when it leaves.

And the drawer says what AMBOSS's does: a Review badge once everything is
answered, the mode in the title, a progress bar under the count, and the
session and question clocks pinned beneath the list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 04:12:48 +02:00
Daniel
1c1f327d4a feat: the chat can put you into practice, and Settings stops crying wolf
AI Mode could cite an article and link to it; it could not do the other
half of the job. POST /ai/conversations/{id}/practice turns an answer
into a study session, built from what that answer actually cited: a
question it named first, then questions filed under the category of an
article it named, then retrieval on the learner's own words. Everything
goes through the bank's visibility rules on the way out — a chat is not a
route to questions a learner could not otherwise reach. Study mode, never
exam: this is reading followed by practice, not a paper.

Two false alarms on the Settings page, both visible in a screenshot:

The STT test called /model/info on the LiteLLM proxy. Our virtual key is
scoped to llm_api_routes and cannot, so a working transcription model
reported a red 403. It now falls back to /v1/models, which the key may
call, and says plainly that the proxy would not confirm what the model is
for — presence, not suitability.

And the TTS test raised a 400 carrying an instruction ("use the Preview
button"), which the page rendered in red with a ✗. That is not a failure.
It answers, and Preview stays the way to hear a voice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 03:45:51 +02:00
Daniel
bc77ba83ae feat: a collections page
Favorites and the question libraries in one place. Card and Table views
with the choice remembered, sort by last used / created / name / size
with a direction control, a count line, and a search over name and date.
Favorites leads as a fixed row: it is the one shelf nobody made and
everybody has, so it cannot be renamed or deleted.

Sorted by when each was last used, not when it was made — the order
things were created in is nobody's mental model of their own shelf. A
library nobody has opened falls back to its age, because it is newer to
the learner than it is to the database. That needed
`user_collections.last_used_at`: null on every existing row, since
backfilling from created_at would invent a use that never happened.

A shelf opens in place rather than linking away. The obvious link would
have been /questions?collection=N, and there is no page there that reads
it — the old bank browser was dismantled — so the card would have led
nowhere. Questions can be taken back out from the open shelf, and any
shelf can be sat as a session through the existing explicit_ids builder.

The ⋯ menu moved out of QuizPage into components/MoreMenu; the player
keeps its own look and its own children through className props. It no
longer closes on any click inside, which the player's feedback form and
share dialog were relying on by accident.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 03:33:37 +02:00
Daniel
b80e188eae feat: one session's topics, asked the same three ways — and a way back
The session analysis ranked its weakest topics by primary category only,
while the Analysis page asked the same question three ways and rolled
answers up the category tree. Two sets of rules for "where does this
question belong" is two pages that can disagree about a learner and
neither able to explain why.

So the rules moved to services/knowledge_groups.py: ancestor roll-up,
article reached through its category, organ system reached through the
symptom keyword. study_tools now asks that service instead of building
the lookups inline, and GET /attempts/{id}/recommendations gives one
session the same Articles / Disciplines / Systems switch. Grouping is its
own call, so changing it does not re-read the question table and the peer
statistics beside it. A running exam ranks nothing — marking it there
would answer the question the exam is asking.

The ungrouped `recommendations` key is gone from the analysis payload
along with the code that built it.

And the document page had no way back. It is reached from the Tools
workbench, which by design has no menu of its own, so leaving it meant
the browser button. It opens onto Tools now, as Tools opens onto
Settings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 03:20:03 +02:00
Daniel
8a1b518502 feat: the two readiness cards
Your score is the share of questions right at your most recent answer to
each. It is deliberately not called an equated score: AMBOSS's EPC rests
on psychometrics we do not have, and a number dressed up as one would be
a claim we cannot support. The card says so.

Against everyone else compares you with other learners on the questions
you have in common — not with their scores on whatever they happened to
sit. A percentile over different question sets reads someone who worked
through the hardest fifty in the bank as weaker than someone who did
fifty easy ones, which is the opposite of true.

Neither appears before it means anything, and each says which half is
missing: more questions of your own, more questions shared with others,
or more learners. The cohort reported is the most any one shared question
saw — distinct learners cannot be summed across questions without
counting the same person once per question.

The "readiness is still locked" note sat above the tab switch and so
appeared on Performance, where it described a table that is on the other
tab. Moved down to the table it is about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 03:08:09 +02:00
Daniel
bfc5ec93d7 feat: an educator can actually write a tip
Two gaps closed. `attending_tip` has been on the question model and in
the API since before this session, rendered by the player — and there was
no field anywhere in the UI to put anything in it. The question editor
now has one, at the foot of the Explanation card.

And the inline kind is written by selecting the words: select "stridor"
in the stem, press ⚕ Tip on that field's toolbar, and the caret waits
where the tip goes. With nothing selected it drops in a marker with
"phrase" highlighted to type over.

A question with no tip offers no button — that was already true in the
player, and is now pinned by a test rather than left to hold by
accident. An emptied tip saves as null for the same reason.

The handbook says how to do both, and what a tip costs the learner:
opened before answering it is recorded with the answer and shown as
"correct after a tip"; opened while reading the explanation it is
revision and costs nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 02:02:16 +02:00
Daniel
68d65ac782 feat: performance over time, locked until it means something
`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
2026-09-12 01:57:46 +02:00
Daniel
767bb60099 docs: mark completion and the answer split done, record tips
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 01:51:25 +02:00
Daniel
789cd1cc81 feat: right after a tip is its own slice
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
2026-09-12 01:51:03 +02:00
Daniel
f048b1f4b6 feat: tips in the prose, underlined where they are needed
`{{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
2026-09-12 01:44:29 +02:00
Daniel
2c6b821f6f style: lighter menu bar
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
2026-09-12 01:39:53 +02:00
Daniel
8c28cc4e9b feat: all attempts vs latest attempt, with the donut shared
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
2026-09-12 01:39:17 +02:00
Daniel
4e272e6ef0 feat: completion over a chosen time range, and a way back out of Tools
"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
2026-09-12 01:35:14 +02:00
Daniel
06433195bb fix: a page you cannot open says so; a section row fits its card
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
2026-09-12 01:21:20 +02:00
Daniel
a704542a14 feat: the session drawer on a phone; and extraction says what actually failed
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
2026-09-12 01:16:22 +02:00
Daniel
2d828c3c03 feat: a repetition does not raise your score; no more deleting a session
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
2026-09-12 01:08:42 +02:00
Daniel
b8b80877ac fix: the study objective was near-white on near-white
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
2026-09-12 00:55:52 +02:00
Daniel
1b996b0a3d feat: relevance is the board's published share, not our bank's proportions
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
2026-09-12 00:53:54 +02:00
Daniel
b2ddee74dc fix: sticky was disabled site-wide, and 100vh is wrong on iOS
`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
2026-09-12 00:46:06 +02:00
Daniel
816ab0a66c feat: rule options out, skip says skip, and the highlighter stops covering the words
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
2026-09-12 00:41:46 +02:00