registration_enabled was a single switch: open to anyone, or closed to
everyone. This adds the setting an operator actually wants in between — open
to people you invited.
A code is single-use, expires (7 days by default, 90 maximum), and can be
revoked or deleted. It is stored hashed with only its last four characters
kept, because an invite grants account creation and a database dump should
not hand someone a working one. The code is readable exactly once, in the
response that creates it.
The claim is a single conditional UPDATE carrying every condition, so two
registrations racing the same code cannot both succeed. It happens after the
account exists, so a code is never spent on a failed registration — and if
the race is lost, the just-created account is removed rather than left behind
as a free registration. The rejection never says which of the four reasons
applied; distinguishing them would tell someone probing codes which guesses
were closer.
Codes avoid I, L, O and U so they survive being read aloud or copied off a
screen, and matching ignores case and separators.
The sign-up field appears only when the server says a code is required. The
admin card creates, lists, revokes and deletes, and carries the toggle.
Verified against the live database: create, claim, second claim refused,
unknown code refused, revoking a used code refused, delete. 684 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
It documented 18 of 67, so anyone setting up a deployment had to find the
rest by reading source — including CORS_ORIGINS, REDIS_URL, NODE_ENV and the
whole Clinical Assistant retrieval group. Each entry now shows the default
the code falls back to, so an unset line reads as "this default" rather than
"broken".
Also documents the OpenBao switch: the entrypoint only fetches secrets when
OPENBAO_ADDR is set, a compose or env_file value always beats the OpenBao
copy, and turning it off means unsetting that one variable and putting the
keys here — with the command to list the key names without printing values.
A test now derives the set of variables from source, so this cannot drift
again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
A marker names a source by the `number` dedupeSources assigns server-side.
Rendering looked it up as sources[n - 1], which works only while the array
order and the numbers agree. Nothing breaks that today, but it is an implicit
contract across a network boundary: any later filtering or reordering of the
list — hiding low-score sources, say — would point citations at the wrong
source silently, which is worse than not linking at all.
Matching on the number cannot drift. Positional lookup remains as the fallback
for a list whose entries carry no number.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
It was textContent, so formatting appeared literally. It now renders inline
Markdown only — bold, italic, code, links, breaks — because this text is
injected into every page and must not be able to introduce headings, images
or block layout that shifts the app around.
parseInline rather than parse, a tag allow-list, event-handler and style
attributes forbidden, data attributes off, and one innerHTML assignment that
can only be reached through DOMPurify. Missing either library, or any failure
while rendering, falls back to the literal text rather than to unsanitised
markup.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
CLINICAL_ASSISTANT.md described DeepL as an admin-configurable translation
provider with its own keys and a fallback between providers. There is no
DeepL client in the code: TRANSLATE_PROVIDERS is ['libretranslate'], and an
unrecognised setting silently falls back rather than being rejected — the
first wording I wrote claimed a rejection that does not happen either.
TODO.md now names clinical-storage-milvus and
personal-assistant-storage-milvus, and records the MinIO audio-backup work
as finished.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Written in the operator-handbook style of the Ops Docs collective (title,
last-updated line, short prose, tables, a quick-checks block, no secrets).
Ready to paste into the collective, or to publish once writing to Nextcloud
is permitted — that call was blocked by my permission layer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
ped-ai-storage held the personal assistant index, and nextcloud-mcp-server
held ped-ai's clinical index and runs no MCP server at all — only etcd,
MinIO and Milvus. The names said the opposite of the truth:
ped-ai-storage -> personal-assistant-storage-milvus
nextcloud-mcp-server -> clinical-storage-milvus
Only the secret bind mounts here name those paths. Both compose files pin
`name:`, so containers, networks and volumes keep their existing names and
no data moves; that also means the container prefixes still read
ped-ai-storage_*, which can only change by copying each volume.
Verified: all three stacks' compose configs validate, ped-ai recreated with
the new secret paths and reads them, and the storage stack still resolves
its own containers from the renamed directory.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
The nc_semantic_search alias is gone from the MCP server, so accepting it
here would point retrieval at a tool that no longer exists. A stale
override now stops the app at startup instead of silently retrieving
nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
The app key carries a second policy covering only the audio-backups bucket,
so the already-mounted credentials serve both. Verified on the live stack:
storage=object, key recordings/<user>/<stamp>, round trip byte-identical,
and the object is removed with its row.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three S3 configurations had grown separately — S3_* for documents,
GENERATED_IMAGES_S3_* for images, and AUDIO_BACKUPS_S3_* after them — with
different key names and their own client construction. That is why moving
storage meant hunting through several files.
src/utils/objectStorage.js now resolves settings for any purpose: its own
variables first, then the shared S3_* ones, with a per-purpose bucket name
(S3_BUCKET_AUDIO_BACKUPS). One endpoint plus three bucket names is enough
for the whole app, and a purpose that needs its own account still overrides
everything. Audio backups and documents use it; generated images keeps its
own tested storage module, whose variable names the resolver already
understands.
Nothing existing has to change: S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY and
the AWS_* fallbacks still resolve, and path-style addressing keeps each
purpose's previous default — off for documents, so a Backblaze endpoint
behaves as before, on where a custom endpoint implies MinIO. A _FILE
credential now always beats an inline one, so a mounted secret cannot be
shadowed by an inherited environment variable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Same MinIO, its own bucket, as asked. The audio-backups bucket is created;
what remains is a MinIO IAM change, which needs root credentials — my
permission layer blocked that call, so it is a script to run rather than
something I applied.
The script is additive and reversible: it attaches a second policy covering
only the new bucket and carries the existing generated-images grant over
rather than replacing it (attaching only the new one would break image
storage). It prints the AUDIO_BACKUPS_S3_* values to set, and how to undo.
A bucket policy alone does not work here: MinIO evaluates the user policy
first and it denies by default. Verified — the app key gets AccessDenied on
the new bucket until its own policy allows it.
TODO records that, and the indexer being repointed from Documents to
Personal assistant so mail is finally reached.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Switching to the Assistant set window.location, which reloads the document
and silently ended any running recording. The assistant is a tab in the same
page and activateTab already rewrites the URL to /assistant, so the switch
now happens in place; the reload stays as a fallback. Verified in Chromium:
recorder still running, no reload, URL /assistant, assistant visible.
Signing out mid-recording used to end it with nothing kept. It now says so
first — "the audio will be saved for 24 hours so you can transcribe it
later" — and stores the audio either way, tagged with the module that
produced it ('encounter', 'soap', 'dictation'), which is what makes it
findable in Settings afterwards. Sign-out completes whether or not the save
worked, and the rescue never rejects, because the caller is on its way out.
Verified: the warning appears and the upload carries module=encounter.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
- docs/speech.md: a new Recording section (wake lock and why it is re-taken
on visibility, idempotent start, what happens when a recording ends by
itself, what signing out does), the retention rules, and step-by-step
instructions for moving audio backups to MinIO.
- Records that the model for a transcription is the user's choice first,
which is why the picker must only ever offer models the gateway has, and
that LITELLM_STT_MODELS is a fallback rather than a list known to work.
- api-reference: /api/transcribe takes `module` and returns `backupId`, and
the Audio Backups group is no longer failure-only.
- configuration.md and .env.example document AUDIO_BACKUPS_S3_*, preferring
the _FILE credential variants.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Recording
- A screen wake lock is held for as long as a recording runs. Browsers drop
the lock whenever the page is hidden, so it is taken again on return —
without that, one glance away ended it for the session. The lock is
reference counted (two recorders cannot release each other's), never
requested while hidden (the request would just be rejected), and a denial
or an unsupported browser leaves the recording running.
- Signing out releases it and stops the recording; nothing is sent, because
the session that owned the audio is gone.
- start() on an already-running recorder is now a no-op instead of replacing
the MediaRecorder and silently dropping everything captured so far.
- A recording that ends by itself — recorder error, or the microphone taken
by another app, unplugged or revoked — takes the same path as pressing
Stop, so it is transcribed and stored rather than left in a tab that still
says "recording". Moving around the workspace already kept recording.
Retention
- Every recording is kept for 24 hours now, not only the ones whose
transcription failed. /api/transcribe already has the audio, so this costs
no second upload, and a storage failure is logged rather than thrown: it
must never lose the transcription someone is waiting for.
- One store (src/utils/audioBackupStore.js) is shared by /api/transcribe and
/api/audio-backups so the two cannot drift. Payload goes to object storage
when AUDIO_BACKUPS_S3_* is set and to the encrypted Postgres column
otherwise; metadata always stays in Postgres, so listing, ownership and
expiry behave the same either way. Object keys are scoped by owner, and
the expiry sweep deletes the object with the row.
Verified against the live database: round trip byte-identical, another user
reads null, 950 -> 48 bytes compressed, expired rows take their objects.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
- adminMilestones was reached only because adminConfig is mounted on
/api/admin ahead of it and guards the whole path. adminMiddleware checks
req.user.role and nothing else, so it failed closed (403) rather than
open — but on mount order, not intent. It now states the requirement,
with a test covering all four admin routers.
- TODO.md records the whole audit: what was verified working (live
transcription round trip, voice mode wiring), what was fixed, the two
advisories that are unreachable and why, and the CI/CD and Kubernetes
work worth doing before scaling out.
Security review found nothing else exploitable: parameterised SQL
throughout (the one interpolated table name is allowlisted), CORS refuses
to start open in production, JWT_SECRET refuses to start unset in
production, rate limits on /api and each auth route, a real CSP, and no
secrets in the repo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Export
- Both stores already held the audio (server /audio-backups/:id/audio and
the local IndexedDB record) but nothing exposed it, so a recording could
not be taken out of the app. Each backup row now has a download, naming
the file by its timestamp and using the extension actually recorded
(webm, or m4a on iOS). A local record is only handed over to the account
that owns it.
Robustness
- MediaRecorder had no onerror and nothing watched the audio track, so a
recorder that failed, or a microphone claimed by another app, unplugged,
or revoked, left the tab saying "recording" while capturing nothing.
Both are now reported once, with the chunks captured so far kept, so
stopping still returns the audio up to the failure.
Deliberately not added: a wake lock. Stopping when the screen sleeps or the
session ends is the intended behaviour — recording is meant to be
deliberate, and nothing is left behind.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Security
- nodemailer 9.0.1 -> 9.1.1, clearing four high advisories, two of which
are delivery bugs that matter for an app that sends mail: recipient-domain
validation bypass via RFC 5322 comments, and an IDN/punycode allow-list
bypass, both of which can route mail to an attacker-controlled domain.
Live transcription
- The Settings picker was a hardcoded list of six ids
(local-whisper-*, local-parakeet-v3, gemini-*). None of them resolve on
this gateway, and /api/transcribe prefers the user's choice over the admin
default, so picking one broke every recording with "Invalid model name".
Verified against the live gateway: local-whisper-large-v3-turbo -> 400.
- The picker now lists what /model/info advertises as audio_transcription,
cached for five minutes, with the built-in list kept only as a fallback
and the admin default marked.
- The pipeline itself is healthy: local-kokoro-tts produced 92KB of speech
and mistral-voxtral-mini-transcribe returned the sentence back verbatim.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Feature audit
- Settings claimed you could reference a template by saying "use my normal
physical exam" in dictation. No phrase handling exists anywhere, and the
prompt says the opposite: "Never copy clinical content from a template —
only formatting and structure." So a template can never supply findings.
The text now says what happens, and keeps the true privacy statement that
only template categories go to the AI (Custom is filtered out in
/memories/context by AI_CONTEXT_CATEGORIES).
- Templates themselves are real: CRUD plus /memories/context, injected as
style hints by hpi, soap, sickVisit, wellVisit, edEncounters and
hospitalCourse, behind the `memories` feature flag.
Docs
- docs/CLINICAL_ASSISTANT.md listed six settings and offered `deepl`, which
no longer exists in the code. The table now covers all seventeen keys the
server reads, with their fallbacks, plus how a model reaches a user.
Testing
- Every test file's stdout is now pure TAP, which is the stream node:test
parses results from. Three sources: a leftover debug console.log dumping
600 characters of HTML, page modules logging into a JSDOM without a
virtual console, and the server startup banners. The banners are guarded
by NODE_TEST_CONTEXT, set only inside node:test children, so production
and `node server.js` output is unchanged (verified both ways).
- Three consecutive full-suite runs at 671/671.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
iOS zoom (and the menu button scrolling away with it)
- Safari zooms the whole page when a text field under 16px takes focus and
never zooms back; while zoomed, fixed chrome sits outside the visual
viewport, which is why the menu button had to be scrolled to. All 144
visible text controls were 13-15px. They are 16px on phones now.
!important is deliberate: per-component rules like `.demo-field select`
outrank any element selector, and a new component would slip through.
- The composer set 15px AFTER its own mobile 16px rule, so at equal
specificity the base rule won; the size is per breakpoint now.
- Pinch-zoom is untouched: no user-scalable=no. Desktop unchanged (13/15px).
Composer
- "Ready" / "Looking up sources..." / "saved" repeated what the send button
already shows by turning into Stop, and on a phone they wrapped under the
model control. Kept in the DOM as live regions; nothing is drawn.
- The model control shows the word "Model" (76px) instead of ids as long as
"openrouter-gemini-3.1-flash-image-preview", and opens the full list on
click. The select stays as the state holder, so choosing still saves
through the same change event.
Search
- Nothing typed lists the 19 destinations only; sub-items appear once there
is something to match.
- Listing, matching and opening now read one attribute list instead of three
that could drift, and bedside's emergency sections are searchable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Admin links
- The [data-admin-jump] click handler had been pasted inside
discoverModels(), so it only registered once someone pressed Search in AI
Model Management, and re-registered on every later search. Normally
nothing intercepted the click, so href="#" did what it says: jump to the
top and leave "#" in the URL.
- This is an app, so the pointers are plain text naming the sections rather
than links, and the handler is gone.
Test flake
- admin-clinical-assistant-wiring failed about 1 run in 4 with "Unable to
deserialize cloned data due to invalid or unsupported version": node:test
reads a test file's results back over the child's stdout, and app.js's own
console.log landed inside a serialized frame.
- The page's console is now forwarded to stderr (jsdom 29: forwardTo).
Verified: child stdout clean, 0 failures in 32 stress runs (was 6 in 24),
and three full-suite runs at 668/668.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Image models
- The Clinical Assistant "Image models" list waited on an old
#assistant-image-model dropdown that no longer exists, so discovery never
reached it and only four hard-coded fallbacks appeared — with no way to
add any of the gateway's 50 image models.
- Image Generation search rows now have + Add / Added. Added models are
saved as clinical_assistant.image_model_roster (validated server-side:
up to 100 ids) and appear in the Clinical Assistant list at once; ticking
one there offers it to users. Anything already allowed or configured
stays listed. Unsaved ticks survive an add.
- The roster notification is guarded, so it can never fail the settings load.
Phone top bar
- The page is drawn under the status bar (viewport-fit=cover) and its
theme colour was the removed header's blue, so on an iPhone content showed
scrolling at the top of the screen. The row is now a real fixed element
that extends behind the status bar (env(safe-area-inset-top), 0 in a
normal tab), the menu button, sources pill and drawers clear it, and the
theme colour is white.
Verified in Chromium: + Add -> saved roster -> listed unticked; tick kept;
remove works. Top bar is the only thing in the top 48px on all 22 pages;
phone menu positions unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4rptBNvn6RYieQw54GXNS
.assistant-sources was a grid with an auto column, which grows to its
widest unbreakable content — so a long file name such as
"2021_Fleisher_&_Ludwig's_Textbook_..." widened every card past the screen.
The column is now minmax(0,1fr), cards shrink (min-width:0) and names break
onto new lines (overflow-wrap:anywhere, with word-break for iOS < 15.4).
On phones the list is one vertical scroll: no sideways pan and no excerpt
scroll box inside it.
Measured on iPhone 14 / SE, Android 360 and Pixel: before, cards reached
636px on every phone (list overflowed by 236-288px); after, the widest card
ends 12px inside the screen and nothing scrolls sideways.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4rptBNvn6RYieQw54GXNS
The list is simply there now. Its collapse preference is removed too, so a
list someone had collapsed cannot come back hidden with no control to open
it. The header's CSS (desktop, phone and preview) goes with it.
Sizes, measured in Chromium before and after: switch, New chat and the
name card are unchanged (desktop card 848, phone 790). The list takes the
header's 16px, and the first date heading sits 10px below New chat on both
desktop (167 -> 151) and phone (173 -> 165; phone list top padding 2px ->
10px), matching the menu's 10px rhythm. The rail itself still never scrolls.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4rptBNvn6RYieQw54GXNS
- Collapsed menu: its own plain white 52px column against the tiled ground,
no border line, in both views. Flat, it had no edge.
- Collapsed + workspace launcher: assistant.css set the rail track to 0 and
beat the 52px rule, so the collapsed menu (name card included) vanished.
- Search follows the view, not the URL: the workspace launcher keeps
/assistant, so Workspace searched chats.
- Create image, "Base it on": a native <select> list is as wide as its
longest option, so full titles pushed it past the dialog. Labels now keep
the whole words that fit the select's width; the full title is on hover.
Verified in Chromium: account menu items on top in all 8 states; collapsed
strip 52px white with the name card at the same place in all 3 views;
search placeholder and results per mode; dropdown widest label 500px in a
506px box (279 in 280 on a phone).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4rptBNvn6RYieQw54GXNS
- The tiled ground moves from .main-content to .app-body, so the menu sits
on it too and the grid keeps one origin whichever view is showing. A
second tile on .main-content would have started 210px in and seamed.
- Desktop: the workspace sidebar is flat (transparent, no border), the same
as the assistant rail, which drops the white box it briefly had. The phone
sheets stay white because they slide over the page.
- Workspace page cards get the assistant chat panel's tiled-white fill.
Measured in Chromium: name card 848-892 at x=8, brand, switch and 210px
width identical in both views, expanded and collapsed; no page scroll.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4rptBNvn6RYieQw54GXNS
Measured in real Chromium (1440x900 and 390x844) rather than inferred.
Desktop
- The assistant rail sat inside the content area's 14px padding, on the
tiled ground. So its background differed from the workspace sidebar's,
its name card sat 6px lower and further in, and the page was 28px taller
than the window. It is now the same flush white sidebar with the same
insets; the chat and sources panels carry the 14px. Measured: name card
848-892 at x=8, brand, switch and 210px width identical in both views,
expanded and collapsed, with no page scroll.
Phone
- The menu button opens the chat history in the assistant (its drawer) and
the workspace list elsewhere. In workspace mode the drawer lists the
workspace links. Choosing a page, switching mode or pressing « closes it.
- The drawer no longer scrolls as a whole: only the chat list does, so the
name card is pinned at the foot, the same place as in the workspace menu
(790-836 at x=12 in both).
- Both menus are one plain shape: same width, text rows, grey current row,
no "Menu" header and one close control.
- The assistant page no longer overflows the phone by 16px.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4rptBNvn6RYieQw54GXNS
Phone
- The desktop collapse rules (52px icon rail) were unscoped, and menu-hidden
is remembered across viewports, so after collapsing on a desktop the phone
sheet opened with its links, the Assistant/Workspace switch and the account
name all display:none. Scoped them to min-width:769px.
- Workspace pill: the sheet now closes on tap, and the launcher is no longer
display:none below 640px, so the pill opens the workspace instead of
toggling a view that could never appear.
- The menu button has its own 48px row instead of floating over the page;
layouts subtract --mobile-bar (0px on desktop, so desktop is unchanged).
- Sources open as their own scrollable band under that row, and the toggle
sits in the row, instead of a sheet/FAB covering the composer.
- The sheet uses dvh, so the account card is not pushed below the visible
screen by the browser toolbar.
Admin
- Image Generation card beside TTS and STT: search the gateway's image
models and test one. No Set button: an image model is chosen per workflow.
It searches only on demand, so opening Admin still makes one discovery call.
- Account card sits at the same height in every rail state; the model
availability list points to where new models are added.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4rptBNvn6RYieQw54GXNS
.sidebar sets overflow:hidden and the collapsed rail is 52px wide, so the account
menu — laid out inside it — was cut to a sliver. Settings, FAQ and Log out became
unreachable behind the avatar exactly when the rail is narrow, which is the state
the screenshot shows. The collapsed menu is now positioned against the viewport
instead of the rail, so it cannot be clipped by the thing that opens it.
The card also sat lower in the assistant than in the app: the app column pads its
bottom and the assistant rail only padded the top. Both now use the same vertical
padding and the same row height, so the card lands on one baseline in either
view.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0161xNW1z4vPusrXKGWcQdQu
Three faults, all visible on a phone at once.
Two hamburgers, neither useful. The assistant added a drawer button of its own
AND the app's button was still there, stacked on top of it. Worse, the assistant
hid the app sidebar unconditionally, so the app's button opened something
invisible. A phone now has exactly one menu — the app sidebar — and the assistant
only replaces it with its own rail on desktop.
The collapse control had nothing to collapse on a phone, where the menu is a
sheet rather than a rail. It closes the sheet instead.
Sources were `display:none` below 640px with no alternative, so every SRC chip in
an answer pointed at nothing. They now slide up as a dismissible sheet, opened by
a button that appears only when an answer actually has citations, or by tapping a
citation itself. Tapping away or pressing Escape closes it — a sheet with no way
out is a trap.
The menu itself is sized for a thumb, borrowing the reference layout: a wide
sheet, close and search leading, tappable rows, and padding clear of the home
indicator.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0161xNW1z4vPusrXKGWcQdQu
The Create image popup showed "Generating image…" forever even after the job
finished. The status poll called fetchAssistantImageJob, which was never
imported, so every tick threw ReferenceError — and the catch treated that like a
transient network failure and rescheduled, permanently. The import is added, a
test now asserts that every api.js function the assistant calls is actually
imported, and the poll distinguishes a programming error (surface it) from a
transient one (retry, but not forever).
Chat titles were hard-cut at 60 characters mid-word, so "Rickets Radiographic
Fea" was all the Create image picker could ever show. The server already allows
160, so titles now keep whole words up to that, and each view decides its own
visible length from the width it actually has rather than inheriting one cut made
at save time. A single very long token still falls back to a hard cut.
Extension cards led with the number at 20px with word-break:break-all, so
"5616/3764/5619" wrapped as "5616/3764/56 19" — unreadable, and unsafe to dial
from. The name leads now, since that is what the eye hunts for in a list of
fifty; the number follows in tabular figures and may only break between groups,
never inside a run of digits. Cards share a minimum height so a grid reads as
rows rather than a ragged mosaic.
The collapsed rail's brand kept its expanded margin-right:auto, which pushed the
stethoscope off the axis the two buttons sat on. Every child of the collapsed
head is now the same centred fixed-size box.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018e1PLqrKgAM9jQhFKRnbLd
Gallery tiles are 56px but were downloading the full ~280kB original. Previews
are now rendered with sharp and stored beside the originals in the same MinIO
bucket under a thumbs/ prefix, so nothing about credentials, lifecycle or backup
changes. Measured on live assets: 216-294kB originals become 13-19kB at 256px,
about 16x smaller; 640px is about 4x.
Both paths, as asked:
- Rendered when a job completes, so the first viewer never waits for a resize.
A preview failure never unmakes a finished job.
- Rendered on demand for anything that has none — the existing 26 images work
immediately with no backfill required, and the result is stored for next time.
Boundaries that matter more than the speed:
- Only 256 and 640 are honoured. An open width parameter would let a caller
drive arbitrary resizes.
- Permission is checked against the ORIGINAL before a preview is served, so a
preview can never widen who can see an image.
- Previews carry their own SHA-256 and owner headers, because the client
verifies both on every asset; sending the original's checksum would be
rejected as tampering, which is that check working correctly.
- Still private, no-store. The client asset pattern was widened to exactly
?w=256 and ?w=640 and nothing else.
Client-side downscaling stays as the fallback when a preview cannot be produced.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018e1PLqrKgAM9jQhFKRnbLd
Generated assets are served `private, no-store`, which is right for a clinical
app on a shared workstation — but it meant every gallery render re-downloaded
every image. Measured: 26 images averaging 279kB, so 7.2MB fetched to draw 56px
tiles, on every open of the Create image popup.
Two changes, both reusable anywhere in the app:
- A session cache holding decoded blobs in MEMORY ONLY, so nothing is written to
disk and the no-store posture is unchanged. Entries are keyed by owner ticket
as well as asset, and cleared when the account boundary moves, so one account
can never read another's bytes out of memory. Concurrent tiles asking for the
same asset share one request rather than racing.
- Any img carrying data-image-thumb gets a downscaled copy instead of the
original, so a 56px tile no longer decodes a 300kB image. The gallery asks for
256px and the in-chat preview for 640px; opening the full view still gets the
original. Browsers without OffscreenCanvas, and any decode failure, fall back
to the full image rather than showing nothing.
This does not reduce the first fetch. Serving genuinely smaller bytes needs
server-side resizing, which needs an image library this project does not carry —
worth a deliberate decision rather than adding a native dependency in passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe
Search now reaches sub-navigation, because people look for "bili" rather than
"Calculators". It reads whatever a loaded component exposes — data-calc,
data-subtab, data-section — so it covers every tab with sub-navigation instead of
one hard-coded list, and the relevant components are warmed when the palette
opens. Opening a result survives the component still loading, and reaching one
from the assistant navigates first and opens it after.
Menu head order is now show/hide menu, then the mark, then search. In the
collapsed rail every item is the same centred 52px box, so the icons finally
share one axis — the brand was a flex row with a gap and sat off-centre from the
buttons beneath it.
Settings, FAQ and Admin are no longer listed in the menu: they already have a
place in the account card, and listing them twice only made the tab list longer.
Their sections remain, and activateTab already tolerates a tab whose section
exists without a sidebar button.
The retry loops are named functions rather than IIFEs, which the module
entrypoint rules forbid.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe
Collapsing hid the sidebar entirely, and the toggle had moved into .menu-head
while the hide rule still targeted direct children of .sidebar-nav — so the head
went, taking the toggle with it, and there was no way to reopen the menu.
Collapsed is now a 52px icon rail rather than a disappearance: the mark, the
toggle, search and the account avatar stay reachable in place, so nothing has to
be floated over the content and there is always a way back. The assistant rail
collapses to the same strip.
Scrolling. The workspace list in the assistant rail had no overflow rule, so its
list of every app tab ran off the bottom and scrolling only appeared once you
left for the app. In the app sidebar the opposite: the whole sidebar scrolled, so
the account card scrolled away with the list and Settings and Log out became
something to hunt for. Both now scroll only the list, with the head and the
account card pinned.
The toggle was also being styled twice — as a head-row icon button and by its own
older rule with a different size and margin. It is now only the behavioural hook
that flips the icon.
Brand is PedAI, which is short enough to sit in the head row without squeezing
the controls; the collapsed rail shows the mark alone.
The reachability test now walks the toggle's real ancestor chain and fails if any
collapse rule hides a branch it sits on, rather than asserting one rule's text —
this class of bug has broken twice by moving the toggle deeper.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe
The header bar is gone. Settings and Log out moved into the account card, so a
whole band of chrome had nothing left to hold. The brand now heads the menu
alongside the search and collapse controls, on one row rather than three stacked
full-width ones. btn-settings and btn-logout are retained hidden as the canonical
handlers other code already calls, rather than duplicating that logic.
One shell for every view. Each tab was a plain white page while the assistant and
the workspace launcher sat on a tiled card, so opening any menu item changed the
whole face of the app. The tiled ground and the card edge now belong to
.main-content, and only the contents differ. The assistant replaces the shell
card rather than nesting a second one inside it.
Search, in both views, from the same palette: the workspace searches the app
menu, the assistant searches saved chats. Both sources are already in memory, so
this needs no new endpoint. Cmd/Ctrl+K opens it, arrows and Enter work, and the
assistant publishes its chats rather than the palette reaching into its
internals.
The collapse icon is now directional angles that rotate when collapsed, so it
always states what the click will do; fa-table-columns said nothing.
Mobile: the sidebar slides in whole, so the in-sidebar collapse control is hidden
there, the account card sticks to the foot of a scrolling menu, and the palette
is full-bleed instead of a centred dialog on a small screen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe
The view now decides only what the menu LISTS. Everything structural is shared,
so switching redraws the content and nothing else:
- One width. The app sidebar is 210px; the assistant rail was 260px, so every
switch shifted the content sideways.
- One surface. The tiled ground belongs to the shared panel rather than to one
view's empty state, so the transcript and the workspace launcher sit on the
same background.
- One menu ending. An account card closes both menus with Settings, FAQ, Log out
and — for admins only — Admin. These were unlabelled icon buttons in the old
header; log out reuses the existing flow rather than a second implementation.
The blue gradient header is now a slim white bar. Being the loudest element on
screen, it made any view without it look like a different product. Everything
measured against it follows from 66px to 52px.
The composer has room around it and centres on an empty transcript instead of
sitting at the very top, and Create image joins take home, export and attach in
the + menu — it acts on the conversation, so it belongs with them rather than as
its own button on the rail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe
The assistant carried its own bar above the layout, on top of the blue app
header that both pages now share. On desktop it held a visually-hidden title, a
mobile-only drawer button and a status pill — so it was mostly dead space, and it
made the assistant taller than the app page it is supposed to match.
The bar is gone. The ready/busy indicator moved into the composer footer, next to
the work it describes, and the drawer button floats on phones rather than
occupying a strip the desktop layout does not have. The rail now sticks to the
top of the layout instead of 52px below a bar that no longer exists.
The menu toggle used fa-table-columns, a split-pane glyph that does not read as
"menu". Both copies now use fa-bars and rotate when collapsed to show which way
they will act.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe
Sources (correcting what I built earlier)
The previous toggle branched the SYSTEM PROMPT, so the same question could get a
different answer depending on a display setting — the bias this was meant to
avoid. The prompt is now unconditional: buildSystemPrompt takes no display
argument and is byte-identical either way. Hiding sources happens on the way out
— the server omits them and strips the now-orphaned [n] markers from the copy it
sends. The answer is generated, stored and exported with citations intact, so
turning the setting back on restores them without re-asking anything. Renamed to
clinical_assistant.show_sources; the old key is still honoured.
Signed-out preview (admin opt-in, default off)
A visitor may try the assistant; reaching for the workspace asks them to sign in.
Deliberately narrow:
- Reachable paths are an exact allow-list, not a pattern, so a new endpoint is
private unless someone adds it on purpose.
- A preview visitor gets no identity at all (id: null), so nothing can be owned,
saved, billed or addressed to them.
- The image tool is withheld rather than left to fail on a null owner, and no
audit rows are written.
- A caller presenting a token is authenticated normally, so preview can never
downgrade a real session; if the setting cannot be read, authentication is
required.
- Actions needing an account are hidden rather than offered and refused.
Composer
The bar above the transcript is gone. Patient take home, Export PDF, Download
transcript and Attach images moved into a + menu in the composer, and the model
selector moved beside send — shown only when there is more than one model, as
before. Both views now start at the same top edge, so switching modes cannot
nudge the page up or down. On an empty transcript the tiled ground runs behind
and below the composer, which floats on it above centre.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe
Three faults from moving the switch into the app sidebar:
1. Clicking Assistant did nothing. activateTab() bailed unless a matching
.tab-btn existed, and removing the "AI Assistant" nav entry removed exactly
that — so every /assistant visit fell through to the fallback tab. A tab
reached from the mode switch legitimately has a section without a button, so
activateTab now keys off the section and only touches the button if present.
2. The switch overflowed its box. The sidebar is 210px; without min-width:0 the
two buttons refused to shrink below their content. They now shrink, with a
slightly tighter font and gap so both labels fit.
3. "Hide menu" had no way back. The toggle sits inside the sidebar it collapses,
so it disappeared with it. It now leaves the collapsing box and pins itself
beside the content, in both the app sidebar and the assistant rail, and the
label already flips to "Show menu".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjVc5oaAaYFNbZGLeJp6TX
The Assistant/Workspace switch and the menu toggle rendered as unstyled native
buttons in the app sidebar. Their CSS was in assistant.css, which is pulled in by
the assistant COMPONENT — so it is simply absent on "/", where those controls now
also render.
Both moved to styles.css, which every page loads. A stale duplicate of the switch
rules was also still sitting in assistant.css; that is exactly how two copies
drift and one silently wins, so it is gone.
Adds a test that walks the classes rendered in index.html and asserts each is
defined in styles.css and NOT duplicated in assistant.css, which would have
caught this before it shipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjVc5oaAaYFNbZGLeJp6TX
Records what is genuinely outstanding and the non-obvious infrastructure facts
found while fixing it, so the next session does not rediscover them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjVc5oaAaYFNbZGLeJp6TX
Image generation was failing with "Generated image storage or migration
unavailable". The real cause was a 403 the catch block was swallowing: the
generated-images-app MinIO policy had lost its s3:ListBucket statement, so the
storage preflight's HeadBucket was denied while writes still worked.
Re-running the storage bootstrap tonight did it. The operator image carries its
own baked-in /opt/storage/check.py, and that copy is older than the file beside
the compose: the host copy grants ListBucket with a comment saying the preflight
needs it, the image copy does not, and running the image overwrote the good
policy. The image's copy also still resolves the store as "clinical-milvus",
from before that service was renamed.
The policy is restored, and the compose now mounts the host check.py (and
bootstrap_basic.py) over the image's, so what runs is what can be read and
reviewed here. Verified: bootstrap re-runs are idempotent again and storage
readiness passes after one.
Also styles the model choice as a real control — a matching chevron, hover and
focus states in the app accent — instead of a bare form element, in both the
composer pill and the Create image popup.
Adds a test that admin rows stack on a phone and that every flex block added
inside one can shrink, so the panel cannot start scrolling sideways.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjVc5oaAaYFNbZGLeJp6TX
The app IS workspace mode, so the switch belongs in both places and the only
difference between the two screens is which pill is highlighted:
- The app sidebar's "AI Assistant" nav entry is replaced by the switch, with
Workspace highlighted. The assistant rail carries the same control with
Assistant highlighted. Clicking Assistant from the app navigates to
/assistant; inside the assistant, Workspace opens the launcher in place.
- Accent corrected: I had it backwards last round and turned the assistant
purple. The app's accent is blue (.tab-btn.active in styles.css); the
assistant now uses it throughout, so neither screen looks like a different
product. Citation chips keep purple so evidence stays distinct from
interactive blue.
- One menu toggle labelled "Hide menu", shared by the app sidebar and the
assistant rail. It replaces three separate controls: a pin button, a floating
expand button, and the assistant's own rail toggle. The toggle stays reachable
when the rail it collapses is hidden, or there would be no way back.
- Workspace cards list clinical work only. Settings, FAQ, Admin, Docs and
Content Manager stay in the app sidebar under Account, where they belong.
- Model availability is discovery-only again: a hand-typed id cannot be verified
at that point, so the manual entry field is gone.
The in-page mode state machine is removed along with the whole class of
hidden-versus-display bugs it carried.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjVc5oaAaYFNbZGLeJp6TX
The mode switch was toggling the `hidden` attribute on elements that each carry
their own `display:` rule. A class selector beats the UA stylesheet's
[hidden]{display:none}, so every one of those toggles silently did nothing —
which is why workspace mode still showed the chat, and why coming back never
restored the saved-chat list. Every affected element was in that state.
CSS now owns both modes from a single body class, in one block, and the JS does
nothing but set that class. No element.hidden juggling remains.
One accent. The assistant used --blue for user bubbles, focus rings, blockquotes
and example pills while citations, cards and the mode switch used --purple, so
the two halves read as two different apps. Purple was already dominant (23 uses
to 6) and is the assistant's identity, so blue is gone entirely. Source cards
pick up the same shape, shadow and hover accent as the workspace cards, and a
targeted citation highlights in the accent instead of a bare border.
Go back is removed from both the topbar and the rail: the Assistant/Workspace
switch is how you leave now, and the dead handler went with it.
Create image no longer takes a full-width gradient row competing with New chat;
it is a square icon button beside it with a tooltip and an aria-label.
Also guards the delegated document listeners against double registration.
initIfNeeded already prevented a second bind in production, but nothing stopped
a stray bindEvents() from double-firing every click — which is exactly what the
test harness hit, activating a tab twice from one click.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjVc5oaAaYFNbZGLeJp6TX
Both surfaces render from the same pass over the app's real .tab-btn list, so
neither can drift from the menu it mirrors.
- The card view now shares the empty state's graph-paper ground, card language
and spacing, and is centred with a max width, so switching modes reads as the
same app instead of a page that looks nothing like it.
- Cards carry the clinical work tabs only: Settings, Admin, Docs and FAQ stay in
the rail list, where someone looking for them will go, rather than sitting
alongside Encounter HPI and Calculators.
- On phones the card grid is hidden entirely and the chat stays on screen — the
drawer already is the menu there, so a second copy only pushed it away.
- The Sources column and its 330px grid track both go in workspace mode.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq