Commit graph

311 commits

Author SHA1 Message Date
Daniel
fa5ed6c2b4 feat: sharing is by link only — the share-with-everyone switch and route are gone
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 57s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 6s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 19:00:53 +02:00
Daniel
1beb2d33b0 fix: room between the starter questions and the box; a half-arrived bold run streams as bold, not asterisks
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 53s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 5s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 18:56:41 +02:00
Daniel
bbcc824b65 fix: the share panel stays open (no library redraw behind it); the Nextcloud upload button says what it is
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 54s
Forgejo Docker Build / Build Docker image (push) Successful in 14s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 6s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 18:54:30 +02:00
Daniel
4127e2d101 feat: the starter-prompt pool is built once and regenerated from the admin button only, on gpt-4.1-mini by default; the question box sits two-thirds down before a chat and at the bottom during one
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 6s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 18:38:11 +02:00
Daniel
de13c8306b feat: templates, not just palettes — colour title and section pages, heading bands, striped callouts, question slides with answers, key-point strips, page footers; Grand Rounds and Board Review join the catalogue
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 6s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 18:05:41 +02:00
Daniel
9c86c56968 fix: a failed recording keeps its tab and the retry adds the words after the last word; Settings drops the session list; FAQ says what sign out does
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 50s
Forgejo Docker Build / Build Docker image (push) Successful in 8s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 5s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 17:56:32 +02:00
Daniel
cba2a312e1 feat: the preview is a viewer — one page at a time, arrows, keys, swipe, a PDF tab and downloads; the theme leaves the list row
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 51s
Forgejo Docker Build / Build Docker image (push) Successful in 11s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 5s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 17:49:22 +02:00
Daniel
b277ce4b10 fix: the assistant asks for context around each excerpt again — read from the stored chunks, 1400 characters by default
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 5s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 17:41:01 +02:00
Daniel
3e10b6faa7 feat: sign out ends the PedsHub session too (RP-initiated logout); the assistant sends the stored excerpt only
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 11s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 17:35:35 +02:00
Daniel
a6c2cb1080 perf: context expansion is off by default — the stored excerpt already carries the page, its tables and figure captions
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 55s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 5s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 17:16:28 +02:00
Daniel
18f7651362 feat: signed in at PedsHub means signed in here — one silent prompt=none attempt before the sign-in page, hash kept
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 51s
Forgejo Docker Build / Build Docker image (push) Successful in 19s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 7s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 17:04:25 +02:00
Daniel
e23d35a570 docs: library indexing is scheduled and on request; panel note matches
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 52s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 5s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 16:34:16 +02:00
Daniel
93426ac089 feat: the library index reconciles when the admin asks — status and Run indexing now in the Clinical Assistant card
Some checks failed
Forgejo Docker Build / Root app tests (push) Failing after 58s
Forgejo Docker Build / Build Docker image (push) Has been skipped
Forgejo Docker Build / End-to-end (browser) (push) Has been skipped
The clinical-assist indexer no longer polls Nextcloud every five minutes; it
scans once at start and then on POST /api/v1/vector-sync/scan with a bearer
token. The admin panel shows what it reports and carries the button; the
address and token are settings (or the environment). The button stays usable
under lockdown, the fields do not.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 16:02:35 +02:00
Daniel
fdbb9326c1 fix: accepting a share link failed — the insert helper adds RETURNING id and the shares table has no id
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 53s
Forgejo Docker Build / Build Docker image (push) Successful in 19s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 6s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 15:29:50 +02:00
Daniel
6c689b420c fix: lockdown also freezes the SSO settings; the gate no longer keeps a door for invites; lockdown documented
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 15:25:41 +02:00
Daniel
c7864f763e feat: sharing is by link — follow it, accept, and it is in your resources
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 56s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 12s
The author presses "Copy a share link" and sends it however they like.
Whoever follows it (app.pedshub.com/#share=<token>) is signed in first if
need be — the token survives the trip through the SSO — then shown what it
is and who from, and adds it with one press. Only the token's hash is
stored; a link lasts 30 days and can be withdrawn; accepting twice is
harmless; the owner following their own link changes nothing. Sharing by
email is gone: nobody is looked up by address. "Everyone signed in" stays
as a switch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 15:20:35 +02:00
Daniel
eb9fdfef35 feat: share a resource with people on this site — by email, or with everyone
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Docker Build / Build Docker image (push) Successful in 6s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 7s
A resource was private with no way out but the author's own Nextcloud.
Share opens reading — open, preview, download — to one person at a time by
exact email (no account is ever listed) or to everyone signed in with one
switch; what others share appears in your library marked "Shared by …".
Writing never travels: modify, re-skin, delete and the share list stay the
author's, every write still filtered on user_id, and the read routes go
through one reader rule. Rows follow the resource and the person.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 15:13:28 +02:00
Daniel
6992ecdf80 feat: a flowchart slide — steps in, boxes and arrows out
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 55s
Forgejo Docker Build / Build Docker image (push) Successful in 12s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 5s
A pathway or an algorithm had no slide of its own and came out as bullets.
"flow" is a slide of two to eight steps, each a short phrase with an
optional note; the exporter lays it out — across for up to four, down for
more — as rounded boxes joined by arrows in the shape vocabulary the
renderer already draws, so the model is never asked for coordinates and
Python gains one small builder. Word gets the steps as a numbered list.
The theme sample shows one, so every template demonstrates it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 14:54:17 +02:00
Daniel
e92b72d406 feat: preview a resource, or a theme's sample deck, as pages — without downloading
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Docker Build / Build Docker image (push) Successful in 7s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 8s
A PowerPoint or Word file cannot be shown in a browser and a phone has
nowhere to open one. Preview renders the resource the way its download
would be built, turns it into one PNG per page (Gotenberg to PDF, pdftoppm
to pages), and shows the pages in an overlay that scrolls and pinch-zooms
like anything else. Rendered once per version — updated_at and theme are in
the key — and served from disk afterwards. The theme picker's sample deck
has the same Preview beside its download.

Every presentation now shows the theme picker in the library, since
markdown slides take a theme too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 14:48:45 +02:00
Daniel
6e779e61ff fix: citation chips that sit together read in ascending order
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 56s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 6s
[1] early in an answer and then "[2][1]" later showed "2 1". A run of
adjacent chips is now sorted by what it displays — a core rule after inline
parsing — so it reads "1 2". Text between two clusters keeps them apart,
every chip still points at its own source, and the numbering itself is
unchanged: only the order within a run moves.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 14:42:51 +02:00
Daniel
ed17d38229 test: the boundary stores the owner id as a string; compare it as one
Some checks are pending
Forgejo Docker Build / Root app tests (push) Successful in 50s
Forgejo Docker Build / Build Docker image (push) Has started running
Forgejo Docker Build / End-to-end (browser) (push) Has started running
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 14:37:02 +02:00
Daniel
2e3cf0d77b fix: signing in again as the same person in another tab no longer freezes this one
Some checks failed
Forgejo Docker Build / Build Docker image (push) Has been skipped
Forgejo Docker Build / End-to-end (browser) (push) Has been skipped
Forgejo Docker Build / Root app tests (push) Has been cancelled
The account boundary reloads a tab when the shared session changes under
it — the guard against one account's tab showing another account's data.
A second tab signing in as the same person is a new session, not a new
account; it is adopted, and the reload is kept for a different owner or
none. "Your account session changed. Reload to continue safely" now means
what it says.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 14:35:19 +02:00
Daniel
d893452d9a feat: the site's Nextcloud is filled in, not typed — the settings page is one button
Some checks failed
Forgejo Docker Build / End-to-end (browser) (push) Blocked by required conditions
Forgejo Docker Build / Root app tests (push) Successful in 55s
Forgejo Docker Build / Build Docker image (push) Has been cancelled
nextcloud.url (or NEXTCLOUD_URL) names the site's Nextcloud. With it set,
the address field is hidden and the page reads "Sign in with Nextcloud",
with the app-password route underneath for whoever needs it; both routes
use the default when no address is given. Without it, nothing changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 14:33:25 +02:00
Daniel
28877ba0c4 feat: My Resources takes Details — what to cover, at length, folded away until needed
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 50s
Forgejo Docker Build / Build Docker image (push) Successful in 17s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 6s
Topic is one line and Instructions say how; neither is the place for a list
of twelve topics or a case to build around. Details is: optional,
collapsed by default, up to 12,000 characters, quoted into the prompt as the
author's own account of what the resource must cover — material, not
commands, in both the deck and the markdown shapes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 14:30:09 +02:00
Daniel
f748e02063 feat: roles follow the SSO's groups, on every sign-in
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Docker Build / Build Docker image (push) Successful in 8s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 8s
Both PedsHub apps now take admin and moderator from the same two Authentik
groups. oidc.admin_groups and oidc.moderator_groups name them; unset means
local roles stand. Applied at every sign-in so removal at the SSO demotes
here, and never applied to the last admin — a group edit must not be able
to lock everyone out of the panel.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 13:54:12 +02:00
Daniel
e306c3ce28 feat: a link into a tab — app.pedshub.com/#resources opens My Resources
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Docker Build / Build Docker image (push) Successful in 8s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 10s
The app has no pages and is not getting any. Another site can still send
someone straight to a tab by naming it in the hash: it is read once at
load, stored where sign-in looks for the last tab, and dropped from the
URL, so it survives the round trip through the SSO and does not stick to
the address bar. #resources, #deck and #decks all mean My Resources.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 13:51:36 +02:00
Daniel
89f1aca8ed fix: a deck reply that closed a slide early is repaired, and a modification keeps the theme
Some checks failed
Forgejo Docker Build / Build Docker image (push) Has been skipped
Forgejo Docker Build / End-to-end (browser) (push) Has been skipped
Forgejo Docker Build / Root app tests (push) Failing after 47s
Three generations in a row fell back to plain slides for the same reason,
visible once a failed reply was kept whole: the model wrote "rows":[[…]]}
and then ,"notes":"…"} — the slide closed before its notes, which is not
JSON. On parse failure the one premature brace is removed, bounded to the
schema's slide keys; a brace that legitimately closes an inner object is
left alone. Both test generations now come back as designed decks.

Modify used to hand the model's new deck back without its theme; the look
is the author's, so it is carried over, and the column agrees.

Failed deck replies are kept under data/logs/deck-failures for the next
time "the reply was not a deck" needs reading rather than guessing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 13:43:21 +02:00
Daniel
0163d40811 fix: every presentation takes a theme, not only the ones the model designed
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Docker Build / Build Docker image (push) Successful in 6s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 8s
"Change template" answered "no slide layout" for 28 of the 41 presentations
in production: the theme lived only inside the deck JSON, and a presentation
whose deck reply failed twice and fell back to markdown slides had nowhere
to keep one. The theme is a column now, written at generation and by the
picker, and the markdown slide builder carries it to the same renderer field
a designed deck uses. A deck's own theme field is kept in step. Articles are
the only thing refused — they have no slides.

A deck reply that fails to parse is logged with its first 240 characters, so
the next "the reply was not a deck" can be read rather than guessed at.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 13:32:24 +02:00
Daniel
a528986a2d fix: a starter-question batch no longer dies when the reply is cut off
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Docker Build / Build Docker image (push) Successful in 7s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 7s
Cases are three to four times the length of headings, and 30 of them did
not fit the 2,600-token ceiling: the JSON was cut mid-list, failed to parse,
and whole categories came back with nothing kept. The ceiling is 7,000,
batches are 20, and a reply that is still cut off yields every question that
finished rather than none. Each batch logs what it offered.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 06:38:29 +02:00
Daniel
75f5486beb fix: an array of route paths is several operations, pages are none, and the prompt-pool build says what it did
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 51s
Forgejo Docker Build / Build Docker image (push) Successful in 8s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 7s
app.get(['/', '/index.html', '/assistant']) reached the OpenAPI generator as
one route whose path was the array, joined with commas; the e2e reachability
check then probed "/,/index.html,/assistant" and found a 404. Each path is
now its own route, and routes outside /api/ are left out: the document
describes what a client calls, and a client does not call index.html.

The starter-question pool logs when a build starts, what each category kept,
and how long it took, so a build that produces nothing can be traced.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 06:35:25 +02:00
Daniel
8c6126b112 feat: starter questions are cases with a decision, and the pool rebuilds itself when the prompt changes
Some checks failed
Forgejo Docker Build / Root app tests (push) Has been cancelled
Forgejo Docker Build / Build Docker image (push) Has been cancelled
Forgejo Docker Build / End-to-end (browser) (push) Has been cancelled
The pool read like a chapter index — "What red flags in a child's headache
history warrant investigation?", "Which clinical scores are useful for
asthma?". The prompt now asks for what a clinician types with a patient in
front of them: a one-line vignette with an age and at least one number,
then the one decision to make now. The filter enforces it: a number is
required, textbook openers are refused, and the length cap makes room for a
case.

A pool carries the prompt version it was built with. One from an older
version is served as before and rebuilt in the background, so a prompt
change reaches the screen without anyone pressing Regenerate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 06:23:16 +02:00
Daniel
e58aa1b996 refactor: sign-in codes and registration invitations leave; the SSO has both
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Docker Build / Build Docker image (push) Successful in 7s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 6s
Sign-in is email → code at sso.pedshub.com, and new accounts come from an
invitation link minted there, so the app's own code emails and invite codes
recorded a path nobody can take. Gone: the login-code routes and their rate
limiters, the invite admin API and card, the invite field on the register
form, the "email me a code / use my password" choice on the sign-in screen
(an email now leads straight to the password), both utility modules, and
the invite-only setting. A migration drops login_codes and
registration_invites.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 06:11:00 +02:00
Daniel
d94dc0e357 feat: the wait is a list of steps, ticked by the stream's own events
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 56s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 17s
One line of changing text is now a list the reader can follow: Analyzing
the question → Searching the clinical library → Found 7 sources → Writing
the answer, plus any step the server reports (looking at an image, drawing
one, completing a cut-off reply). Steps are added when the stream says work
began and ticked when it says it ended, so the list is a record of the real
work, not an animation on a timer; nothing delays the answer.

The one hand-off the server cannot signal — retrieval runs before the stream
opens — is done by the stylesheet, not a JS timer, so the autosave debounce
keeps its clock to itself.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 05:48:14 +02:00
Daniel
fa9970458f fix: the export's last table column wraps again, and citations-off strips every marker form
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 51s
Forgejo Docker Build / Build Docker image (push) Successful in 19s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 11s
The export styles pinned the last column of every table to one unbreakable
line (width:1%; white-space:nowrap) — right for a numbers column, wrong for a
two-column clinical table whose second column is the whole finding: the
table grew to the width of its longest sentence and iOS then inflated the
text in those cells, so the "Finding" column read in a bigger font than the
"Feature" column. The pin goes, and the sheet opts out of text inflation.

With citations switched off the answer is still grounded in the retrieved
sources; only the markers are removed. The stripper now also removes the
escaped \[1, 2\] form and [src] placeholders, which used to survive as text.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 05:42:32 +02:00
Daniel
cb5bce13e3 fix: the unfinished block streams as markdown, and a deployment's JS is never an hour stale
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 52s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 16s
The tail — the block still arriving — was shown as raw text until its blank
line came, which is the flash of asterisks, pipes and brackets the reader
saw on every paragraph. It is now parsed each frame like the finished part
(Open WebUI never shows the flash because it re-renders the whole message
per token; ours re-renders one block). Two exceptions stay text: a block
inside an unclosed code fence, so a half-written diagram is not handed to
its renderer every frame, and a table header with no body row yet.

JS, CSS and component HTML were cached for an hour, and the assistant's ES
module imports carry no version query, so every open browser kept the old
citation renderer for an hour after the deploy that replaced it. They now
revalidate on every load (no-cache with the ETag), which is a 304.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 05:10:12 +02:00
Daniel
07161c6fa8 feat: speech models have a roster; each brings its voices, users choose across all of them
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Docker Build / Build Docker image (push) Successful in 8s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 6s
Discover lists speech models with the voices each accepts and a + Add that
puts the model on tts.roster. The Roster card lists every model with a voice
picker, Test, Make default and Remove. Test on any row (or a discovered model
not yet added) fills the test panel's voice list with that model's voices, so
Orpheus and Kokoro can be heard one voice at a time before either is chosen.

The default is a pair — PUT /config/tts/default sets tts.model and tts.voice
together and refuses a voice the model does not accept, naming the ones it
does. The generic setter no longer takes tts.model/tts.voice one at a time,
which is how a Kokoro voice got paired with Orpheus. A default that leaves
the roster stops being the default.

Users pick from the voices of every roster model, grouped by model in
Settings; the stored value is "model|voice" so read-aloud sends the voice to
the model that accepts it. A bare voice saved before there was a roster is
read as a voice of the default model. chooseTTS is the one place the pair is
decided, shared by read-aloud, the admin test and the settings options.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 04:49:24 +02:00
Daniel
8a6a4df121 refactor: citations are a markdown-it token, and the numbers you see are display order
The old renderer rewrote the text: it found "[n]" with regexes, renumbered
them, and swapped the result back in — which broke inside `arr[2][1]`, inside
HTML attributes, and whenever two turns disagreed about what "[3]" meant. It
also had a fallback markdown renderer of its own for when the rewrite
produced something markdown-it would not parse.

Now "[n]" is an inline rule registered on the same markdown-it instance that
renders everything else. The parser decides what is prose and what is code, a
link, or a URL, so the rule never sees "[1]" inside a code span, and it steps
aside for "[1](url)". Math is two more rules on the same parser instead of a
regex pre-pass, so "$" inside a URL is no longer math.

Identity vs display: the stored "[n]" and each card's id are the source's
identity (sourceNumber) and are never rewritten. The number a reader sees is
the order of first appearance, computed at render time from the token stream
(orderSourcesByCitation), so "one, then seven" cannot happen and a saved chat
re-opens pointing at the same cards it was saved with. Stored messages and
sources are untouched; export and the modal resolve by identity.

Translated HTML gets the same links through a TreeWalker over text nodes
(linkCitationsInHtml) rather than a regex over markup.

Deleted: renderCitationLinks, normalizeAdjacentCitationClusters, the
fallback renderer (fallbackMarkdown/renderMixedList/renderFallbackTable),
renderLatexText, CITATION_SCAN. Tests that asserted rewritten text now assert
token output; harnesses that render for real are given a parser.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 04:41:40 +02:00
Daniel
e376f69502 fix: each TTS model offers only the voices it will accept
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Docker Build / Build Docker image (push) Successful in 21s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 6s
"These settings don't work" — picking a model and testing with a voice
returned 500 every time. The gateway said why, once asked directly:

    voice must be one of the following voices: [autumn diana hannah austin daniel troy]

The screen was listing twelve Orpheus voices and six Kokoro ones in a
single flat list with no indication of which model would accept which,
because LITELLM_TTS_VOICES — written for one model — was treated as a
list that applied to all of them, and the Orpheus lists were pushed in
beside it. Choosing Orpheus and testing it with a Kokoro voice is not a
configuration; it is an error, and it was the default outcome.

LiteLLM cannot supply this. /model/info reports mode audio_speech for
all four models and carries no voice field for any of them. So the
mapping lives here, keyed by family so the gateway alias and the
upstream id resolve to one list, and every list was taken from the
provider rather than from documentation:

  Groq Orpheus English   autumn diana hannah austin daniel troy   (stated by Groq)
  Groq Orpheus Arabic    abdullah fahad sultan lulwa noura aisha  (stated by Groq)
  Fish s2.1-pro          alloy                                    (alloy returns audio; the rest 400)
  Kokoro                 sherpa/kokoro:* from LITELLM_TTS_VOICES  (the gateway's own list)

The environment still wins for the model it was written for, so the
local gateway's voices can change without a code change — but it
answers for that model only. A model with no list at all is offered
nothing rather than another model's voices, and a voice known to belong
to a different family is refused.

There were two copies of this knowledge before: getLiteLLMTTSVoicesForModel
branched by family and fell through to the env list for any model it did
not recognise — which is how Fish came to be offered six Kokoro voices.
One table now.

Also in this commit: citation renumbering skips fenced code, inline code
and math, so arr[2][1] in a code block is never rewritten. Renumbering at
render time was tried and reverted — it also has to skip HTML attributes,
and every such region is another regex branch. It stays at the answer
boundary, and the saved-chat boundary is next.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 03:54:31 +02:00
Daniel
3e8972348a feat: a revoked invitation can be deleted
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 56s
Forgejo Docker Build / Build Docker image (push) Successful in 14s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 9s
Revoked codes had a Revoke button and nothing else — the row stayed on
the list for good. The reasoning was that revoke stops a live code and
leaves the row behind as a record, which is right about revoke and wrong
about what comes after: a revoked code is already dead. It cannot be
redeemed by anyone, so keeping it only fills the list.

The rule that matters is unchanged, and it is the only one that ever
mattered: a code that could still be redeemed is never deletable.
Deleting one takes it off the list without taking it out of anybody's
inbox — the holder keeps something that looks valid, it quietly stops
working, and nothing is left to say who had it. Revoke is what stops
such a code; deleting it afterwards is the second half of the same
thought, and it was missing.

Also removed a note that had been wrong since codes became copyable:
"The code is shown once, here. Only its hash is stored, so it cannot be
read again afterwards." Both sentences stopped being true when the
cipher was added so an invitation could be handed to somebody later than
the moment it was made.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 03:30:05 +02:00
Daniel
0e1e74882e feat: sources are numbered in the order the answer cites them
Borrowed from the quiz app's AI Mode, where validating citations and
ordering them fall out of the same pass: it collects the sources an
answer actually used into an insertion-ordered map, so the list comes
back in first-citation order for free.

Ours listed sources in retrieval order — an order the reader never sees
and has no way to follow. An answer whose first citation was [7] opened
a list that began at [1], so matching a marker to a source meant
hunting. Reference lists in published writing are numbered by first
appearance for exactly this reason.

Cited sources now come first, renumbered by first appearance, and the
markers in the text are rewritten to match. Anything retrieved and not
cited keeps its place after them, labelled "not cited" — the panel is
also a view of what the search returned, which is worth keeping, but it
should not sit among the numbers the answer used.

The marker itself now shows its number instead of the word "src". Every
citation read identically, so the only way to tell one from another was
to hover it — which made the numbered list beneath useless to match
against. The export has shown numbers since the day "src" was
introduced, with no recorded reason for the difference.

Renumbering happens once the whole answer is known, never while
streaming: the order is the order of first citation, so a citation that
has not arrived yet cannot take its place, and numbers would shuffle
under the reader mid-sentence. The text is rewritten in a single pass —
number by number would turn 2 into 1 and then that 1 into whatever 1
maps to.

An invented citation reserves no position and is left exactly as it was.
It is still not turned into a link, and citation_audit still records it;
what matters here is that it cannot push a real source down the list.

Accuracy was already held: a marker with no matching source never
becomes a link. This changes what a reader can do with the ones that are
real.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 03:26:17 +02:00
Daniel
ae602a1852 fix: My Resources uses the image model you chose for it, and says so on save
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 5s
"The model is chosen per request, where the deck is generated" described
something that happens nowhere. There is no per-request picker, and
asking where it was is what exposed the real bug.

My Resources read clinical_assistant.image_model while the admin screen
saved my_resources.image_model. That setting was stored, returned by the
API and rendered into the form — and never used by anything. Somebody
noticed the field did nothing and disabled it rather than finding out
why, which left a control that could not be changed and a note
explaining a mechanism that does not exist. My note repeating it was
wrong too.

The generator now reads its own setting and falls back to the
Assistant's, so an install that only ever set one model keeps working
untouched, and the field is enabled again with "leave blank to use the
Clinical Assistant's" — which is now true rather than a rationalisation.

Saving also says what it saved. "Saved 8:31:59 PM. Decks will be
reviewed by ..." answered a different question from the one an admin
actually has, which is whether the model they just picked is the one
that will draw. It now names each workflow's model and fallbacks back,
and spells out the blank case rather than leaving a gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 02:37:57 +02:00
Daniel
0287fd091b feat: a key per search provider, and the sign-in code email looks like our mail
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Docker Build / Build Docker image (push) Successful in 15s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 6s
Two things, both about not making someone redo work.

One key per provider. There was a single websearch.api_key shared by all
of them, so trying a different provider meant pasting a new key over the
working one and pasting the old one back to return — and the keys are not
interchangeable, so a wrong pairing fails as an authentication error that
reads like a dead provider. Each now has its own slot. The old shared key
is still read as a fallback: whatever was configured before this is the
right key for whichever provider was selected at the time.

The sign-in code email went out raw, while every other message this app
sends goes through emailWrapper — so the one mail a person receives while
locked out was the one that looked least like it came from us. It now
uses the same wrapper, and the body is built around the thing the reader
actually needs: the code, alone, large, monospaced so a 0 cannot be read
as an O, in a box of its own.

It also names the address it signs into. A code arriving at a shared
mailbox, or to someone with two accounts, is otherwise a number with no
indication of what it opens — and that line is the one thing that lets a
person notice a sign-in they did not start. The address is escaped; it is
the only part of that mail that did not come from us.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 02:30:17 +02:00
Daniel
1242b01286 feat: Exa as a search source; the speech buttons say what they do
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 1m0s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 6s
Two unrelated small things in the admin.

Exa searches by meaning rather than keywords, which suits a clinical
question asked as a question — the shape the assistant actually sends.
It is also the only provider that returns the page text in the same
call, and the snippet is the part the model reads, so asking for it
there saves a second fetch per result. type is 'auto' rather than
'neural': pinning neural makes it worse at the keyword-shaped queries
the other four handle well.

One adapter, one dropdown entry, no other change — the route already
validated against webSearch.PROVIDERS, so the backend accepted it the
moment the list grew. A test now walks both lists in both directions: a
provider the server takes but the admin never offers is unreachable, and
one the admin offers but the server rejects is a setting that cannot be
saved.

The speech and transcription buttons said "Set". Directly beneath them
is a Roster card headed "What has been added", and Set is the one thing
that does not add anything — there is a single voice and a single model,
and choosing one replaces the last. They now say "Make default", and the
note says plainly that nothing joins a list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 01:58:20 +02:00
Daniel
d33d355627 feat: a streaming table renders as a table, and the wait says what it is doing
Measured against Open WebUI on the same question, sampling every 250ms:
it never showed a raw pipe. A real <table> appeared with 2 rows at
t+3.25s and grew to 4, then 6, as they arrived.

The blank-line rule alone cannot do that, and a table is the case it
serves worst: a markdown table contains no blank line, so the whole of
it stayed in the tail as plain text until the line *after* it landed,
then snapped into place. That is the markdown flash, on exactly the
content where it shows most.

So a table is now rendered while it is still arriving: once there is a
header, the |---| rule and one body row, the tail is rendered as
markdown rather than held as text, and every complete row that follows
joins it. A half-typed row is left out and appears a frame later, which
is what makes the table grow a row at a time.

Re-rendered each frame rather than appended, unlike a settled block: the
rows arriving next carry no header of their own, so they cannot be
parsed as a separate chunk. The tail is small, so the cost is small.

The status was the other half of that trace: Open WebUI keeps a skeleton
beside the streaming content until the answer is done. Ours removed the
status the instant the first token landed — the moment it becomes most
useful, because the answer is arriving *and* the assistant is still
working, drawing a figure or completing a cut-off reply. It now sits
above the partial answer, shimmering, until the final render replaces
the bubble.

And the wait itself says what is happening. Retrieval finishes before
the stream opens — deliberately, so a bad request still returns an error
rather than a stream — which makes that first line the only thing a
reader has during the slowest part. "Retrieving and synthesizing
references" described the software; "Searching the clinical library"
describes the work.

Mutation-tested: dropping the table rule, rendering the incomplete
trailing row, showing a table with no body row, or failing to restore
the status each frame all fail a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 01:55:09 +02:00
Daniel
c14fa25c3f fix: the image settings card can save again; Learning Hub is out of the admin
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 20s
"Not all of it was saved: Workflow not found" on every press of Save
availability. The card sent image settings for three workflows, and the
server has only had two since Learning Hub was removed — the DB
constraint allows clinical_assistant and my_resources and nothing else.
One rejection failed the whole save, so the two settings that were valid
looked unsaved as well.

The frontend was the only place that still believed in it. Also gone:
the learning_hub.image_behavior prompt, its Learning prompts section in
the admin — which held that one prompt and nothing else — and the
theme's card tints, which never applied.

While there: My Resources had a Model dropdown labelled "set per
request", permanently disabled and permanently empty. A control that can
never do anything reads as broken rather than as not applicable, so it
is now a sentence saying where the model is actually chosen. Its
fallbacks stay — those apply to whichever model the request picked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 01:39:15 +02:00
Daniel
3a40ff9b6d fix: the theme reaches the renderer, so changing it changes the deck
Picking a theme on a saved resource did nothing. The picker saved the
choice, the route wrote deck.theme, and the renderer knew how to apply
it — the theme was lost in between.

attachFigures, the step that puts drawn figures back onto slides,
rebuilds the deck as a fresh object:

    var out = { title, subtitle, date, slides };

A fresh object keeps only the fields it names, and theme was not one.
So every export rendered in the default palette, whatever the picker
said. Rendering the same deck under clinical-blue, teaching-amber and
high-contrast produced three byte-identical files; it now produces
three different ones, and they look different.

Silent, because nothing downstream could tell the difference between a
deck with no theme and a deck whose theme had been dropped — both mean
"use the default", which is also the right behaviour for an unknown id.

Known and not fixed here: the two tinted cards on a compare slide are
hardcoded blue and amber rather than taken from the theme, so those
stay the same colour under every palette. The headings, accent, rules
and bullets do change. That is a gap in the theme definition, not in
this path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 01:25:31 +02:00
Daniel
993442f98a feat: the API describes itself, at /api/openapi.json
docs/api-reference.md was hand-written, and by the time anyone checked
it was documenting twenty-three endpoints that answer 404 while missing
others that exist. That is what hand-written reference material does: it
is correct on the day it is written and silently wrong afterwards. A
second hand-written document, in YAML this time, would rot the same way.

So paths, methods and mount points are read from the Express router
stack at request time. They cannot disagree with the app, because they
are the app: 186 paths, 215 operations, and — checked — no /learning
endpoints, which is what the prose version went on claiming for weeks
after that feature was deleted.

What introspection cannot know is what an endpoint is *for*. That half
lives in src/utils/openapiRoutes.js, keyed by "METHOD /path", and it is
the half that rots, so it is the half that is enforced: a Playwright
spec fetches the live document and fails when the number of operations
without a summary rises above 199 — the debt as measured today. A
ratchet, not a target. Adding an endpoint pushes the count over and
fails the build; describing one lowers the number. The failure lists the
operations by name, so it says what to write.

Whether an operation is public is stated per route rather than inferred
from middleware. Guessing wrong there is worse in both directions:
calling a public endpoint protected hides a hole, and the reverse
invites a bug report.

The contract spec lives in e2e rather than the unit suite because it
needs the whole app mounted, and requiring server.js from node:test
pulls in the database pool and hangs the run — that has happened here
before.

Also: e2e now runs in CI on dev, gated by a shell check inside the step
rather than a job-level "if", which this Forgejo dispatches anyway and
then kills with "Early termination".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 00:52:55 +02:00
Daniel
36cb742ce7 ci: fix the failing job, split deploy out, and drop the Android build
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 54s
Forgejo Docker Build / Build Docker image (push) Successful in 7s
Three things, one subject: making CI say the truth about this repo.

## The red on every run was ours, not the runners'

Every docker-build run came back success, success, failure — the same
shape for weeks. The failing job was `deploy`, and it was failing to
*not run*:

    if: ${{ github.event.inputs.deploy == 'true' }}

On a push there is no github.event.inputs at all. This Forgejo does not
treat that as false and skip; it dispatches the job, the runner cannot
resolve it, and the task ends in "Early termination". The runners were
never at fault, and nothing about them needed changing.

The `'runs-on' key not defined` line is a red herring: the `build` job
prints it too and succeeds. It names the job's *needs* target, not the
job, and the old android-apk workflow used `needs:` happily for months.

Deploy is now its own workflow with only workflow_dispatch — no
condition to evaluate, so nothing can be dispatched by mistake. No job
in either file now carries a job-level `if`. The one conditional left is
a *step* (push to registry), and step conditions are evaluated by the
runner once the job is already running, which is why that one has always
worked.

## dev and main

docker-build now runs on `dev` as well. Both branches prove the same two
things — tests pass, image builds — and only `main` publishes the image,
so nothing on `dev` can be mistaken for something deployable. Deploying
stays a person pressing a button after looking at the change.
CONTRIBUTING.md documents the flow.

## Android

Removed: the mobile/ Capacitor project, docs/mobile-build.md, and the
Android bits of scripts/release.sh. All of it is in git history — 4613a278
is the last commit that had it — for when it is rebuilt.

src/utils/platform.js stays. isMobileClient only decides token lifetime,
it is twelve lines, and it is the contract a future app would come back
to; deleting it would be a change to auth for no gain.

.github/workflows/ went too — all five. There is no GitHub remote on
this repository, so none of them has ever run, and two of them wrote
into mobile/ paths that no longer exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 23:30:52 +02:00
Daniel
5f738fbe30 feat: a theme is shown by a sample deck you download, not a picture
A picture of one slide answers a narrower question than the one a theme
picker is asked. What a person wants to know is what a deck will look
like in this theme — all of it, at the size it will be shown, with the
fonts substituted the way they will be. One rendered slide showed one
layout, in content someone then read instead of looking at.

So: a sample deck per theme. Every layout the renderer can draw — title,
bullets with a sub-point, both two-column forms, table, callout, figure,
full-slide figure, section divider, a custom slide with shapes, an arrow
and a chart, and a references slide — with filler text throughout.
Download it, open it, see the theme.

The text is deliberately meaningless. Clinical content in a specimen
invites you to read it, and then you are judging the teaching rather
than the type; that is what the old croup slide got wrong.

No engine. The previous preview needed a Gotenberg round trip and a
pdftoppm to produce a PNG, cached on disk because of what it cost, and
could fail in ways a missing picture cannot explain. python-pptx builds
the file in ~330ms and PowerPoint draws it. The link is a plain anchor,
so it is there whether or not anything on the server is well.

The figure placeholder rides the same path a generated figure does —
attachFigures downgrades a figure slide with no picture to bullets and
an image slide to a section, so without it the sample would silently
stop showing those two layouts.

Verified: all five themes build, 11 slides, the chart is a real chart
and the placeholder embeds as real media.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 22:24:05 +02:00
Daniel
8602c7bd14 fix: the theme preview says what it is doing, and says when it fails
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 57s
Forgejo Android APK / Build signed APK (push) Successful in 2m4s
Forgejo Docker Build / Build Docker image (push) Successful in 19s
Forgejo Docker Build / Deploy to the host (push) Failing after 1s
The box was hidden until an image loaded and hidden again on error, so a
preview that failed and a preview that did not exist looked identical —
an empty gap under the dropdown. That is what it looked like from the
outside, and it cost a diagnosis: the endpoint renders a correct 934x525
PNG in 0.66s when called exactly as the browser calls it, cookie and
all, and nothing on the page could have told anyone that.

The first view of a theme is a pptx render, a Gotenberg round trip and a
pdftoppm — a second or so before anything appears, and then cached on
disk. A second of silence reads as a feature that is not there, so it
now says it is drawing, and on failure says the look itself is
unaffected, which is true: the preview is a picture of the export, not
the thing that produces it.

img.src is assigned after both handlers. A cached preview can complete
before a handler attached below the assignment exists, which would leave
the box stuck on "Drawing...".

describeTheme already ends by calling it, so the call sitting beside it
fired a second identical request on every load.

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