Commit graph

398 commits

Author SHA1 Message Date
Daniel
77361816d8 fix: the "Add to library" block in a question preview was three faults at once
Some checks failed
Tests / backend (push) Failing after 4s
Tests / frontend (push) Failing after 26s
Tests / e2e (push) Failing after 37s
It said library and meant collection. Its dropdown was always empty,
because the only page that renders this preview passes no collections —
so it read "Choose collection…" with nothing under it. And the "New
library…" input called setCollections, which does not exist in that
component: typing a name and pressing Enter created the collection,
added the question to it, and then threw a ReferenceError. Twelve lines,
none of which worked as written.

Removed rather than repaired. This preview is a moderator's editing
surface; putting a question aside is a learner's act and already lives
in the session and reading flows, where the collections are actually
loaded.

While there, the same word elsewhere: the Collections page called its
own contents "the question libraries you keep", which is the mislabel
the user spotted, on the page named after the other word. A library here
is the article library or an image library; a collection is what a
learner puts aside.

Found by the ped-ai session; the ReferenceError is mine to have missed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 15:35:14 +02:00
Daniel
a8cc0c40df fix: Sign in goes to the provider, not to a box containing one button
Some checks failed
Tests / backend (push) Failing after 9s
Tests / frontend (push) Successful in 33s
Tests / e2e (push) Has been cancelled
The landing page's Sign in opened a modal whose entire content was a
single "Sign in with PedsHub SSO" link. That is a step that exists to be
clicked through.

Every sign-in control on the page — the header, the hero, the closing
call to action — now goes straight to /api/auth/sso/login when the site
is SSO-only. The modal is still built and still opens on a site that has
a password door, which is the only thing it was ever for.

/login is deliberately left as it is. It renders the one button rather
than redirecting, because it is also where the provider sends somebody
back when sign-in fails — ?error=sso_failed — and a page that redirected
on sight would bounce them into the provider again, forever.

Verified live: one click from the landing page lands on
sso.pedshub.com's flow with the client id, callback, scope, state and
nonce, and no modal is rendered on the way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 15:31:50 +02:00
Daniel
cdf4ab80b4 fix: the figures route handed out the answer side to anybody signed in
Some checks failed
Tests / backend (push) Failing after 7s
Tests / frontend (push) Successful in 34s
Tests / e2e (push) Has been cancelled
GET /questions/detail/{id}/figures had no check at all, sitting next to
a detail route that has one. Any signed-in account could ask for any
question's figures by id and be handed its explanation images: the
paths, and the library record that now rides on them — whose titles run
to "Neonatal Herpes Simplex · Q874". No attempt required, and the answer
in the title. Found by walking today's surfaces as a real learner
account rather than reading the guards.

Same rule as the route beside it: the stem is readable in the bank, the
answer side belongs to whoever writes the question.

Also, refining an article no longer breaks the links into it. The refine
path replaces the whole section list and the model was never shown the
existing ids, so it invented fresh ones — silently breaking every
`[[95#id]]` pointing at a section, from another article, a question's
key point or a study plan's reading. The id travels in the heading now
and the prompt says to return it unchanged for any section kept. The
model is also told to leave existing `[[123|links]]` exactly as written
and never to invent one, because a guessed number points at nothing.

And the section strip is centred. Widening its box to 1600px let the
links spread but `flex: 1` on the strip — right for every other strip on
the site — filled the whole box with the links against its left edge:
measured at 1500px, they began 70px left of the page content while the
strip ran 180px past its right. Content-sized and centred now, still
scrolling when the links genuinely outrun the window. Diagnosis from the
ped-ai session; verified at 1500 and 1920 with no arrows at either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 15:29:29 +02:00
Daniel
deb8ee7830 fix: the high-yield view was the longest prose in the article
Some checks failed
Tests / backend (push) Failing after 6s
Tests / frontend (push) Failing after 41s
Tests / e2e (push) Failing after 40s
Measured across the 323 AI drafts in the bank before touching anything.
The prompt describes three readings of a topic and the second of them
was not happening:

  short     326 sections   1 per article   1,808 chars   4 highlighted
  long    2,112 sections   6.5 per article   721 chars
  clinical 1,060 sections                     531 chars

So the revision view — the thing meant to be tight lists of what a
candidate must know — came out as a single section two and a half times
longer than a full one, written as bullets that were paragraphs, and 4
of 326 carried the ==highlight== the reader has a renderer for. "Tight
lists, not prose" and "sparingly" are adjectives, and the model read
them as suggestions.

They are numbers now: 2 to 4 separate sections, each under 600
characters, bullets of at most 20 words, and one to three highlighted
facts per section with an example of what that looks like.

Two drafts written against the live model afterwards:

  Kawasaki disease        3 short sections, 292 chars avg, 3/3 highlighted
  Neonatal hypoglycaemia  3 short sections, 227 chars avg, 3/3 highlighted

and the reader draws nine key-point marks on the first of them.

Also, refining no longer loses a section's variant. The existing draft
went to the model as a flat list of `## Heading`, so it had to guess all
over again which sections were the bedside and which were the revision
view — a refine could quietly move one into the other. The variant
travels in the heading now and the prompt says to keep it.

Left alone deliberately: the 16,000-token ceiling (no truncation
failure in the logs to justify moving it) and temperature 0.4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 15:19:37 +02:00
Daniel
3c7931a350 feat: find an article and drop its link, from wherever you are writing
Some checks failed
Tests / backend (push) Failing after 4s
Tests / frontend (push) Failing after 27s
Tests / e2e (push) Failing after 36s
The picker already existed and did the right thing — search by title,
expand an article to pick one section, click to get `[[264|Croup]]` or
`[[264#workup|the workup]]` on the clipboard. It was wired into exactly
one place: the article editor. Everywhere else, linking still meant
opening the library in another tab and reading the id out of the address
bar.

It is on the markdown toolbar now, so every field that has one gets it:
a question's stem, its explanation, and each option's reasoning — seven
buttons on the question editor, which is most of the places a
cross-reference is actually written. The marker lands at the caret
rather than at the end, and still goes to the clipboard, so it works the
same whether you insert it or paste it elsewhere.

The card editor has its own button, because its two faces are plain
textareas with no toolbar. It remembers which face the caret was last
in — a marker belongs where somebody was typing, not always on the back.

Lazily loaded: it carries a search box and a request, and most edits
link nothing.

Verified on the live question editor: search "croup" returns four
articles and clicking one appends the marker to the stem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 14:45:45 +02:00
Daniel
963ca04cf8 feat: no account creation here — the provider makes accounts
Some checks failed
Tests / backend (push) Failing after 7s
Tests / frontend (push) Successful in 30s
Tests / e2e (push) Failing after 33s
Gone: /auth/register, /auth/signup-policy, POST /admin/users, the
RegisterPage, the register half of the landing modal, the Register
button, the "Sign up" link, the "Create an account" hero button, and the
UserCreate schema. /register redirects to /login for anybody holding a
bookmark. A first admin on a fresh install still comes from
DEFAULT_ADMIN_EMAIL at startup, so nothing is locked out.

And no flash of the old way in. Both sign-in surfaces defaulted to "no
provider" and drew the email form while /auth/sso/config was in flight,
then swapped it — so a reload showed a form that does not exist, briefly,
every time. They render nothing until the answer arrives. The landing
modal is now one button, "Sign in with PedsHub SSO", with no sentence
under it: the button already says where you are going.

Also, the section strip takes the width it has. It sat inside the 1200px
measure that keeps an article readable, so on a wide desktop the last
entries fell off the end and a scroll arrow appeared beside acres of
empty space. Verified at 1280, 1600 and 1920: ten links, no arrows.

And "Make a deck" comes out of the strip and the phone menu — that was
an over-reach on my part. The landing CTA keeps it, pointing at
app.pedshub.com/#resources, which is what was actually asked for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 14:37:10 +02:00
Daniel
e858f4c166 fix: a schema promising an owner where the column now says NULL
Some checks failed
Tests / backend (push) Failing after 5s
Tests / frontend (push) Successful in 29s
Tests / e2e (push) Failing after 33s
Opening any shared deck answered 500 — ResponseValidationError, "Input
should be a valid integer" for user_id. The ownership migration made
those columns nullable and the response models still declared
`user_id: int`, so the first read of a deck after it was a crash rather
than a page. A learner hit it on Cards.

FlashcardDeckResponse, DocumentResponse and QuizResponse now allow None,
with a test that walks the three and fails if any of them promises an
owner again. The grant-input schemas were left alone on purpose: their
user_id names the person a grant is for, and a grant with nobody in it
is not a thing.

Also gone: send_login_code_email, forty-eight lines of email template
for a feature that no longer exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 14:17:55 +02:00
Daniel
1f2770257f docs: a Help page for the people who use the site, a Handbook for whoever runs it
Some checks failed
Tests / backend (push) Failing after 9s
Tests / frontend (push) Successful in 29s
Tests / e2e (push) Failing after 27s
They were one page, and it served neither. Somebody wanting to know what
"Studying for" does had to scroll past what the tutor is prompted with
and what re-embedding breaks.

/help — every signed-in account. Signing in through PedsHub SSO, what
"Studying for" scopes, Qbank against Sessions against Collections, study
and exam mode, where performance comes from, Reading and Cards and study
plans, "Make a deck", and how to report a bad question. An educator also
gets a section of their own: what a moderator has, what a grant gives
and what it does not, that nothing in the bank belongs to anybody, how a
draft becomes an article, how a plan is built, and what a moderator
cannot do. A learner never sees that half.

/handbook — administrators only now, with the same FAQ plus the rest.
The Settings card that pointed at it is admin-only to match, and
RequireAuth learned an admin door, which it did not have.

Help is in the account menu rather than the footer: a question you have
while working is answered from where you are.

And the repo docs describe the site that exists. CLAUDE.md lost the LMS
section — courses, modules, lessons, enrolments, all removed months ago
and still documented — and gained the permission model and the sign-in
flow. ADMIN.md's role table said moderators create courses; it now says
what the three roles actually reach, where roles come from, and that the
bank has no owners.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 14:12:47 +02:00
Daniel
12453ba473 fix: a role is not set here while the provider holds it
Some checks failed
Tests / backend (push) Failing after 4s
Tests / frontend (push) Failing after 28s
Tests / e2e (push) Failing after 34s
Two endpoints still set a role locally — PUT /access/{id}/role and PUT
/admin/users/{id}/role. With OIDC_ROLE_CLAIM configured, my sync applies
the person's groups on every sign-in, so a role set in the app lasts
until they next sign in and is then overwritten. That is the worst kind
of setting: it appears to work, holds for an afternoon, and quietly
undoes itself.

Both now answer 409 naming where the switch actually is. With no mapping
configured they behave exactly as before, which is what a site with no
provider needs.

The two existing tests now patch is_configured explicitly rather than
inheriting whatever the container's .env happens to say — a test that
reads differently on two machines is not a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 14:04:16 +02:00
Daniel
cf2f42975e feat: no invite codes and no email sign-in codes — that is the provider's job
Some checks failed
Tests / backend (push) Failing after 8s
Tests / frontend (push) Successful in 33s
Tests / e2e (push) Failing after 33s
Both were this app doing an identity provider's work. Sign-in and
sign-up happen at sso.pedshub.com now: it takes the address, sends the
code, checks it, and knows about second factors — none of which belongs
here, and two of which were never done here at all.

Gone: services/invites.py, services/login_codes.py,
routers/login_code.py, the two models, the three admin invite routes,
the invite_only flag and its switch, the invite field on both sign-up
forms, and the code half of the sign-in page — which was the primary way
in and is now a button that says "Sign in with PedsHub SSO". The
password form stays for a site with no provider configured.

Migration r7b8c9d0e1f2 drops invite_codes (three spent rows) and
login_codes (empty). The dump beside it has both.

585 tests, and the contract snapshot is 320 routes — five fewer, all
five named in the diff so the removal is reviewable rather than
discovered later by a client.

Also: "Make a deck" in the signed-in menu and on the landing page, going
to the scribe's My Resources at app.pedshub.com/#resources. Same
sign-in on both sides; the arrow says it leaves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 13:56:48 +02:00
Daniel
b6cfcaa1e9 feat: the bank belongs to a role, not to a person
Some checks failed
Tests / backend (push) Failing after 5s
Tests / frontend (push) Successful in 32s
Tests / e2e (push) Failing after 26s
571 categories, 21 uploaded documents, 14 articles, 8 card decks, 30
shared tests and 2 questions carried somebody's name — mostly
daniel@danvics.com, which is not even the working administrator any
more. So "who may edit this" partly depended on who happened to create
it, and handing the site to somebody else would have meant rewriting
every one of those rows.

Migration q6a7b8c9d0e1 empties those owner columns and makes them
nullable, because ownerless is now a legitimate state and a NOT NULL
owner is exactly what forced a name onto every row. Nothing is deleted
and nothing moves. What keeps its owner, deliberately: attempts, notes,
favourites, collections, folders, study-plan progress, and the quizzes
that are somebody's own sittings rather than shared bank tests.
study_plans needed nothing — it never had an owner column.

Then the code, so it cannot grow back. Authorship is no longer a way in
anywhere: may_edit_question and can_edit_article ask the role and the
grants and nothing else; the article draft, status and delete paths lost
their "or you wrote it" arm; decks are the bank's, so an educator
reaches any of them and a learner reaches the shared ones; documents are
the corpus, so they are editors-only rather than "mine"; and every
creation path writes user_id NULL. The bank listing's "mine" facet went
with it — it counted nothing and could only ever count nothing.

Verified against production as a real learner account: every bank write
403s, admin settings 403, documents empty. As an admin, everything
opens.

Also: a category grant no longer offers Editorial in the menu. It offers
Questions and Images, which is what a grant covers; Editorial is the
whole library's review queue and its route is moderator-only, so the
entry was a door that answered "Not yours to open".

Six tests changed rather than deleted — they asserted the old model, and
each now asserts the new one: writing an article does not make it yours,
writing a question does not make it yours, an answer image is not opened
by authorship, the tutor is not opened by authorship.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 13:26:25 +02:00
Daniel
8d7485eaad fix: the public page's own sign-in box knew nothing about the provider
Some checks failed
Tests / backend (push) Failing after 4s
Tests / frontend (push) Failing after 28s
Tests / e2e (push) Failing after 30s
/login was right; the landing page has a second, separate sign-in modal
and it still offered Email, Password and "Forgot password?" on a site
where none of the three can work. It now leads with "Sign in with
PedsHub SSO", drops the email form and the register half entirely under
sso_only, and says in one line where sign-in happens.

Both halves come from /auth/signup-policy and /auth/sso/config, the two
the page was already entitled to ask.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 13:06:24 +02:00
Daniel
282b6a25f7 fix: no password sign-up offered on a site that signs in through a provider
Some checks failed
Tests / backend (push) Failing after 5s
Tests / frontend (push) Successful in 27s
Tests / e2e (push) Failing after 32s
/register drew the whole form under SSO-only — name, email, password
twice, invite code, Sign Up — and only the POST refused it. That is
asking somebody for four fields and a password they will never use
before telling them the door does not exist.

/auth/signup-policy is the question the form already asks, so it answers
it: sso_only, the provider's name, and registration_open false whenever
single sign-on is the only way in. The page says one line instead. The
Register tab on the landing modal and the Register button in the header
go with it — and the modal falls back to Sign in if it was opened
straight into Register.

The sign-in page needed nothing: it already hid its "Sign up" link on
registration_open false, which is now also true under SSO.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 06:02:01 +02:00
Daniel
c9e0655d6e fix: shut every password door under SSO-only, and keep the token out of the URL
Some checks failed
Tests / backend (push) Failing after 9s
Tests / frontend (push) Successful in 29s
Tests / e2e (push) Failing after 27s
Two findings from a security pass on the SSO path, both real.

sso_only gated login and the login codes and nothing else. Register,
forgot-password, reset-password, resend-verification and setting a
password through PUT /auth/me all went through — so a site running
single sign-on could still mint a password account nobody vetted, and if
the flag were ever turned off, there it would be. One helper, five doors,
403 with a reason at each.

And the access token travelled in a query string. The SSO redirect is a
page load, so the browser asked nginx for /sso-callback?token=<a live
bearer token, good for a day> and nginx logs the request line — every
sign-in wrote one into the frontend container's access log, the
browser's history, and the Referer of whatever loaded next. It carries a
one-time code now: a random 32 bytes parked in Redis for sixty seconds,
traded at POST /auth/sso/exchange for the token, and deleted as it is
read, so a code replayed from any of those places buys nothing.

Also the OIDC state cookie, which is what stops an authorization
response being replayed at you: same_site lax (strict drops it on the
provider's top-level GET and fails every sign-in) and secure whenever
APP_URL is https.

And one cross, not two. The header's menu button is already a cross
while a drawer is open, so the drawer's own close button was a second
control an inch below it for the same job — gone from the article, the
player and the review.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 05:55:37 +02:00
Daniel
6137bf65b8 fix: the SSO login button 500s — the redirect was never awaited
Some checks failed
Tests / backend (push) Failing after 4s
Tests / frontend (push) Successful in 34s
Tests / e2e (push) Failing after 29s
Authlib's Starlette client is the async one, so `authorize_redirect`
hands back a coroutine. `sso_login` was a sync `def` that returned it,
and FastAPI tried to serialise a coroutine as a response body:
ValueError: 'coroutine' object is not iterable. Every click of the SSO
button was a 500.

It has presumably always been broken. Nothing caught it because nothing
had a provider configured to click the button with — /auth/sso/config
answered sso_enabled:false, so the button was never drawn. It surfaced
within a minute of an Authentik being pointed at the app.

Verified against the live provider: /api/auth/sso/login now answers 302
to sso.pedshub.com/application/o/authorize/ with response_type, scope,
state and nonce.

A shape test guards it — the endpoint is a coroutine function and the
redirect is awaited — because the alternative is standing up an OIDC
provider inside a unit test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 05:30:34 +02:00
Daniel
7f36e07af0 feat: SSO reads roles from the provider's groups, and needs a verified address
Some checks failed
Tests / backend (push) Failing after 4s
Tests / frontend (push) Failing after 33s
Tests / e2e (push) Failing after 34s
Everybody arriving through SSO landed as a learner and was promoted by
hand. That is fine for three people and wrong for three hundred: the
list of who may edit the bank then lives in two places and only one of
them is maintained. OIDC_ROLE_CLAIM names the claim that carries somebody's
groups ("groups" for most providers, "roles" for Entra app roles), and
OIDC_ADMIN_GROUPS / OIDC_MODERATOR_GROUPS say which values mean what.
All three shapes a provider might send are read — a list, a
space-separated string, a comma-separated one — and matched case-blind.

Applied on every sign-in, not only at creation, because a group somebody
can be added to and never removed from is not a list anybody can rely
on. The one demotion it refuses is the last administrator: a mistyped
group name should not lock everybody out of the settings page,
including the person who could fix the group name. It is logged when it
happens.

Blank OIDC_ROLE_CLAIM is the old behaviour exactly: role "user", set
once, never touched.

And an address the provider will not vouch for is not an identity. This
matches on email, so an explicit email_verified:false would hand an
existing account to whoever typed its address at a careless provider.
Refused now — only on an explicit false, since a provider that omits the
claim is not asserting anything either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 05:20:52 +02:00
Daniel
cc4463d42c fix: /questions/detail hands out the answer to anybody signed in
Some checks failed
Tests / backend (push) Failing after 7s
Tests / frontend (push) Successful in 35s
Tests / e2e (push) Failing after 31s
Found by walking the permission model as a real learner account rather
than reading the guards. The bank listing has always nulled the answer
side for whoever does not write the question; this route, added for the
full-page editor, returned the row whole behind nothing but
get_current_user. Any signed-in account could ask for
/questions/detail/3869 and be handed the correct option, the
explanation, the per-option reasoning and the key points for a question
it had never sat — 2,924 questions, one URL, no attempt required.

Same rule as the listing now, via the same may_edit_question: the stem
still reads, the answer side comes back null, and explanation figures —
answer-side by definition — are filtered out with it. Nulled rather than
refused, because the stem is legitimately readable.

Three tests: a reader gets the stem and none of the answer, whoever
writes it gets all of it, and an author counts as writing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 05:12:01 +02:00
Daniel
094bab20cd feat: an opened explanation figure shows what the library knows
Some checks failed
Tests / backend (push) Failing after 9s
Tests / frontend (push) Successful in 38s
Tests / e2e (push) Failing after 43s
Two voices, and they belong in different places. The thumbnail in an
explanation carries what the *question* says about the figure — words an
educator chose for this question. Opening it now adds the catalogue
entry underneath: what the image was filed as, the description written
when it was filed, where it came from, and any marks an educator drew on
it, behind a switch.

It rides on explanation rows only. Those are already withheld by the
server until answers are revealed, so the record reaches somebody who
has finished the question and gets nowhere near somebody who has not —
which is the whole reason the caption came off the figure in the first
place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 05:05:59 +02:00
Daniel
954b13e7b3 fix: a speech model is added with its voices, and Orpheus is sent where it works
An Orpheus id (groq-orpheus-english) did not start with "local-", so
generate_tts_audio sent it down the OpenAI path and every call failed. It
also could be added with no voice, and the "local-%" filters in /tts/voices
and the default lookup hid any non-local voice from learners even when it
was added and marked default.

services/tts_voices.py is the one table of which voices belong to which
model (Kokoro, Orpheus English/Arabic, Fish), the same table the scribe app
keeps. Anything the table knows, or anything local-*, goes through the
LiteLLM gateway with the options its family needs (Orpheus: wav). Adding a
bare model id creates one row per voice with friendly names, so an
administrator adds "groq-orpheus-english" and six voices appear to test one
by one; a voice from another family is refused, naming the ones that work.
Learners are offered every active voice, each saying which model serves it,
and /tts/speak answers with the media type the model actually returned.
Kitten and Supertonic tables go — those models left the gateway.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 05:04:37 +02:00
Daniel
6312bf7a00 fix: Tidy says Tidy, and takes the shake out when pressed
Some checks failed
Tests / backend (push) Failing after 4s
Tests / frontend (push) Successful in 35s
Tests / e2e (push) Failing after 37s
It was a bare ⌁, 30px square, wedged between the kind and the remove
cross in a 360px panel — which also squeezed the label box down to four
characters. Nobody could be expected to recognise it, and the answer to
"where is Tidy" was "you are looking at it". The row is two lines now:
the shape and its two buttons, then the label across the full width, and
the button says Tidy.

And pressing it does something. smoothOut ran Ramer–Douglas–Peucker and
stopped: simplification drops the points that carry no shape, but it
never moves the ones it keeps, so a stroke that wandered by more than
epsilon came back wandering by exactly as much. A relaxing pass follows
it now — a moving average over the interior, twice — so the shake goes
and the route stays. The two ends never move: where somebody put the pen
down and lifted it is the deliberate part of a freehand mark.

Still offered rather than applied, per shape and undoable. A traced
anatomical edge is meant to wander.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 04:50:19 +02:00
Daniel
7c1689ca7f fix: Editorial is behind the Editorial door, including its article pages
Some checks failed
Tests / backend (push) Failing after 3s
Tests / frontend (push) Successful in 25s
Tests / e2e (push) Failing after 30s
The two editorial article routes were in the authenticated block rather
than the moderator one, so any signed-in learner could open
/editorial/articles/331 — the read view, but under Editorial's name and
with a crumb to a queue they are refused. They sit behind the same guard
as the queue now.

And the way through from Reading is offered to moderators and admins
only. It was offered on `canEdit`, which also covers whoever wrote the
article — not the same thing, and for a plain author that link is a
Forbidden page with a friendly name on it.

An ?edit=1 on a reading address follows the same rule: a moderator is
taken to the editorial address, and for anybody else the parameter is
simply dropped. Sending a learner to a door they cannot open is worse
than ignoring a query string they did not type.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 04:41:04 +02:00
Daniel
e62a742b73 fix: Reading cannot edit, and the menu button reaches the menu everywhere
Some checks failed
Tests / backend (push) Failing after 5s
Tests / frontend (push) Successful in 27s
Tests / e2e (push) Failing after 28s
Reading is read-only. Edit, Unpublish and Generate cards sat in the
reading toolbar, so an educator reading between questions was one
mis-tap from the editor — which is how somebody opened edit mode on
Bronchiolitis in the middle of a session. All three live at
/editorial/articles/:id now, with one named door through from Reading
for whoever may edit. An ?edit=1 on a reading address is not ignored:
the ask is fine, the address is wrong, so it is taken to the editorial
one.

And a cross-reference followed from Editorial stays in Editorial.
Reading and Editorial are two modes of the same page, and one link out
of the second into the first put an educator into the learner's view of
the next article with no way back to the queue.

The menu button now reaches the menu on every page it has been taken
over on. On a phone the burger belongs to whatever is on screen; the
player handed back the site menu as a tab inside its drawer, and an
article and an answer review did not — so on those two the only menu
button on the page could not open the menu. Both have the player's own
two tabs now, from one list of links rather than three copies of it
(one of which pointed Qbank at /questions, which has never been a
route).

And the button shows a cross while what it opens is open. It kept its
three bars behind an open drawer, an inch from the cross inside that
drawer: two controls disagreeing about what was on screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 04:36:44 +02:00
Daniel
734ff194f2 fix: a question's figure says what the question says, not what the library says
Some checks failed
Tests / backend (push) Failing after 5s
Tests / frontend (push) Successful in 31s
Tests / e2e (push) Failing after 32s
The caption under a figure in a session was the image library's own
description, and a library description is written to catalogue an image:
"an X-ray of a child's pelvis and hips, showing abnormalities in the
right hip joint" is a fine catalogue entry and a complete giveaway under
a stem about a limping five-year-old. One line in figure_json fell back
to it, and every one of the 343 figures in the bank was inheriting one —
338 of them on stems, read before the question was answered. Not one
figure had a caption of its own.

So a figure carries what the question says about it and nothing else.
The caption and the label are set on the question, when the image is
attached; most question figures want neither, and now show neither. The
catalogue title went with it — nothing rendered it, and it gives the
same thing away over the wire.

The second route in was the viewer: an image written into a stem as
markdown opens a panel that fetches what the library knows and prints
it. Inside an attempt it no longer asks. In the library, in an article,
in review of the image itself, the description is still the
description.

Also: no PMID links. A reference list says what was read; it is not a
set of doors out of the article. The number is there to look up, as
text.

And a drawer's own button is a cross. It kept the ☰ that opened it while
covering the screen, which reads as a second menu rather than the way
out of the one in front of you.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 04:26:59 +02:00
Daniel
dbfdff90a1 fix: Reading is published, Editorial has its own address, and repeat works
Some checks failed
Tests / backend (push) Failing after 5s
Tests / frontend (push) Failing after 25s
Tests / e2e (push) Failing after 30s
Reading shows nobody drafts, not even an admin. An educator's unfinished
work sat among the published shelf with a tag on it, which made Reading
and Editorial two views of one list and left you unsure which you were
looking at. The list is published-only for everybody now, and the tag
and its style are gone with it — the badge stays on an article's own
page, where a draft can still be opened.

And Editorial has its own URL. /editorial/articles/:id renders the same
page, but the crumb reads "Editorial" and goes back to the queue.
Opening an article from the queue used to land on Reading's address, so
the only way out was the top of the published library — you lost your
place in the queue to look at one draft. Drafting from the reading page
lands there too, because a new draft is editorial work from the moment
it exists.

References from PubMed are fields, not a sentence. Every other
reference on an article is {title, author, pages} and the reader reads
those keys, so the flat line the PubMed path wrote drew as six blank
rows under a References heading: the DKA draft cited six real papers
and appeared to cite none. A paper now fills journal, year and PMID
instead of pages, and the PMID is a link to the record. Rows written
before this pull themselves apart on the way out rather than being
rewritten in the database, so the drafts that already exist heal
themselves.

Repeat session has never worked. The dialog asked the bank for mode
"study" — the name of the route it lands on — and the bank has "timed"
and "learning", so every repeat came back 422 and the dialog reported
its own house message, "Could not build that session", because the
detail was a list rather than a string. Both fixed: the right mode, and
a server that says something is quoted rather than swallowed.

And the objective named in "your performance analysis for Pediatrics
Boards" opens the objective picker. It was a link to the account page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 04:16:46 +02:00
Daniel
2bc1b9fbd8 fix: a deck keeps the article's filing, and the card fills the window
Some checks failed
Tests / backend (push) Failing after 7s
Tests / frontend (push) Successful in 28s
Tests / e2e (push) Failing after 29s
Four things a deck got wrong.

The category. Every deck written from an article is filed where the
article is filed — and the list drew all of them "Uncategorized",
because the router defines its own FlashcardDeckResponse that shadows
the one in schemas/, and that one has no category_id. So the field was
set on the row, returned by nothing, and an educator refiled by hand
what the system had already filed correctly. The shared schema was
imported by no module at all, so it is gone rather than left as a
second definition to read past next time.

The size. Fifteen cards is the per-chunk default, and an article is one
chunk however long it is — a ten-section piece and a two-paragraph stub
both asked for fifteen. Now roughly a card per 150 words, floored at 12
so a short article still makes a deck and capped at 30 so one call stays
inside the model's output.

The card. Set at list-item size inside a frame that fills the window, so
a two-line question sat in the middle of an acre of white. The face
scales with the window and stops at a comfortable measure; the back is
set smaller than the front, as prose rather than a headline.

And the contract snapshot, which still owed the jobs endpoint from the
last commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 03:58:59 +02:00
Daniel
4aa1352da7 feat: jobs move to the workbench, with their logs and a way to clear them
Some checks failed
Tests / backend (push) Failing after 11s
Tests / frontend (push) Successful in 33s
Tests / e2e (push) Failing after 31s
**The badge is off the navbar.** It sat in the header of every page, for
everybody, polling every thirty seconds — for a number that means something to
the handful of people who start an extraction and nothing at all to a learner
sitting a session. Extraction is workbench business and it lives there now:
Settings → Tools → Jobs, and a link from the workbench itself, which is where
one is started.

To answer the question it raised: a job ages off the list after a day, and the
steps behind it after an hour. Which is to say it disappears when Redis forgets
it, on its own, with nothing to tell you it had.

**So there is now a way to clear one.** "Forget" takes a job off your list, with
a confirm beside it. It stops nothing that is running — the button is not
offered for a running job — and deletes nothing the job produced; it clears a
line somebody has read and dealt with so the ones they have not are not buried
under it. Only from your own list: the id alone is not authority over anybody
else's, and the keys behind it are shared.

**And a way to read one.** The details panel is called "Log" now, because that
is what it is — every step the job took, in order, and the reason it stopped if
it stopped. That reason has been recorded all along and shown nowhere.

Also, on the deck: the way out is a back link above the title like every other
page rather than a small grey button in the bar of card controls, and the four
buttons that are the whole interaction are full size.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 03:41:22 +02:00
Daniel
8461abf5bf feat: the deck is the window, the chips behave like links, and looking counts
Some checks failed
Tests / backend (push) Failing after 6s
Tests / frontend (push) Successful in 28s
Tests / e2e (push) Failing after 32s
**The deck fills the window properly now.** The shell was the right height but
the site's footer still sat below it, so the page could be scrolled — and
scrolling it took the deck's header off the top and put "Dashboard · Reading ·
Search" under the verdict buttons. The footer is hidden while a deck is open,
exactly as it is for the player, and the page no longer scrolls at all. Same on
a phone: the box stays, only the padding changes, and the burger stays the
site's menu because there is nothing else for it to mean here.

**The chips under a correct answer are links like any other.** An article chip
now gets the hover card — what the article says, a tab, or the pane beside the
answer — instead of navigating out of a session with no way to see first
whether it was worth it. A deck chip asks: it names the deck, says that
studying it leaves the session, and offers Study now, New tab, or Not now.

**Showing an answer counts the card.** The header moves as you work, so Next is
plainly the next thing to do. Counted separately from the verdict, because
seeing an answer is not knowing it and adding the two together would make the
mastered figure a lie — it reads "3 seen · 20% mastered".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 03:35:53 +02:00
Daniel
3e03aa78cc feat: a deck is studied in the window, the way a session is sat
Some checks failed
Tests / backend (push) Failing after 9s
Tests / frontend (push) Successful in 35s
Tests / e2e (push) Failing after 30s
Boxed like the player, and for the same reason: this is one thing at a time,
and a card floating in the middle of an empty page reads as an aside rather
than as work. The deck takes the window, the bar saying what you are studying
and how far in is fixed at the top, the card fills what is left and scrolls
inside its own frame, and the controls are fixed at the foot.

The foot is the player's three-part bar, adapted to what a deck actually has:
the way back to the decks at the left, Prev / Show answer or the verdict /
Next in the middle, the keyboard shortcuts at the right. No Exit session — a
deck has no block to hand in — and no menu, because the controls that change
the run (All, Review, Shuffle, Reset) are at the top where they belong.

The navbar's 32px bottom margin is zeroed while a deck is open, as it is for
the player: the shell is measured against the window minus the header, and
that margin was 32px the arithmetic did not know about — exactly how far off
the bottom of the screen the verdict bar had ended up.

Separately: no "AI draft" and no "New article" on the reading page. Reading is
where an article is read, by everyone including the people who write them.
Writing one starts in Editorial, which is the queue of what still needs a
person, and whose rows already open the editor rather than the reader — so an
administrator reading the library and an administrator working through it are
two different doors, which is the point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 03:30:04 +02:00
Daniel
2e4e4000a5 fix: the study page had no styles of its own, and a thumbnail stretched to its caption
Some checks failed
Tests / backend (push) Failing after 8s
Tests / frontend (push) Failing after 30s
Tests / e2e (push) Failing after 34s
Two things, and the first explains why the deck looked unstyled on your screen
and boxed on mine.

**The study page's appearance lived in the deck *list*'s stylesheet.** Each
page is its own bundle, so opening `/flashcards/:id/study` directly loaded the
markup with none of it: no card, no border, no pinned verdict bar — just
left-aligned text on the page background. It looked right only if you happened
to arrive from the list, which had already pulled that CSS in. The study page
has its own stylesheet now.

**The thumbnail's frame stretched to the width of its caption.** A figure is a
column — picture, then caption — and a column stretches its children to the
widest of them by default. A portrait radiograph half the caption's width
therefore sat in a bordered box the caption's width, with a white panel beside
it inside its own frame. That is the white panel in the screenshot.

Also: the card shrinks to its content up to the height it has, rather than
filling it — a one-line front in a box four hundred pixels tall is mostly
emptiness — and a card no longer prints its own front text under its picture,
where it said the same thing twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 03:21:47 +02:00
Daniel
0a2f7d08db fix: a link on a card previews, and the deck reads like the player
Some checks failed
Tests / backend (push) Failing after 4s
Tests / frontend (push) Failing after 26s
Tests / e2e (push) Failing after 30s
The preview card was there all along and invisible. It was positioned inside
the paragraph it belonged to, and a flashcard is a small box with its overflow
hidden — so the card was drawn and clipped, and the link read as doing nothing.
I had "fixed" that by turning previews off on cards, which was the wrong end of
the problem: the preview is what the link is for.

It is drawn into the body now, placed from the link's own rectangle, so nothing
between the two can clip it. That is a fix everywhere, not only on cards.

Two things behind it:

- **Clicking a link flipped the card back**, because the whole card is a flip
  target and the click bubbled — so the preview opened and the thing it was
  anchored to vanished in the same gesture. Only the empty parts of a card flip
  it now.
- **There was nowhere for "Split view" to go** from a card. The deck now has a
  pane of its own: the article opens beside the card, the deck keeps its place,
  and following a reference out of the article replaces the pane rather than
  losing you.

**The deck is boxed like the session player.** Header at the top, verdict at
the foot, and only the card scrolls between them. A card with a picture on it
is taller than the window, and the two buttons the whole exercise turns on were
below the fold: you scrolled down to read the answer and back up to say whether
you knew it. Unflipped, the foot offers Show answer rather than two buttons
about a card you have not seen.

And the four grey pills down the side of every row in Browse are one strip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 03:05:24 +02:00
Daniel
6305d82e95 fix: card generation, the reader's own styles, and Tools that owned other people's work
Some checks failed
Tests / backend (push) Failing after 4s
Tests / frontend (push) Failing after 25s
Tests / e2e (push) Failing after 28s
**Card generation was failing every time.** The model configured for the
flashcard task — and for extraction and keyword — was `ds-deepseek-v4-flash`,
which the AI proxy no longer serves. All three now point at the v4.1 model that
does exist, and the dead rows are gone.

The reason was reachable only by reading the job record: the panel said "Card
generation failed." and nothing else, while the proxy's actual answer sat in a
field nothing displayed. A failed job now says which model is missing and where
to change it.

**The reader had no styles of its own.** Its appearance lived in
ArticlesPage.css, imported by the reading page — so an article rendered
correctly there and as unstyled boxes anywhere else it was used. The split pane
inside AI Mode is where that finally showed: section headings drew as bare
bordered rectangles. The reader imports what it needs now.

**Settings → Tools listed the question workbench and the taxonomy**, which are
question work and belong beside the questions. Both are on the question
manager's own bar now, the taxonomy's way back leads to Questions rather than
to a settings page nobody was on, and the workbench's does too.

Also: air under AI Mode's composer, which sat flush against the footer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 02:54:44 +02:00
Daniel
93a3b74011 fix: a link inside the split pane just goes
Some checks failed
Tests / backend (push) Failing after 5s
Tests / frontend (push) Failing after 26s
Tests / e2e (push) Failing after 28s
"Beside what you are reading, or in a tab?" is a question with one real answer
for somebody who is already beside what they were reading. Inside the pane the
card offered Split view — which replaced the pane they were in — and the click
that would have simply followed the link opened a card instead.

So inside the pane the link goes: the pane follows it, the trail records it,
and Back returns to the reference that sent you. The hover card is still what
happens everywhere else, where the question is real.

One of the tests covering this was passing without testing anything. It held ⌘
with `userEvent.keyboard('{Meta>}')` and then clicked with a *different*
`userEvent` session, and modifier state lives on the session — so the
"modified click" it asserted about was an ordinary one. It holds the key now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 02:47:48 +02:00
Daniel
febb14490c feat: ground AI drafts in the library and PubMed, and mend the card system
Some checks failed
Tests / backend (push) Failing after 15s
Tests / frontend (push) Successful in 28s
Tests / e2e (push) Failing after 26s
**Two sources an AI draft can draw on**, both off until an administrator turns
them on, both appended to the prompt as extra material rather than woven into
it — so a draft with nothing to draw on is byte-for-byte the draft that has
been working well.

- *The clinical library.* The indexed shelf the clinical assistant already
  searches, over MCP on the internal network. Ported from ped-ai: sessions are
  reused, a dead one is reopened once, and a library that cannot be reached
  never fails the article — it just means the educator is writing without it,
  and the progress line says so.
- *PubMed.* NCBI's E-utilities, no key required. Ported whole, including the
  two lessons that cost somebody an afternoon over there: PubMed ANDs every
  term, so "bronchiolitis management in infants" can find nothing where
  "bronchiolitis management" finds six — hence the query ladder — and three
  esearch calls in a row will trip the rate limit, hence the spacing. The
  reference list is written from the records rather than by the model, so every
  line is a paper that exists with a PMID somebody can look up.

Measured on the live stack: 24 excerpts, 6 papers, 6 references, 6 in-text
citations, in one draft.

**The card system, which turned out to be half-built:**

- There was no way to make a deck by hand, and no way to edit a card at all —
  you could browse, view and delete. Both are there now, the editor taking
  front, back and a picture.
- Filing, writing, sharing and deleting are all educator work now, behind one
  named gate rather than four scattered checks. A learner studies.
- A deck generated from an article inherits that article's category instead of
  landing in Uncategorized for somebody to file by hand.
- A link inside a card previewed instead of going. A card is a box a few lines
  tall, often inside a flipping panel, and a hover card anchored in one is
  clipped by it — so the link read as broken because clicking it did nothing.
  Where there is no room to preview, the honest behaviour is to take you there.

**An AI draft belonged to no editorial queue.** Nothing set `generated_by`, so
a drafted article was neither "generated, unread" nor anything else: the tile
counted it and there was nowhere to click. Drafts are stamped with the model
that wrote them, and there is now a plain Drafts queue that cannot be fallen
through.

**The sign-in code email** is laid out rather than written: the code is the
biggest thing on the screen, then which account it signs into, then a way back
to the page, then permission to ignore the whole thing.

Also: a back link out of a deck, in the same words as the rest of the app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 02:44:42 +02:00
Daniel
158930d532 feat: section links in prose, a picker that writes them, and cards that render
Some checks failed
Tests / backend (push) Failing after 5s
Tests / frontend (push) Successful in 28s
Tests / e2e (push) Failing after 36s
**Cross-references can name a section.** `[[264#workup|the workup]]` opens the
reader at that heading, which is what a sentence about one part of a long
article actually means. Whole-article `[[264|label]]` is unchanged, and a
section renamed since is not a broken link — it lands at the top of the right
article, which is a mild disappointment rather than a dead end.

**A picker that writes the marker for you.** 🔗 Link an article, in the editor:
type a few words, click the article — or one of its sections — and the marker
is on the clipboard with the right title as its label. Getting an id used to
mean opening the library in another tab, finding the article and reading the
number out of the address bar, which is four steps and a chance to mistype,
every time. Its own small endpoint, because the listing deliberately does not
carry sections and this needs nothing else.

**Three things about cards that were built but never drawn:**

- A card can carry an image. The column is there, the API returns it, the
  editor accepts one — and no view in the app rendered it, so every picture
  anybody attached to a card was stored and never seen. Both card views show it
  now, small until clicked like every other figure.
- The deck browser printed `[[331|Epiglottitis]]` as brackets and a number. The
  study view has rendered them as links for a while; now both do.
- There was no way to make a deck by hand. Every deck came out of a model —
  generated from a document section or an article — so an educator who wanted
  to write six cards had nowhere to put them, and the add-a-card route could
  only add to a deck that did not exist yet. `+ New deck` on the cards page.

**Generate cards ran in silence.** It starts a real job, and the only place its
progress was drawn was inside the refine panel — which lives in the editor and
is shut. Pressing it on the reading page did nothing visible for ninety
seconds. It now says what it is doing where it was pressed.

**Overlays were invisible to learners.** A stored width is a fraction of the
image, and the stroke is drawn with `non-scaling-stroke`, which makes
`stroke-width` a count of screen pixels — so 0.006 meant six thousandths of a
pixel. The editor has always multiplied by its rendered width; the viewer now
does the same sum. Every region an educator has ever marked was invisible to
everyone who was not editing it.

Also: the figure viewer no longer scrolls, at any width, and the page behind it
is pinned properly (`overflow: hidden` on the body does nothing on iOS, so a
figure opened half-way down an article drifted while it was read). Options are
full width on a phone. The question toolbar's seven glyphs are four, with the
rest folded into the ⋯ that was already there, spelled out in words. The jobs
popover closes on a click anywhere outside it. And the editor has a way back to
Editorial — "back to the article", from an article you opened to edit, is a
loop.

The contract snapshot caught both new routes on the way through, which is what
it is for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 02:05:02 +02:00
Daniel
2d55805471 chore: keep playwright's output out of the repository
Some checks failed
Tests / backend (push) Failing after 4s
Tests / frontend (push) Failing after 29s
Tests / e2e (push) Failing after 33s
Reports, traces and the last-run marker are per-run artefacts; CI keeps them
for a fortnight as an upload, which is where they belong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 01:23:45 +02:00
Daniel
e72cdd6716 feat: a versioned API, refresh tokens, and an end-to-end stack that found four bugs
**The API.** Every route now lives under `/api/v1`, with `/api/...` rewritten
onto it — one route, two spellings, so they cannot drift and the OpenAPI
document describes each endpoint once. Errors carry an `error` object with a
stable code, one human sentence and, for a validation failure, the fields that
were wrong; `detail` is untouched so nothing that reads it breaks. The whole
surface — 320 routes, their parameters and their status codes — is checked in
as `backend/tests/api-contract.json`, and a test fails on any difference,
naming the routes that moved. `docs/api.md` is the contract in prose.

**Refresh tokens**, so an app can stay signed in without keeping a password.
Rows rather than signatures: listable, withdrawable, stored as hashes, rotated
on every use. A spent token coming back ends the whole session, because a theft
and a replay look identical from the server and the safe reading is the unsafe
one. A browser is not given one — it has nowhere to put it and a person to ask.

**An end-to-end stack**: `docker-compose.test.yml` with its own Postgres and
Redis, `e2e/seed.py` for the smallest world the tests name, and Playwright with
five projects — desktop, iPhone, Pixel, iPad and a browserless API project.
Devices because every bug reported this week was a phone bug found by a person
looking at a screenshot; a desktop-only suite would have passed through all of
them. Forty tests, five clean runs.

It found four things in its first hour:

- **A fresh deploy could not start.** `create_all()` ran before
  `CREATE EXTENSION vector`, so any database that had never had pgvector
  installed died on the first table with a vector column. Invisible here
  because this one has had the extension for a year.
- **A figure in a published article was a 404 for everyone but an admin.**
  Media in the library is nobody's to read by default, and nothing made an
  exception for a drawing an article actually shows — so every illustration
  added this week was an empty box for every real user.
- **Every rate limit was one bucket for the whole site.** The backend saw
  nginx's address for every request, so ten bad passwords from anybody locked
  out everybody, and no log line could say who. nginx now takes the real
  address from the proxy and overwrites the header on the way in; uvicorn runs
  with --proxy-headers.
- **The reading page's breakpoints disagreed** — 1150px in the component,
  820px in the stylesheet. Between them the menu button claimed the contents
  drawer and then toggled a class on a rail that was still in the layout: the
  contents did not open and the site menu did not either. The button was dead
  on every tablet.

And two smaller ones: the login limiter counted successful sign-ins, so eleven
people behind one hospital NAT locked each other out — it is cleared by a
correct password now; and `/uploads/{path}` served GET and HEAD from one route
with one operation id, which makes every OpenAPI client generator refuse the
document.

The first admin's password is generated and printed once at first start when
`DEFAULT_ADMIN_PASSWORD` is blank, rather than the account not existing:
`docker compose logs backend | grep -A3 "FIRST ADMIN"`.

CI (`.forgejo/workflows/tests.yml`) runs the backend suite, the contract, the
frontend suite and the build on every push to dev, main or master, and the
end-to-end stack on those branches and on pull requests into them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 01:23:38 +02:00
Daniel
1f52c1217f refactor: the app is a PWA, so the native wrapper goes
1,881 files: a Capacitor shell for Android and iOS, a Forgejo workflow that
signed and uploaded an APK, and `/api/mobile` — the API that shell talked to.
Nothing in the web app called it, and the PWA (manifest, service worker, icon)
is untouched: installing PedsHub on a phone works exactly as before.

`/api/mobile` was worth removing on its own account. `GET /api/mobile/sync`
returned, for every quiz a learner could see, every question in it *with its
correct answer and explanation* — "bulk sync with answer keys for offline
study". It is the same hole that was just closed on the question bank, still
open through a second door, and `GET /api/mobile/quizzes/{id}` was a third. It
also carried a second password-login endpoint with its own rate limiter, which
is twice the sign-in surface for an app that no longer exists.

The tests that reached those endpoints were asserting quiz visibility and
grading, not the transport. They assert the same things through /quizzes and
/attempts now, so nothing is less covered than it was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 00:26:35 +02:00
Daniel
c22e7f9547 fix: the answer side of a question needs an attempt, or the job of writing it
Asked for: no tutor on a question outside a session. The tutor is handed the
correct answer and told it may explain it, so it is answer-side content — and
once that rule is written down, the same rule catches two bigger holes:

- `GET /questions/bank` returned `correct_answer`, `explanation`,
  `option_explanations`, `key_points` and `attending_tip` for every question in
  the bank, to any signed-in learner. It is the question manager's listing, but
  nothing stopped anyone calling it: the whole answer key, one request away
  from the questions it answers. Stems are still listed to everyone; the answer
  side now goes only to whoever writes that question.
- The explanation image behind a question was readable by the same rule, with
  no attempt behind it.

"Whoever writes it" is one function now — `may_edit_question` — and it means
moderation, authorship, or an editorial grant that reaches where the question
is filed. Everyone else earns the answer by sitting the question, which is what
an attempt is. The bank browse, the search, the session and the review are all
unchanged; the frontend already sends `attempt_id` everywhere it shows an
answer.

The question manager was reachable by a learner with no grant, and would now
load as a bank of stems with every answer field blanked — a broken page rather
than a door that is not theirs. It says so instead.

Also, while looking at where cards surface: the answer review showed neither
the topic reading nor the cards written against a question, though the player
has shown both under the answer for a while — and the review is the one place
a learner goes through everything they got wrong. The list form of that
component fetched its cards and then dropped them on the floor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 00:13:43 +02:00
Daniel
c1391c1554 fix: the phone pass — figures, drawers over panes, and the player's chrome
- A diagram opened on a phone was a caption above an empty screen. The SVGs
  are written with a viewBox and no width or height, so they have a shape but
  no size: Chrome falls back to 300×150, Safari resolves them to nothing inside
  a shrink-to-fit box. Vector figures now get the width they are allowed and
  take their height from their shape. Photographs are untouched — the same rule
  would blow them up.
- "Source: Schematic drawn for PedsHub." now reads "Source: PedsHub", on all
  five drawings, set through the API.
- A drawer opened from the header appeared behind the split-view pane. It is
  the header's own control, and the header is above everything, so the drawer
  is too — and its backdrop now starts below the header rather than covering
  the button that opened it.
- The session header repeated the question counter that the bar above the
  question already gives, and on a 390px screen those two rows were a fifth of
  the scroller, above every question. The counter goes; on a phone the whole
  header card goes, because the drawer already carries the session's name, its
  mode and how much is answered.
- Every control in the question's toolbar now names itself. Below 800px the
  words are hidden and only the glyph is left, so a button whose name lived in
  that hidden span had no name at all to a screen reader and no tooltip to a
  long press. "Clear highlights" is no longer a second pencil an inch from the
  pencil that opens the note.
- The last line of an explanation sat flush on the bar at the foot of the
  player, which reads as content cut off rather than content finished.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 23:50:36 +02:00
Daniel
b345709c4b fix: drawers that close, sources that are sources, and a model that does not haggle
Reported from a phone, all of it:

- The menu button opened a drawer and then did nothing. Pressing the same
  button is how a thumb closes a drawer; the only way out was the strip of page
  beside it. It toggles now — chats, contents, a session's questions and a
  finished attempt's rail, all four.
- AI Mode's chat list started at the top of the window, so its first row sat
  behind the header: unreadable, untappable, and covering the button that would
  have closed it. It starts below the header now, the way an article's contents
  already did, and the measurement they share is one hook rather than two.
- The star that saves an article hung its panel from its right edge. That star
  is the first thing in the reading bar, so on a phone two hundred pixels of the
  panel were off the left of the screen, over the title. It measures and picks
  a side.
- Cited questions were listed under "Sources". A question is not something you
  read, it is something you sit — so it stays out of the list and out of the
  count, and still counts towards the session the button builds.
- The session offer counted its questions out loud, which invites haggling over
  a number the learner does not set. "Practise this", then "Your session is
  ready". Twenty is the cap, as it was.
- Asked for five questions, the model explained itself: how many it had looked
  at, what it could go and fetch. It is now told to ignore the number, not to
  apologise for it, not to offer to find more, and to say the same thing again
  if asked again.

Also: AI refine is off the reading page. Drafting is drafting — it belongs in
the editor, next to Save, not on the page a learner is reading.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 23:34:24 +02:00
Daniel
3279e14bb2 refactor: remove the LMS
There will be no courses. What was there: one draft called "jk" with two empty
lessons, and 4,000 lines of code around it — courses, modules, lessons,
enrolments, per-lesson progress, SCORM, BigBlueButton, completion certificates,
three React pages, a router, two models.

Its real cost was everywhere else. Every query that measured practice had to
remember `Quiz.course_id.is_(None)`, and forgetting it in one place would have
silently mixed course attempts into a learner's analytics; the bank predicate
carried a subquery to exclude a course's own questions from every search,
recommendation and share; quiz access had a second, parallel rule about
enrolment. All of that is gone, so the remaining rules say what they mean.

`quizzes.allow_review` goes with it. It was only ever enforced for a course
quiz, so it had become a promise nothing keeps — the public session page was
still offering "no answer review" about sessions that review fine.

The fixtures' question 5 lived in a course quiz and stood for "a question that
exists but is not in your bank". There is no such thing now — a question is in
the bank unless it is deleted — so the counts it kept out of the numbers are
back in, and the tests that turned on it now turn on deletion or on the
attempt that actually holds a question.

Files the LMS uploaded stay on disk and stay protected: LEGACY_LMS_PREFIXES in
app/utils/upload_access.py is what keeps them unreachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 23:27:51 +02:00
Daniel
2e8f8dda94 fix: moderator flag in /auth/me, and AI Mode answers that go wrong
The moderator-only UI was hidden from moderators. `/auth/me` never returned
`is_moderator`, and the whole frontend decides what to show by reading it —
which is why "Draft with AI" appeared to do nothing: the panel was never
rendered for the person who owns the button.

AI Mode, from the reports alongside it:

- An empty answer was stored and drawn as a blank card. Both the empty reply
  and the reply that is empty once an invented citation is deleted are now
  refused before anything is written, so the question comes back in the box
  with a reason. Old blank cards render an "Ask again" instead of nothing.
- The model narrated its own retrieval — how many sources it had seen, what it
  could go and fetch — when asked for more questions than it could cite. It is
  now told to name what there is in one sentence and stop.
- A thread took its name from the learner's raw typing, so the rail read "hi,
  how do i treat a febrile seizure?". Openers are trimmed, "i" is a capital,
  and the cut falls on a word.
- A cited article opened somewhere else. It now gets the same hover card as a
  cross-reference in prose — excerpt, new tab, and the pane beside the answer.
- A built session said "questions ready" with no number: the endpoint returns
  `count`, not `questions_count`.

And a race the last of those exposed: the guard that stops the transcript
being fetched over an answer that has just arrived was cleared as soon as the
request finished, so whether the exchange survived depended on when React ran
the effect. It now tracks which thread is on screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 23:05:55 +02:00
Daniel
e1b8ba012d fix: the AI draft opens what it wrote, and Editorial opens the editor
**"Draft with AI does nothing" was almost true.** The job worked — about forty
seconds, then a draft saved — but the panel closed onto a library that looked
unchanged, and the only progress it ever showed was the same "Drafting…" line.
The job now reports the article it made and the page goes straight into it, and
the wait is counted in seconds so it is visibly a wait rather than a hang.

**Editorial rows open the editor.** It is the queue of things to *do* to an
article; rows that opened the reader made an editor press Edit on every one.

**No Contents button on a phone.** The header's menu opens the contents, and a
second door in the prose is the same mistake the player had. An open drawer now
has a backdrop that closes it, and choosing a section closes it too — it sits
over the prose it just scrolled to.

**References wrap.** A source title is often a filename with underscores and no
spaces to break at, so the list ran off the side of the screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 22:37:46 +02:00
Daniel
672e1c2014 feat: citations you can click, and a session offered rather than entered
**The numbers were decoration.** `[1] [2] [3]` in an answer were plain text
that looked like references and did nothing — worse than not numbering them.
Each is now a control that takes the reader to the source it stands for, which
flashes so it is clear which one was meant.

**A session is built, then offered.** "Practise these 3 topics" used to build a
session and immediately leave the conversation for it. Leaving mid-conversation
to sit twenty questions is a decision: the session is now made either way and
the answer says so — *Start now*, or *Later*, which leaves it in the sessions
list and lets the chat carry on.

**And the chats rail is behind the header's menu on a phone**, like a session's
questions and an article's contents, opening as a drawer from the left with a
backdrop that closes it. The in-page "☰ Chats" bar is gone: one control, in the
same place, on every page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 22:37:46 +02:00
Daniel
18fa1913a4 fix: the contact form's messages were readable by anyone
`GET /api/contact/submissions` had no authentication. `require_admin` was
imported inside the function body and never used as a dependency, so the import
read as protection and was none: anyone who guessed the path could read every
sender's name, email address and message. `PUT .../read` was open the same way.

Both now depend on `require_admin`, with a test that a learner gets 403 and an
administrator gets the list. A row with a null timestamp no longer takes the
whole listing down with it — which is the only reason the hole showed up as a
500 rather than as data.

Also: the tutor's site switch lives in Redis, which the tests share with the
running site, so turning the tutor off in the interface turned a test red. The
test now sets the flag it depends on and puts it back.

And the tutor button is hidden until the server says it is allowed, rather than
shown and then withdrawn — on a site with it switched off that flicker reads as
a bug rather than a policy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 22:37:28 +02:00
Daniel
4b8d66cf2c fix: one door to the questions, and difficulty as chevrons after the answer
**The rail was still drawn twice on a phone.** The fix for it was written next
to the rule it fixes, and lost: `.quiz-player.is-boxed .quiz-sidebar { display:
flex }` sits further down the file at equal specificity, and equal specificity
is settled by order. The narrow-screen rules now live at the end of the file,
with a comment saying why they are there.

**And a second door beside the first.** "Question 2 of 20 ☰" opened the same
list of questions as the menu in the header — a few pixels below the control
that is on every page in the same place. It is text now.

**The more-menu no longer opens off the screen.** It hung leftwards from a
button near the right edge, so on a phone half of it was outside the viewport
("…A FOLDER"). On a narrow screen it is a sheet above the bottom bar instead.

**Difficulty is three chevrons.** A word takes the width of a word, which is
why it only ever appeared where there was room for one; three marks fit a list
row and are read without reading. Where they appear is the part that matters:
in the player only once the answer is on screen, and in the rail only against
questions actually answered. Being told a question is easy while you are
working on it tells you how hard to look.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 22:10:42 +02:00
Daniel
cbff4ab073 content: five diagrams, drawn rather than generated
Salter-Harris I–V, the parabolic profile and its two lanes, the conducting and
respiratory zones, the four types of respiratory failure, and the mole as the
bridge between one atom and a weighable mass. Each is in the image bank with a
title, a description and a source, and embedded in the article it explains — so
it behaves like every other figure: a thumbnail in the prose, the full view on
a click, findable by what it shows.

Drawn as SVG and not generated as pictures, for two reasons. A schematic has to
have every label exactly right and stay right at any size, and one assembled
from shapes is corrected by editing a line rather than by rolling the dice
again. And a generated raster of anything clinical would be a fabrication
dressed as evidence, which is the one thing a teaching figure must never be.

The sources are kept here beside the docs because they are text, and text
belongs in the repository where it can be diffed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 22:00:49 +02:00
Daniel
242e787040 feat: tidy a hand-drawn mark, and see an image as a learner does
**Tidy.** Asked whether the drawing could be straightened like Photoshop, and
whether that needs a model: it does not, and one would be worse — slower,
different every time, and unable to say why. Three geometric questions with
definite answers — is this a straight line, an ellipse, a rectangle — measured
against the points actually drawn, with Ramer–Douglas–Peucker underneath for
the curves that stay curves.

Offered per shape and never automatic, which is the whole design: a trace along
the edge of a lesion is *supposed* to wander, and a tool that straightened it
would be correcting the finding rather than the drawing.

**Preview.** The media library can now open an image exactly as a learner meets
it — full screen, description, source, overlay behind its own switch. Checking
a caption or a mark by imagining the reader's screen is how a mark ends up in
the wrong place on it. The viewer is now its own component, so the figure in
prose and the library open the same one.

And the footer says what was asked for instead of the disclaimer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 21:50:31 +02:00
Daniel
04abd78380 feat: cards remember, render as prose, and are reachable from a question
Three things the card system did not have.

**Spaced repetition.** There was none. "Known" and "to review" were React state
that vanished on reload, so a deck of two hundred was two hundred cards every
time and the only spacing was whichever cards a learner remembered to skip.
Verdicts are now kept, and the deck comes back in the order the learner's own
history calls for: due first, most decayed first, then never seen, then the
rest — because somebody who has met the whole deck recently should still get a
deck rather than a screen saying come back on Thursday.

It borrows the question player's arithmetic rather than choosing its own.
`recall_probability`, `DUE_RECALL`, the thirty-day half-life: two schedulers
with two ideas of "due", in one product that shows a learner one readiness
number, is how the number stops meaning anything. Two outcomes and no
four-point scale — a scale asks a learner to rate their own recall in units
they have never calibrated, and the extra resolution is noise.

**Cards are prose.** Both faces go through the same renderer as everything
else, so a card can carry `[[264|respiratory failure]]`, a `==key point==`, a
teaching tip or a figure. That is most of what "link cards to things" turns out
to mean.

**A deck is reachable from the question.** Beside the topic-reading chip under
the correct answer, one chip per linked deck. Read from the question's end
only, deliberately: a card that listed the questions it belongs to would hand a
learner revising the deck the shape of the exam, and the answer with it.

Migration m3d4e5f6a7b8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 21:50:31 +02:00
Daniel
ddd8c4c4e3 feat: the session moves along the difficulty range, per topic
The half of "adaptive" that was never built. Difficulty was a filter a learner
could set and nothing the session did on its own, so somebody at 30% on a topic
and somebody at 90% were asked the same questions in the same order.

A question's value is now scaled by how far its level is from the one the
learner's readiness on *that topic* calls for: under 0.45 wants easy, up to
0.78 medium, above that hard. Per topic because strong on growth and weak on
arrhythmias is the normal case and one number across the two describes nobody.

A multiplier and not a filter, for the same reason the reranker is a
permutation: thinning the bank to one level makes it three times smaller, and
on a narrow topic that is the same eight questions every time. One step away
keeps 0.72 of its value, two steps 0.5, and an unlabelled question 0.92 —
unknown is not wrong.

The first band's edge is 0.45 rather than 0.55 so that a topic with no evidence
— which sits at exactly NEUTRAL_RECALL — gets medium questions. Handing
somebody the easiest questions in a subject nobody has measured them on is a
poor way to find out what they know.

Written up in docs/adaptive-sessions.md, which now says five rules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 21:39:23 +02:00