Commit graph

301 commits

Author SHA1 Message Date
Daniel
572bf6877c feat: mark regions on an image, and cite where it came from
The drawing tool for the overlay contract: freehand, rectangle, ellipse and
arrow, in five colours and three weights, over the image at full screen. Every
coordinate is normalised to the unit square, so one drawing is right in a
256px thumbnail and on a projector; strokes are drawn in screen pixels rather
than viewBox units, because a unit square stretched over a 3:2 film would
otherwise make horizontal strokes half again as heavy as vertical ones. Pointer
events throughout, so a finger traces an edge as well as a mouse does.

It hangs off the media library's edit dialog, beside two new fields: the
citation and a link to check it.

The drawing tool was written by a Fable subagent against
docs/image-overlays.md, in three files of its own; the wiring, the schema and
the reader's half are mine. Its judgement calls I have kept: Cancel asks before
discarding a traced edge (a stray tap on a phone is the same harm as Escape),
undo is a history stack rather than "remove the last shape", and freehand
points closer than 0.0025 apart are thinned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 20:43:05 +02:00
Daniel
4782e2f1a4 fix: AI Mode opens on the starters, not on a paragraph about itself
The rules were explained before anybody had asked anything. What the assistant
will and will not do is visible in the answers themselves — each carries its
sources, or says it has none — and a page that explains itself up front is a
page nobody reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 20:34:46 +02:00
Daniel
809ac2fca6 feat: figures are thumbnails that open into a viewer, with source and overlay
A 2,000px radiograph written into an article rendered at whatever width it
happened to be — a wall of greyscale in the middle of a sentence, four
megabytes to draw it, and no way to look at it properly. Every image in prose
is now a 256px thumbnail with the author's label under it, and a click gives it
the screen.

The viewer puts what is known about the figure beside it: its title, what it
shows, and where it came from. `media_assets` gains `source` and `source_url`
for that — a citation belongs to the file, because the same figure used in
three articles is cited the same way in all three, and a licence that turns out
to be wrong is one row to fix rather than three paragraphs to find. Asked for
when the figure is opened, not when the page is drawn.

And `overlay`: the regions an educator has marked, as vector shapes in
normalised coordinates on the unit square, so one drawing is correct in a
thumbnail, in the viewer and on a projector. Off until the learner turns it on
— marks shown before they have looked answer the question for them. Vectors
rather than a second burnt-in picture, for four reasons written down in
docs/image-overlays.md. The tool that draws them is next; this is the storage,
the contract and the reader's half.

On a narrow screen the description stacks above the image rather than beside
it, where it can be read before scrolling to the picture.

Also here: `classify_question_difficulty`, which labelled all 2,924 questions
in batches of twenty-five against a written rubric — 622 easy, 1,634 medium,
668 hard, no failed batches. The column had been NULL on every row since it
existed. Migration l2c3d4e5f6a7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 20:33:00 +02:00
Daniel
0d41179b30 feat: the topic behind the right answer, as a chip where the eye already is
Reading links existed and sat at the foot of the whole explanation block, under
everything else, as a headed list. A learner who has just been shown the right
answer is looking at the right answer — so the link now sits directly under it,
as a chip, the way a reference site does it. Outside the option's button rather
than inside it: a link nested in a button is neither, and the click would have
toggled the explanation instead of opening the article.

Also a real bug beside it. Key points were gated on `option_explanations`
being non-empty — the wrong field entirely — so a question with key points and
no per-option reasoning showed none of them. It happens not to bite today
because exactly one question in the bank has either, which is its own finding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 20:18:05 +02:00
Daniel
2d80df8d73 feat: the id, the slug and the marker at the top of the editor
Both identifiers were looked for and neither was on the page: the id had to be
read out of the address bar and the slug out of a form field below the fold.
They are now two chips under the title, each copying itself.

The third chip is the one that earns its place — the cross-reference marker
itself, `[[264|Pediatric Respiratory Failure]]`, id and title already
assembled, because that is what somebody wants those two things *for*.

And the broken-link check finally shows. The backend has computed
`broken_links` on every save since markers existed and nothing had ever
rendered it, which made the check pointless: a dead cross-reference found a
week later belongs to nobody. Saving now says which markers point at nothing —
and saves anyway, because a link to an article that has not been written yet is
a note to write it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 20:06:56 +02:00
Daniel
dadd2447b9 fix: the landing figures count when you can see them, and no contact form
The counters ran on mount, which is while the visitor is still reading the hero
two screens above — so the animation finished before anybody could see it and
the numbers simply appeared. They now start when the figures come into view,
and a ref stops a later re-render sending them back to zero.

The contact section is gone, and with it the Contact link in the footer. The
endpoint behind it is untouched, so the form can come back somewhere else
without being rebuilt.

And the clinical tools say "the full vaccine schedule" rather than naming the
two bodies that publish it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 20:06:56 +02:00
Daniel
448bfdd71c fix: the difficulty facet counts itself, and the adaptive item is closed
Two halves of one TODO, settled with a measurement rather than a guess.

*Shrunk readiness* was already done — `CandidateRanking.accuracy()` pulls a
topic towards NEUTRAL_RECALL by PRIOR_ANSWERS, so one miss does not read as 0%
— and there is now a test pinning it, because the note claiming otherwise
outlived the fix by weeks.

*Difficulty as a dimension the session moves along* cannot be built, and the
reason is a number: all 2,924 questions have a NULL `difficulty`, and the
empirical route is no better at 788 answers over 706 questions from 5 learners,
about one answer each. A ladder scored against that would be scoring noise
while looking as though it worked.

What is built instead is honesty in the control that already exists: the
Difficulty facet counts each level under the other filters and disables one
that would empty the bank, so nobody picks Hard and watches the count fall to
zero with no explanation. Reopen the ordering when something writes that
column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 19:57:21 +02:00
Daniel
afc223d79e fix: an article opens as the article, and Editorial can delete
Opening on the high-yield view was a defensible default and a bad one in
practice: an author who had written ten sections opened their own page, saw
two, and reasonably concluded the rest had not saved. It also made every
reader's first impression of a topic its revision card. The article now opens
on the fullest reading it has, and High yield is a switch you throw.

Delete is on the Editorial row as well as in the editor. Having it only in the
editor meant opening an article in order to get rid of it.

And the Library button is gone from the Editorial header: it is the Reading
page, it is in the menu, and a third door beside the two controls that are this
page's own job is a door in the way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 19:57:21 +02:00
Daniel
5670ebbdda feat: the deck studio is a deck being made, not three pictures of slides
Three improvements and one real bug.

The bug: `.lp-sl-wash` is absolutely positioned and ends in an opaque white, so
it painted *over* its static siblings — the title slide laid its rule, heading
and picture out correctly and then covered them with its own background. An
explicit z-index on the wash and its siblings fixes it.

The improvements: the slide now sits inside editor chrome, with an export
control that lights up as each slide lands, because three slides in a row are
three pictures and the same three inside a window are a deck being made; a
pointer moves across the canvas, places two things and presses export, which is
what makes it read as being *built*; and the artwork is worth looking at — a
figure on the title slide, a line chart with gridlines, a shaded band and
plotted points, a two-segment ring and a legend. Each slide carries its own
accent, so the three do not look like one slide shown three times.

The warm accent was half theme and half amber, which against a blue primary is
the colour of a wet pavement. It is mostly amber now.

Also: "Pediatric clinical tools" is "Clinical tools", for the same reason the
hero no longer says pediatric.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 19:57:06 +02:00
Daniel
831cb01650 feat: an article follows a topic, rather than copying it once
"Questions filed there later are not added" was the honest description of what
the previous commit built, and it was the wrong thing to build. "The Cardiology
article covers the Cardiology questions" is a standing statement about the
material, not a snapshot of who happened to be filed where on the afternoon
somebody pressed a button — and a copy stops being true the first time a
question is added, silently, with nothing on any screen to say so.

So the claim is now stored, and it is what writes the links:

* `question_article_links` is still the **only** table anything reads. No count,
  no QBank button, no mirror panel on a question, no AI Mode boost learns a
  second question to ask.
* `article_topic_claims` records *why* some of those rows exist, and is the one
  place that makes them — when the claim is staked, when a question is filed
  into the category (single, bulk, or on create), and on a half-hourly sweep
  that catches whatever bypassed both.

A link made this way is an ordinary row and can still be deleted by hand; a
sweep puts it back, which is the honest consequence of a standing claim.
Dropping the claim is how you stop it, and the panel now lists what an article
follows with two ways out — stop following and keep the links, or stop and
remove them.

Migration k1b2c3d4e5f6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 19:57:06 +02:00
Daniel
aafea65a52 feat: delete an article, and a trash for the ones that were published
There was no way to delete an article from anywhere in the interface. The API
had one; the only route to it was curl.

Now there is a control at the foot of the editor, and it does one of two things
depending on the article's history — and says which before it is pressed:

* A draft that was **never published** is deleted outright. There is nothing to
  restore, and a trash full of abandoned stubs is a second list to maintain.
* Anything that has been published, even once, is **marked** and appears in the
  trash on Editorial, restorable exactly as it was. Somewhere there is a
  learner's note against one of its sections, a question linked to it, and a
  link somebody sent a colleague; a DELETE typed in the afternoon should not
  settle any of that.

`first_published_at` is what decides, stamped on the first publish and never
cleared — unpublishing does not make an article unseen, so it does not make
deleting it safe either. Backfilled from `reviewed_at` for everything currently
published, because an article with a null stamp reads to the rule as a
never-published draft.

A binned article is out of the listing, the editorial queue, every slug and id
lookup, and — immediately — the search index, so it cannot still answer a
learner's question from the trash.

Also on Editorial, because a hundred rows is a queue you work through and not a
page you scroll past on the way to the next queue: each bucket keeps its own
box, its own scrollbar and its own filter.

And the editor finally has a way out that is not Save: Back and Discard, with
an inline confirmation when there are unsaved changes. The way out was the
browser's back button, which throws the sitting away without saying so.

Migration j0a1b2c3d4e5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 19:31:28 +02:00
Daniel
db242d7e83 feat: link a whole topic to an article in one action
One question at a time is right for a cross-reference and wrong for "every
Cardiology question belongs to the Cardiology article", which is most of what
an educator is doing in that panel.

Choose a category and every question filed under it — primary category or
additional, and its subtopics unless you say otherwise — is linked in one go.
The count is fetched first so the button carries the number: "Link 43
questions" is a decision, "Link this topic" is a guess, and the difference
matters when the topic turns out to be the whole of Cardiology. Capped at 300,
with a refusal that names the number and suggests a subtopic.

Ordinary link rows, not a rule. The article does not "claim" the category; it
gains the questions in it now. A rule would be a second answer to "which
questions belong to this article" — one the counts, the QBank button, the
mirror panel on each question and the AI Mode boost would each have to learn to
ask — and it would silently attach tomorrow's questions to an article nobody
has looked at since.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 19:31:28 +02:00
Daniel
c099dd16fb fix: the player's furniture stops pushing the question down the page
Three things above the stem — the session's name, the mode row, the "Question 4
of 12" counter — carried eighteen pixels under the header, twenty over the
counter and a line of spacing inside it. Together that was a third of the
screen before the first word of the question. Tightened, all of it.

The clock beside them may no longer be the part that gives: squeezed, "0h 00m"
broke after the hours and each of the three readings became two lines of its
own height, which is what pushed the group out of the bar it sits in.

And Next is an equal half of the bar rather than two thirds of it. It is the
only coloured thing down there; it does not also need to be a blue band across
most of the window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 19:09:55 +02:00
Daniel
a765913440 feat: ==key points==, highlighted in yellow
A summary where nothing is emphasised is a shorter article, not a revision aid.
An educator can now run a highlighter over the sentence that matters, from the
editor's toolbar or by typing the markers, and the reader sees it in yellow —
most usefully in the high-yield view, which is what it is for.

A remark plugin rather than a rewrite of the source string, for the same reason
as tip terms: a reader's own highlights and the read-aloud cursor are stored as
offsets into the raw markdown, and editing the string before it is parsed would
shift every offset after the first key point. Splitting the parsed text node
keeps each piece pointing where it came from.

`<mark>`, not a styled span: find-in-page and print already know what it means,
and so does a screen reader. A lone `==` stays the characters it is — "Sodium
== 140" is a lab value — and a highlight may not run across a line, so one
stray marker cannot swallow a section.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 19:05:12 +02:00
Daniel
a55644818e fix: define the two form classes nothing defined, and peek the nav on hover
`.form-label` and `.input` are written in four places — the articles page, the
flashcards page, the article editor, the category tree — and were defined
nowhere. An undefined label class is an inline element, so the label sat flush
against an unbordered box: "Titlee.g. Febrile seizures". They are now the
standalone form of the `.form-group` rules, defined once, because a form should
not look different for having no wrapper around it.

And the section bar comes back when the pointer is on the logo. It hides while
you read, which is right, but scrolling up was the only way to see it again —
no use to a reader looking for the way *out* of the page rather than the way
back up it. Focus does the same thing, so a keyboard reaches it too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 19:05:12 +02:00
Daniel
1be6d10550 feat: a QBank door on every article, which says why it is empty
The offer to practise a topic was a paragraph under the last section: it
appeared only where questions happened to be linked, explained itself at length
to everybody including the reader who never wanted questions, and sat where
nobody looks.

Now it is a button in the controls row, on every article whether or not
anything is linked — and that is the point of it. A reader's question is "are
there questions on this?", and a control that vanishes when the answer is no
cannot answer it. With nothing linked it opens a dialog that names the study
objective, says there is nothing for *that* objective, and offers a custom
session; the objective is usually the whole reason, and a learner who cannot
see which one they are on has no way to tell that from "nothing exists on this
topic at all". With questions behind it, it is the builder it always was.

Reading pages still never show a stem, an answer or an explanation.

Also: the two selects stack and the dialog scrolls inside itself under 520px,
and every control is left to the site-wide 16px rule for coarse pointers — a
field under 16px makes iOS Safari zoom in on focus and never zoom back out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 19:04:51 +02:00
Daniel
177df84817 feat: a deck building itself on the landing page, and no captions anywhere
Three things this site does, and the third had no picture. A talk built from
the same evidence the bank is written from — a template, the figures dropped
in, exported as PowerPoint — is hard to say in a sentence and easy to show, so
this says nothing at all: a slide assembles itself, the deck beside it lights
up as each one lands, and the eye works out what it is watching. The one link
is navigation, not explanation.

Three slides share a 21s loop, each a third of a turn behind the last by way of
a negative delay, so there is no clock in JavaScript and nothing to unwind on
unmount. Every part inside a slide rides the same loop and the same delay plus
its own `--d`, and that is what staggers the build. Under calm motion the loop
is not applied and the three lie side by side, finished.

Also here, because it is the same argument: the study panel's caption is gone.
The panel is a picture of a question marking itself, which is the whole of what
the caption said, and a screen reader is told that by the figure's own label.

And the hero no longer says "pediatric". The domain says whose site this is,
more steps are coming, and a line naming one exam has to be rewritten the week
the next one is added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 19:03:30 +02:00
Daniel
532d613393 feat: question folders, per-section notes, and two feedback paths
Four things that share a spine, so they arrive together.

**Folders.** A hand-picked set of questions, and the fourth thing a grant can
name beside exam, discipline and category. Deliberately not `user_collections`
with a sharing flag: a library is a consequence of access — you save what you
can already see — while a folder is a source of it, and one table holding
thousands of private lists beside a handful that confer permission is one
mistake away from a leak. Built from the question manager, granted on /access.
Membership stays with the owner and moderators so a grantee cannot widen their
own reach, and deleting a folder takes its grants with it.

Two live constraints had to be rewritten to accept it: `ck_grant_has_a_dimension`
and `uq_grant_dimensions` both predate `folder_id`, so a folder-only grant
failed the check and two folder grants collided on the unique index.

**Per-question feedback.** The learner's half already existed. What was wrong
was who could read it: any grant at all let an educator list and delete reports
about the whole bank. Reports are now scoped by `question_scope_predicate`, the
same predicate that decides which questions that educator can see, and a reply
thread makes the report a conversation the learner can follow rather than a
form that swallows what they said.

**Per-section notes and article feedback.** Two tables on purpose:
`article_section_notes` is private to whoever wrote it, `article_feedback` goes
to whoever maintains the article. Both point at the section id inside
`articles.sections` rather than at `article_section_index`, whose rows are
dropped on unpublish — a cascade from there would delete a learner's writing
because an educator took an article down for an afternoon. A rename keeps a
note attached; a deleted section leaves it marked orphaned under the heading it
was written on, for its writer alone to remove.

The header's feedback badge covers both, because questions and reading are the
same job to whoever is doing it.

Migration i9f0a1b2c3d4. 556 backend and 572 frontend tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 18:37:43 +02:00
Daniel
73ab2515f5 fix: ship the three files the overview page imports
`0700e68` and the two commits before it added the overview, its stylesheet and
the search-panel context, and committed only the page. App.jsx imports
`./context/SearchPanel` and DashboardPage.jsx imports `./DashboardPage.css`,
neither of which was in the tree — so master has not built from a clean clone
since. It built here because the files exist on this disk.

Nothing new: the four files as they already are, plus SearchOverlay's `tab`
prop, which is what the context passes it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 18:37:23 +02:00
Daniel
0700e68eaf feat: three coach marks on the overview, once per browser
A tour is a confession that the product does not explain itself, so this one
names the three things a learner would otherwise go looking for and stops:
the session chosen for them, search against AI Mode, and what the ring is
measuring. Anything needing six steps wants rebuilding rather than narrating.

Steps are declared against a `data-tour` attribute rather than a class or a
ref, because a tour that reaches into another component's internals breaks the
first time somebody renames something for a visual reason. A step whose target
is not on the page is dropped from the count rather than shown pointing at
nothing — an educator and a learner do not see the same dashboard, and "Step 2
of 6" must not promise a step that will never come. The count is fixed when the
tour opens, so a block arriving late cannot renumber it under somebody midway.

Only on the overview, and after the objective rather than beside it: two things
asking for attention at once is one of them being dismissed unread. Seen-ness
is per browser, because a tour is about the interface rather than the account.

The dimming is four rectangles rather than one enormous box-shadow, which would
repaint the whole screen on every scroll event — and this follows scrolling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 18:10:30 +02:00
Daniel
dd7bd3668e feat: an account may have no password, and may set one later
Single sign-on wrote a random string nobody would ever know. That reads as
"has a password" to everything that asks — so Settings demanded a current
password before it would let those accounts set their first, and the only way
through was to click "forgot password" for a password they never had. The same
trap was waiting for anybody who only ever signs in with a code.

Null says the true thing. Signing in refuses an account with no password the
way it refuses a wrong one, because which accounts have one is not a question
that endpoint answers. Setting a first password asks for no current one;
changing an existing password still does. `/auth/me` reports whether there is
one at all and nothing about it, because Settings has to choose between "Set a
password" and "Change password" and cannot tell from the outside.

The random strings already written are left alone. They are unguessable, so
nothing can sign in with them, and clearing them would mean deciding from
outside which accounts were meant to have one.

Identity is the email address throughout, so the three ways in are three ways
into the same account: single sign-on, a code, or a password — and a person may
acquire or drop the third at any point without losing the other two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 17:39:29 +02:00
Daniel
25a9a8aca4 feat: sign in with a code sent by email
A password is a thing to remember and a thing to lose. Somebody who can read
their own mail can now sign in without one: ask, receive six characters, type
them into the page that is already open.

A code rather than a link, and the difference is not cosmetic. The token in a
link was 256 bits, unguessable however long it lived, so its length, its expiry
and its rate limit were three independent decisions. Six characters is 2^30,
and the three stop being independent — so they are argued together:

  * six characters of the invite alphabet, imported rather than copied, because
    there should be one answer to which characters a person may be asked to
    retype and that one already drops O/0 and I/1;
  * a code answers five guesses and is then retired, not slowed — whoever is
    typing has lost the mail or does not own it, and both are one click from a
    new one;
  * one code live per person, since several would mean one guess tested against
    all of them;
  * ten verify attempts per address per fifteen minutes, so nobody buys five
    fresh guesses at a time by asking again.

Tens of guesses an hour against a billion, and the victim gets a mail for every
code burned. Eight characters would buy a thousandfold against an attack the
guess budget has already ended, and cost every person two more characters.

The attempt count lives in the row, not the cache. The Redis limiter fails open
when Redis is down, which is right for what it usually guards and wrong for the
only thing standing between a patient stranger and six characters.

Verifying is scoped to the address. A short code looked up on its own would be
tried against every code live on the site at once — the short code's one real
weakness, closed by knowing whose code it should be before comparing.

Fifteen minutes, because a first mail between strangers is routinely greylisted
five to ten and a code that expires before it arrives is not a sign-in method.
Shortening it buys nothing: one code is live and it answers five guesses
however long it sits there.

Nothing distinguishes an address with an account from one without — same
message, same status, same duration, and both rate limits counted before the
account is looked up, so a 429 cannot become the tell. Redis keys are
fingerprints, and the table holds a fingerprint rather than the code.

SSO stays first where it is configured, and a password is still one click away
for anybody who has one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 17:29:28 +02:00
Daniel
1c66eb84b1 fix: two ways to be told an address has an account, and a door to writing
`/auth/forgot-password` and `/auth/resend-verification` both take care to say
"if that email exists" and both then answered the question anyway.

The reset limiter returned early for an unknown address, so it counted nothing
for one and counted for the other: ask four times and a registered address
gets 429 while an unknown one gets 200 for ever. It counts either way now — in
Redis for an address with no rows to count, keyed by a fingerprint, because a
list of addresses somebody tried is itself worth not keeping.

Resend answered "Email already verified." for a known verified address and "if
that email exists" for everything else, which is not a hint but an answer. One
sentence for every outcome now.

And Editorial has a way to write something. Drafting was only reachable from
the library — a page about reading, behind a button an educator arriving to
work has no reason to look for — so the two panels now open from a link.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 17:18:30 +02:00
Daniel
3418ed023b fix: WebP figures, the openai SDK removed, and a voice a site can add to
Three things landed together; the message names all of them, because a commit
that mentions one is a commit nobody finds the other two in.

**Figures.** Thirty-four JPEG 2000 files — 21 on questions, the rest unattached
in the media library — are WebP now, with `questions.image_path`,
`questions.explanation_image_path` and `media_assets.path` repointed together.
Serving already converted them on the way out, so nothing was broken; this
removes the step and makes what is stored the same thing that is served. The
originals stay: they are the only copy of what came out of the PDF, they cost a
few megabytes between them, and a conversion nobody can undo is not one to run
against a live bank. Paths are found by what the columns say rather than by
listing a bucket, because three tables record them and updating two would be
worse than none.

**The openai SDK is gone.** Ten call sites — one more than the map said, the
Celery article drafter — every one of them a POST with a JSON body, and not one
reading usage, cost, tool calls or logprobs. Every other call to the same proxy
was already plain httpx: embeddings, the ChromaDB embedding function, speech
both ways, model discovery, the vision probe. So this deletes an abstraction
rather than swapping one for another, and leaves one HTTP client instead of
two. `chat()` and `achat()` return the message content; a `ProxyError` carries
the status and the first 500 characters of the body, which is where the proxy
explains itself.

Behaviour is preserved deliberately, including a 600-second fallback timeout
for the four call sites that were running on the SDK's ten-minute default.
Lowering that is a real change and belongs in its own commit.

Proved against the live proxy on both services rather than only against mocks:
a completion, an async completion, a real 400 the vision probe still classifies
as a refusal, 407 models read from the catalogue, and a word read off an image.

**Voice.** A chosen voice is honoured whatever serves it. The prefix check only
accepted a locally served one, so a site adding a hosted voice would offer it
in Settings, save the learner's choice, and then quietly read every question in
the default voice. The list has always come from the database — adding a voice
is a row in Settings → AI models, never a code change.

And the sign-in page stops offering a locked door: `signup-policy` reports
whether registration is open at all, and the Sign up link goes when it is not.
The switch existed and the only way to discover it was to fill the form in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 17:13:05 +02:00
Daniel
3f57bda6aa feat: a reading voice in Settings, and a tutor that reads every figure
The voice picker was a dropdown in the quiz player, beside the question — the
one control on that screen with nothing to do with answering it, and one a
learner sets once and never touches. It is a setting now, on the user rather
than in a Redis blob, with a play button beside each voice because a voice is
worth hearing before it is chosen. Choosing nothing stays a real choice: it
means whatever an administrator marked default, so a site that changes its
default reaches everybody without a row being edited.

The tutor reads figures from `question_media` rather than the two legacy path
columns. Those agree exactly today, so nothing was being lost — the first
question given a second figure in the editor would have been the one that
broke it, silently and only for the tutor. The legacy columns remain as a
fallback for anything not projected into that table yet.

And the retrieval thresholds are written down in docs/retrieval-thresholds.md:
the three answers, the sixteen queries they were measured against, why they are
deliberately not the retrieval floor, and how to re-measure when the corpus
grows. Worth keeping the headline in mind — "discuss love" scores 0.491,
alongside "tell me a joke". A number in the 0.4s is noise, not a weak signal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 16:48:09 +02:00
Daniel
0a331cf4eb feat: the landing page shows the product instead of describing it
Six cards of prose sat under two mock players that already showed the thing.
"A player built for how the paper asks. Sit a session as study or as an exam
block…" is the picture above it, written out again, and a stranger reads
neither. They are gone, and so is the heading over the band — it named what the
pictures show.

A third panel joins them: an analysis with a ring that fills and three bars
that grow. No figures and no labels, because a number on a landing page is a
number nobody checks; the shapes move and where they sit says what they are.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 15:40:37 +02:00
Daniel
889c40483c feat: link a question to its reading from either end, and a player that reads like a Qbank
**Linking.** A question could be tied to an article only from the article, by
typing the question's number into a box — so opening a question you had just
linked showed no sign of the link, and there was no control to add one. Both
ends now search: find the article by title from the question, find the question
by stem from the article, pick which section of the article the link lands on,
and see what is already linked. One shared finder, so the two ends of one
relationship cannot describe it differently. `GET /questions/{id}/articles`
mirrors the endpoint that already existed the other way, and `GET
/articles/linked` is retired — it answered this question by shipping the whole
prose of every linked article to the quiz player for a list of titles.

"Practise this topic" is a reader's control and no longer appears on an editing
screen.

**The player.** The rail was a bordered card floating in the page with a
scrollbar of its own, so a session had two scrollbars side by side and a
collapse handle tucked inside the card's padding. It is a column now: flush,
full height, its own background rather than its own border, the handle on the
boundary it moves, and a progress bar under the count. The bar at the foot is
the bottom edge of the window — three flush segments, no gaps, no pills —
because Exit as a small grey pill beside a large blue Next made leaving look
like the accident.

Study mode no longer asks whether you are sure. Leaving suspends: every answer
is saved, nothing is graded, and it is waiting where you left it — so the
dialog asked permission for something reversible, under a name for something
that does not happen. An exam still asks once, because a block has a clock, and
it now says what it is: "Leave this block?", not "End Session".

Options are lettered. The explanations already are — a stem extracted from a
board PDF says "Preferred Response: E" — so numbering them 1 to 5 left the
reader translating between two labellings of the same five lines. The tutor is
told the same letters, and the answer key is marked against its own option and
declared authoritative, so a model that would have answered differently cannot
tell a student the marked answer is wrong.

"Preferred response" and "Source page 518" are gone: the first labelled a block
that is obviously the answer, the second named a page of a book the learner
does not have. The clocks moved out of a grey strip across the explanation,
where they read as part of the answer, to the foot of the rail with everything
else about the session.

**AI Mode.** Sources are headed and counted at the end, where evidence belongs,
with the practise button after them rather than above. That button appears only
when there is something to build from and says what it will build — it used to
sit under "how can I help you today?" offering to make a session out of
nothing. A cited question opens in place: `/questions/:id` is the editor, so
following one dropped a learner into a form for changing the question they had
just been told about. And a session built from a chat is named like every other
session, rather than after the chat — asking "hi" produced "hi — practice".

Also: two test questions with raw `<p>&nbsp;</p>` in their stems were live in
the bank; retired. And 36 article summaries were written as a table of contents
with the colons filed off — "Peanut allergy prevention and management: LEAP
guidelines by risk tier, risk stratification, and anaphylaxis treatment" — every
noun phrase sounding informative and none of them saying anything. Rewritten as
claims, with the rule added to the prompt that produced them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 15:17:04 +02:00
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
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
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
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
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
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
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
Daniel
e311a6b5ad fix: choosing is answering; figures say only what an educator wrote
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
2026-09-12 00:19:35 +02:00
Daniel
202979f7c0 feat: a workbench — PDFs in, drafts read, questions out
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
2026-09-12 00:02:02 +02:00
Daniel
fc1aaf5dca fix: a score is out of what you answered, not out of what was set
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
2026-09-11 23:49:24 +02:00
Daniel
6abe3cca11 fix: review or resume, and an exam clock set by the questions
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
2026-09-11 23:42:27 +02:00
Daniel
cc1c981b9e feat: time's up is said, not just done; and it is acknowledged before the screen changes
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
2026-09-11 23:34:44 +02:00
Daniel
6860750770 fix: the exam clock runs only while somebody is sitting the exam
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
2026-09-11 23:30:18 +02:00
Daniel
c353373231 fix: an exam that is still running is not marked
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
2026-09-11 23:17:43 +02:00
Daniel
28cef1e75f fix: one label for picking a session back up
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
2026-09-11 23:10:28 +02:00
Daniel
4fc149b8cc fix: exam mode's rail is numbers, as a real paper's is
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
2026-09-11 23:09:32 +02:00
Daniel
f9acdf42fa feat: click a question and land on it; repeat only the ones worth repeating
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
2026-09-11 23:07:45 +02:00
Daniel
27a1679774 fix: leaving and resuming take one press; session actions sit with the result
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
2026-09-11 23:03:09 +02:00
Daniel
76848c5947 fix: the reading browser counted the wrong thing, and the hover card fled
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
2026-09-11 21:24:04 +02:00
Daniel
fc4df94a6a feat: the tutor is an administrator's to allow, and a handbook explains the rest
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
2026-09-11 21:13:38 +02:00
Daniel
c59f9f936b refactor(settings): People and models rebuilt natively; AdminPage is gone
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
2026-09-11 20:39:38 +02:00
Daniel
3d379d41a8 refactor(settings): drop Safety and the classifier rollback, collapse the Access tree
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
2026-09-11 20:27:55 +02:00
Daniel
5c4823d9a4 feat: exams are a group you can build, with the board's own blueprint
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
2026-09-11 20:27:48 +02:00
Daniel
d1de9589ad feat: questions are soft-deleted, and the trash holds them
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
2026-09-11 20:12:58 +02:00
Daniel
6b6c5e1b49 fix: the objective menu was clipped out of existence; one door to settings
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
2026-09-11 20:03:11 +02:00
Daniel
2c5d3c67b4 feat: one settings page, and comments removed
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
2026-09-11 19:40:24 +02:00
Daniel
c20b1e678f feat: an objective menu, no unscoped choice, and pages that survive a deploy
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
2026-09-11 19:31:13 +02:00
Daniel
5f61422daf feat: a share dialog that shows what is being shared
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
2026-09-11 19:24:45 +02:00
Daniel
2267f53b55 feat: the player is a box; sharing and sign-up are the administrator's
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
2026-09-11 19:17:35 +02:00
Daniel
392a2cc483 feat: feedback replaces comments; Qbank is a landing page; one question page
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
2026-09-11 18:50:57 +02:00
Daniel
522af7181c feat: a real objective picker, and a section bar that fits
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
2026-09-11 18:31:37 +02:00
Daniel
73e9de8831 fix: a session left part-way could not be deleted, and dead space above the footer
`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
2026-09-11 18:28:22 +02:00
Daniel
14a75303a7 fix: every cross-reference in the corpus was a dead link
`[[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
2026-09-11 18:25:31 +02:00
Daniel
e176068f26 fix: two strips that said "there is more" and gave no way to get there
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
2026-09-11 15:12:48 +02:00
Daniel
4a28b5e0a0 fix: new question opens the editor page; the bank previews rather than quizzes
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
2026-09-11 14:45:26 +02:00
Daniel
5575fe39bb feat: per-block counts on a study plan, not a status word
'1/1 article · 50/50 questions' beside each block, the way a course
module reports itself. Counts rather than a percentage: '0/6 articles'
is something you can act on, '0%' only tells you how to feel about it.
The state stays alongside for the one-word answer.

Frontend 308/308.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 13:34:56 +02:00