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
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
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
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
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
Voice lists were a single flat set from LITELLM_TTS_VOICES, so picking a
model could leave an incompatible voice selected and the request would
fail at the gateway. Voices are now resolved per model family (Kokoro,
Kitten, Supertonic, Groq Orpheus EN/AR), with a compatibility check that
falls back through user → admin → env → first valid voice. Groq Orpheus
requests also pin response_format to wav.
Also refreshes the cardiac/respiratory auscultation samples, extends the
well-visit component, and fixes the Android launch theme background
(@null → colorPrimary) so the splash does not flash through.
NOTE: this is in-progress work that was already sitting uncommitted in
the working tree; it is committed here as-is so the tree was clean for
the release bump.
Adds opt-in biometric login to the Capacitor app. Replaces the password
step on subsequent sign-ins; the 2FA step (if any) still applies — by
design, defense in depth.
How it works:
- After a successful password sign-in on a Capacitor build, prompt the
user to enroll. If they accept, capacitor-native-biometric.setCredentials
stores the (email, password) pair in the iOS Keychain / Android Keystore
with biometric-protected access. The local flag ped_bio_enabled=1 is
set so the next launch knows to probe.
- On the login form, if isNativeApp() + bioStored() + bioAvailable.ok,
reveal the "Sign in with Face ID / Touch ID / fingerprint" button at
the top. Label is set from the actual biometryType returned by the
plugin so users see what their device supports.
- Tap → verifyIdentity (OS prompt) → getCredentials → fill the email +
password fields → fire the existing form submit so all the regular
flow runs (turnstile, 2FA prompt, error handling, session storage).
- Explicit logout deletes credentials AND clears the local flag,
hiding the button on the next visit. Auto-logout (token expiry,
network) does NOT come through that path, so biometric persists
across silent session resets.
Storage choice — password not JWT:
- JWTs expire and the storage would constantly need refresh.
- Storing the password lets the standard /api/auth/login flow run,
which already handles password-rotation (a stale stored password
just fails 401 → user falls back to typing the new one → re-enrolls).
- The password sits in OS-level secure storage, accessible only after
successful biometric verification — same security posture as a
password manager autofill.
Files:
- mobile/package.json: add capacitor-native-biometric@^5.0.0 (Capacitor 6
compat)
- mobile/android/app/src/main/AndroidManifest.xml: add USE_BIOMETRIC
uses-permission
- mobile/ios/App/App/Info.plist: add NSFaceIDUsageDescription string
- public/js/auth.js: bioPlugin/bioAvailable/bioStored/bioEnroll/
bioRetrieve/bioForget helpers; window.PedBio surface; reveal-on-load;
click handler; post-login enrollment prompt; logout cleanup
- public/index.html: hidden #btn-bio-login + #bio-divider above the
email field on the login form
- public/css/styles.css: themed gradient button + hover lift
- mobile/README.md: feature list updated
Build steps for Daniel:
cd mobile && npm install # picks up capacitor-native-biometric
npx cap sync # ports the plugin into android/ + ios/
# then build APK / IPA as usual
Three concurrent themes from this session:
═══════════════════════════════════════════════════════════════════
ED ENCOUNTERS — per-stage cards + consolidate→MDM finalize
═══════════════════════════════════════════════════════════════════
UX redesign per Daniel's feedback ("every stage note should be shown,
if AI is told to modify that particular note then the modified version
is used in final mdm"):
- Each generated stage stays on screen as its own editable card with
its own embedded "Don't Miss" panel. No more single rolling note
element that gets replaced on each generation.
- gatherCurrentNotes() reads contenteditable text from each stage card
before any operation (advance, finalize, persist) so inline edits
flow into the next AI call and the final consolidate.
- Stage badge is now state-accurate. "Stage N (recording)" with yellow
background after Add-more before generation; "Stage N" with gray
after generation. Fixes the bug where the badge flipped to Stage 2
the moment Add-more was clicked.
- Save & Done now runs TWO server-side AI calls in /finalize:
1. edConsolidate (new prompt) → polished single final note that
integrates every stage chronologically (HPI / ROS / PE / ED Course /
A&P with disposition).
2. edFinalize (rewritten with full inline 2023 AMA E/M element
rubric — problems / data / risk definitions, level mapping with
concrete examples) → MDM JSON.
- Two new cards render after finalize: blue-bordered Final Consolidated
Note + green-bordered MDM. Stage cards become read-only.
- partial_data on the saved row now stores {stages, finalNote, mdm,
finalized} so resume re-renders the full state.
Why two-call finalize: a single combined prompt makes the model cut
corners on one task. Two focused calls cost ~2× latency at the very end
of an encounter — acceptable since finalize is a one-time terminal
action, not a per-stage hot path.
Files: public/components/ed-encounter.html, public/js/ed-encounters.js,
src/routes/edEncounters.js, src/utils/prompts.js (edConsolidate added,
edFinalize rewritten).
═══════════════════════════════════════════════════════════════════
EXTENSIONS / PAGERS — visual polish
═══════════════════════════════════════════════════════════════════
Multiple iterations based on Daniel's feedback:
- Layout: align-items:flex-start so action buttons stay pinned top-right
when long numbers wrap (was align-items:center → buttons drifted into
the text area, causing visible overlap).
- Number: word-break:break-all + min-width:0 + font-feature-settings:tnum
so long numbers wrap within their column instead of pushing under the
buttons. Click-to-copy with a 0.55s green flash + ✓ copied badge.
- Phone/pager Font Awesome icon next to the number in the type color —
at-a-glance type signal (replacing an earlier 3px left stripe that
Daniel found visually bulky).
- Name: font-weight 700, font-size 14.5px, color g900, letter-spacing
-0.012em — scan-target headline typography for long lists.
- Alternating subtle backgrounds by index (white vs #fafbfc) so a long
list reads as distinct rows.
- Hover: card lifts 1px with a soft shadow; action buttons fade from
55% to 100% opacity. Cubic-bezier transition on transform.
- Entrance: staggered fade-up animation per card (35ms × index, capped
at 12). prefers-reduced-motion media query disables motion.
- Empty state: 48px FA icon + heading instead of plain gray text.
Files: public/js/extensions.js, public/css/styles.css.
═══════════════════════════════════════════════════════════════════
DOCS REORGANIZATION + APPLICATION-LOGIC DOCS + ADMIN VIEWER
═══════════════════════════════════════════════════════════════════
Document moves (preserving git history via git mv):
BROWSER_WHISPER_SETUP.md → docs/browser-whisper-setup.md
BROWSER_WHISPER_TROUBLESHOOTING.md → docs/browser-whisper-troubleshooting.md
DEVELOPER_GUIDE.md → docs/developer-guide-extended.md
EMBEDDINGS_SETUP.md → docs/embeddings-setup.md
FEATURES_EXPLAINED.md → docs/features-explained.md
IMPROVEMENTS.md → docs/improvements.md
OPENID_SETUP.md → docs/openid-setup.md
TRANSCRIPTION_OPTIONS.md → docs/transcription-options.md
README.md updated with the new paths + a Documentation section that
links to docs/logic/ at the top.
New application-logic doc series (~8,300 lines total) at docs/logic/.
Built with 5 parallel doc-writing agents per Daniel's "use multiple
agents" directive. Each doc explains how a part of the app actually
works — application logic, data flow, design decisions, sacred zones,
how-to-extend recipes — at a depth that lets a new dev (or an AI
assistant) modify the code confidently.
docs/logic/README.md — index + recommended reading order
docs/logic/architecture.md (2166 L) — frontend IIFE pattern, lazy tab
load, backend route convention,
schema, encryption, deployment
docs/logic/clinical-notes.md (1546L) — every note tab + helper trio
docs/logic/bedside-and-calculators.md (1373L) — bedside ES module
pocket + calculators + PE Guide
+ suture selector
docs/logic/auth-admin-learning.md (1281L) — auth (local+OIDC+2FA) +
admin panel + Learning Hub
(Quiz engine logic at sub-detail
only — TODO follow-up)
docs/logic/ai-and-voice.md (1128 L) — callAI 5-provider routing,
prompts, voice/STT, helper trio
docs/logic/ed-encounters.md (821 L) — multi-stage ED + MDM (this
session's worked example)
Admin-only docs viewer:
- New route /api/admin/docs/{tree,file}: recursively walks docs/, returns
the tree as JSON; /file?path=X validates path stays inside docs/ and
renders markdown via marked. Both gated by req.user.role==='admin'.
- New tab "Docs" (book icon) in the sidebar, hidden by default and
revealed in auth.js when user.role==='admin' (same pattern as the
existing Admin and CMS tabs).
- New component public/components/admin-docs.html: split-pane layout
with a tree sidebar + filter input + a markdown reader pane.
- New module public/js/admin-docs.js: lazy-loads the tree on first tab
activation, renders collapsible folders, persists expanded state and
last-opened path via UIState. Server-rendered HTML so no client
markdown parser needed.
- CSS for the viewer (responsive split-pane, code-block styling, table
scrolling, etc.).
- Mounted at /api/admin/docs (NOT /api) — important: mounting a router
with router.use(authMiddleware) at /api accidentally 401s every other
/api/* path (caught and fixed during testing — /api/health was 401'ing).
Files: docs/* (moved + new), README.md, public/components/admin-docs.html
(new), public/js/admin-docs.js (new), src/routes/adminDocs.js (new),
public/index.html (tab + section + script), public/js/auth.js (admin
gate + logout cleanup), public/css/styles.css (viewer styles), server.js
(mount).
═══════════════════════════════════════════════════════════════════
KNOWN GAPS (TODO follow-ups)
═══════════════════════════════════════════════════════════════════
- Learning Hub quiz engine (MCQ / multi-select / T-F scoring + attempt
tracking + progress dashboard) is covered at the architectural level
in docs/logic/auth-admin-learning.md but not drilled into the quiz
data model and scoring flow. Worth a focused follow-up doc.
- ED finalize: if MDM step JSON parse fails, server returns 502 with
the consolidated finalNote in the error payload, but client doesn't
surface the partial result. Add a "MDM failed, retry" affordance.
- No e2e Playwright coverage for ED encounters or the new docs viewer.
Soft-delete for notes — Daniel asked for "deleted notes go to trash"
so a slip of the finger doesn't lose work.
Schema: migrations/1777090000000_notes-trash.js adds a deleted_at
timestamptz column to personal_notes (NULL = active) plus an index
on (user_id, deleted_at).
Server (src/routes/notes.js):
GET /api/notes now filters deleted_at IS NULL
GET /api/notes/trash new — list trashed items, newest-
deleted first
DELETE /api/notes/:id now soft-deletes (sets deleted_at)
DELETE /api/notes/:id?hard=1 hard-delete, only allowed on items
already in trash (UI bug can't
erase an active note)
POST /api/notes/:id/restore pull a note out of trash
POST /api/notes/trash/empty hard-delete every trashed note for
the user
Frontend (public/components/notes.html + public/js/notes.js +
public/css/styles.css):
• Sidebar gets two tabs — "Notes" / "Trash (n)" with live count
• Trash tab shows deleted-at timestamps, Restore + delete-forever
per row, Empty-trash button at the bottom
• Active list and trash count refresh in parallel after every
save / delete / restore
• Delete button in the editor now says "Move to trash" and uses
the showConfirm helper (no native dialogs)
Sanitizer swap (public/js/notes.js):
Replaced the homegrown allowlist walker with DOMPurify (already
loaded from cdnjs in index.html, used by learningHub.js too).
Custom HTML sanitizers historically have bypasses; DOMPurify is
the right primitive.
Tests (test/notes-sanitize.test.js — node:test + jsdom + dompurify
as new dev deps):
9 contract tests covering script-tag stripping, inline event
handlers, img onerror, iframe/object, style attributes, every
preserved tag in the allowlist, javascript: URI rejection,
null/undefined input, and nested-script-inside-paragraph. Total
test count: 37 → 46 passing.
SW cache bumped to pedscribe-v12-notes5.
1. flushAutosave dropped brand-new unsaved notes.
The guard was `if (_dirty && _activeId != null)` but _activeId is
null until the first POST succeeds. So tap-New-Note → type → tap-
Back went through flushAutosave → skipped → closeToList → lost
the typed content. saveNote() already handles isNew via POST;
the guard only needs to check _dirty.
2. beforeunload sendBeacon used wrong HTTP method.
navigator.sendBeacon is POST-only by spec, but my code used it to
target PUT /api/notes/:id. Beacons silently hit a 404 route and
the note didn't save. For brand-new notes the URL resolved to
/api/notes/undefined. Replaced with fetch({ keepalive: true })
which browsers queue + ship even across unload, supports PUT,
and handles POST for new notes correctly.
3. Mobile: empty-state card stacked below the sidebar in list view.
The mobile media-query hid .notes-reader + .notes-editor when
data-view="list" but didn't include .notes-empty-state, so the
big "new note" card appeared below the list on phones. Added
the missing selector.
4. Delete fallback used window.confirm.
The `else if (window.confirm(...))` branch in deleteActive was
a rule violation even as a fallback — feedback_no_native_dialogs
says no native dialogs anywhere in frontend. Dropped it;
showConfirm is loaded by app.js before any tab activates so the
fallback path is unreachable in practice. Also upgraded the
confirm call to use the danger + confirmText options so the
modal renders red "Delete" button instead of neutral "Confirm".
5. Recorder kept running after switching tabs or notes.
Dictating, then tapping another clinical-tools tab (or opening
another note) left the MediaRecorder active and the mic stream
open — wasted battery, privacy surprise. Added stopRecording
(silent=true) to the non-notes branch of tabChanged, to
startCreate, and to openReader so any in-flight recording
cancels cleanly when the user moves on.
Minor:
• Dead "'Saving…' : 'Saving…'" ternary in saveNote simplified.
SW cache bumped to pedscribe-v12-notes4.
Four changes landing together so the UX flows properly.
1. Fix voice-generation model error.
/api/notes/from-voice was passing `req.body.model` (undefined)
through to callAI, which resolved to LITELLM_DEFAULT_MODEL (empty
for LiteLLM deployments with no explicit default) → `model=""` →
LiteLLM 400 "Invalid model name". Server now reads the admin-
configured `models.default` setting, falls back to the
LITELLM_DEFAULT_MODEL env var, and only passes a model if one
resolved. Empty string never reaches the provider.
2. Read mode as the default post-save.
Opening an existing note or saving a new one now lands on a
clean, read-only rendering of the body (sanitized HTML with an
allowlist — p/h2/h3/strong/em/u/s/a/ul/ol/li/blockquote/code/pre).
Click the "Edit" button to switch to the Tiptap editor. Matches
the mental model of "notes are documents I review, not drafts I'm
always editing."
3. Autosave during edit.
Title-input + Tiptap onUpdate trigger a 1.2-second-debounced save.
In-flight saves coalesce: if the user types more while a save is
in progress, a follow-up fires right after it lands so the last
keystroke never gets stranded. beforeunload uses navigator.
sendBeacon to best-effort flush on tab close. The manual Save
button is kept as a "save + switch to reader" shortcut. Ctrl/
Cmd+S still works in the editor.
4. Mobile layout.
The two-pane layout collapses to a single-pane view below 900px,
driven by a data-view attribute on .notes-layout. Back buttons
appear in the reader + editor heads on mobile to return to the
list. Desktop layout unchanged (sidebar + right pane always
visible).
Also:
• CSS specificity fix — .hidden{display:none} was losing to
.notes-rec-indicator{display:inline-flex} on source order, so
the "Recording" indicator was showing when idle. Added explicit
.notes-rec-indicator.hidden + .notes-voice-bar .btn-sm.hidden
overrides at higher specificity.
• Reader-body sanitizer — allowlist of safe tags + attributes;
<a> links get target=_blank + rel=noopener.
• SW cache bumped to pedscribe-v12-notes2 so clients pick up the
new module / component / CSS.
New "Notes" tab under Clinical Tools — a per-user scratchpad that's
explicitly NOT fed into AI prompts (distinct from user_memories).
Two-pane layout: searchable list on the left, rich-text editor with
title + save/edit/delete on the right.
Backend:
migrations/…_add-personal-notes.js — personal_notes table
(id, user_id → users ON DELETE CASCADE, title, body, created_at,
updated_at) with indexes on user_id + (user_id, updated_at).
src/routes/notes.js — CRUD + one AI endpoint:
GET /api/notes list, newest-updated first
GET /api/notes/:id fetch one
POST /api/notes create (title + body required)
PUT /api/notes/:id update
DELETE /api/notes/:id remove
POST /api/notes/from-voice transcript → { title, body }
via callAI (admin-controlled
provider — never selectable by
the clinician).
Body + title encrypted at rest via the same cryptoUtil used for
user_memories; 500-note per-user cap; 200-char title / 50 KB
body limits.
Frontend:
public/components/notes.html — empty-state card ("Hello 👋"),
sidebar list with search, editor head with voice-bar (Dictate /
Pause / Resume / Stop + live timer + pulse indicator), Tiptap
body, metadata footer. Uses existing .tp-* toolbar classes.
public/js/notes.js — lazy-init on first tab activation; Tiptap
editor built from window.Tiptap (same bundle the Content
Manager uses); delegated list clicks; Ctrl/Cmd+S to save;
uses app.js's AudioRecorder + transcribeAudio so the STT
pipeline is shared. On stop → transcribe → /api/notes/from-
voice → drop the AI-structured title + body into the editor;
clinician reviews then saves.
public/css/styles.css — 70 lines of .notes-* styles (card
layout, warm empty-state with gradient icon + tips, pulse
animation for the recording indicator, focus states, hover
nudges).
public/sw.js — bump cache from pedscribe-v12 → pedscribe-v12-
notes1 so clients pick up the new module/component.
Admin-controlled STT provider: the recorder posts its audio blob
to the existing /api/transcribe (Google / LiteLLM / ElevenLabs /
Browser Whisper — whatever admin wired up in Settings). Users
cannot pick the provider from this UI.
Calculators:
- Bedside tab consolidating emergency protocols (Neonatal+Apgar+NRP, Airway/RSI,
Cardiac Arrest/PALS, Respiratory, O2 & Ventilation, Status Epilepticus,
Sepsis & Fever with PECARN/Aronson/Rochester/Step-by-Step, Anaphylaxis,
Procedural Sedation, Agitation, Antiemetics, Antimicrobials, Burns with
Lund-Browder body-parts TBSA + Parkland, Toxicology, Trauma).
- Global age→weight estimator at top of Calculators tab (APLS + Best Guess).
- Pressure-time waveform SVG teaching graphic for Ventilation.
- Algorithm image lightbox (fullscreen, Esc/tap-to-close).
- Every weight-based dose shows mg/kg inline for clinician verification.
- Drug tables wrapped in overflow-x:auto for mobile.
Infrastructure:
- Pure dose math extracted to public/js/calc-math.js (dual-export Node+browser).
- 36 unit tests in test/calc-math.test.js via node:test (zero new deps).
- "npm test" added to package.json.
New feature: after generating any clinical note, the app automatically
suggests relevant billing codes displayed as clickable chips below the output.
Backend (src/routes/billing.js):
- POST /api/suggest-codes endpoint analyzes note text
- Extracts diagnoses from Assessment section via regex
- Looks up ICD-10 codes: local common pediatric map (40+ conditions)
first, then NLM Clinical Tables API for unknown terms
- Suggests CPT E/M codes based on note type, visit complexity,
ROS/PE system counts, and MDM level estimation
- Supports: outpatient (new/established), well visit (age-based),
ED, inpatient (admit/subsequent/discharge)
Frontend (public/js/app.js):
- suggestBillingCodes() renders collapsible card with ICD-10 and CPT chips
- Click any chip to copy the code to clipboard
- Shows E/M level assessment (diagnosis count, ROS, PE, MDM complexity)
- Disclaimer: "Suggestions only. Always verify codes."
Integration: called after note generation in all 6 tabs
(encounter, SOAP, sick visit, well visit, hospital course, chart review)
Calculators tab with 7 tools:
- BP Percentile (AAP 2017) with age/sex/height classification
- BMI Percentile (CDC 2000) with extended obesity classification
(Class 1/2/3 using % of 95th percentile per CDC 2022)
- Growth Charts: weight-for-age, length-for-age, head circumference,
weight-for-length (WHO/CDC LMS), Fenton preterm (22-50 weeks)
- Bilirubin: AAP 2022 phototherapy threshold + Bhutani nomogram
with Nelson Table 137.1 risk factors for severe hyperbilirubinemia
- Vital Signs by Age (Harriet Lane) with quick reference formulas
(estimated weight, min SBP, ETT size, maintenance fluids 4-2-1)
- Body Surface Area (Mosteller formula)
- Weight-Based Dosing with max cap and volume calculation
Fix growth chart sub-tab navigation (pills scoped separately from
top-level nav to prevent panel disappearing)
- Add FAQ tab with accordion sections: Getting Started, AI & Models,
Voice & Transcription, Saving & Export, Privacy & Security,
Well Visit & Sick Visit, Learning Hub, Troubleshooting
- Documents how AI learns from physician edits (correction tracker)
- Fix FAQ accordion (CSP was blocking inline script, moved to app.js)
- Patch all 5 npm vulnerabilities: nodemailer 8.0.5, xmldom, basic-ftp,
path-to-regexp (npm audit now reports 0 vulnerabilities)
- Remove model category grouping from dropdown (flat list, no optgroups)
- Fix model dropdown dark background on options (white bg, dark text)
- Update FAQ model guidance to reflect admin-managed model selection
Security:
- Add session management: users can view/revoke active sessions in Settings
- Add password change in Settings (requires current password, HIBP check)
- Force logout all sessions on password reset
- Fix logout to destroy server-side session (was only clearing cookie)
- Add trust proxy for correct client IP in rate limiting and audit logs
- Add CORS support for multiple domains (CORS_ORIGINS env var)
- Add HIBP breach check endpoint and inline warnings on password fields
Audit logging:
- Add audit logging to all 24 PHI-handling endpoints across 13 route files
- Covers: generation, transcription, TTS, refine, encounters, documents, Nextcloud
- All fire-and-forget (no response delay)
AI improvements:
- Refine now includes original source material (transcript, notes, labs)
so AI can reference the full input when modifying output
- Add correction tracking (trackAIOutput) to sick visit and well visit tabs
- Fix sickvisit missing from encounter save noteIdMap
UI fixes:
- Non-blocking busy bar for transcription and AI generation (replaces full-screen overlay)
- Fix encounter recording: hide record button during recording (was showing two stop buttons)
- Fix ROS/PE "All WNL" stacking duplicate event handlers; add Clear buttons
- Enlarge AI instructions textarea in Learning Hub CMS
Domain:
- Primary domain now app.pedshub.com, with scribe.pedshub.com and peds.danvics.com as CORS origins
- Add Cloudflare Turnstile to login, register, and password reset forms
- Switch AI provider to LiteLLM, transcription to OpenAI Whisper
- Change domain to scribe.pedshub.com
- Fix PPTX export: add tables, bold/italic, numbered lists, code blocks, blockquotes
- Fix announcement banner close button (CSP was blocking inline onclick)
- Fix auth middleware: empty Bearer token now falls through to cookie auth
- Fix audio backups: only save on transcription failure, stop auto-deleting on success
- Soften AI correction injection to prevent model hallucination from correction history
- Fix LiteLLM TTS model name handling (no incorrect openai/ prefix)
- Expand AI instructions textarea in Learning Hub CMS
- Update README for v6 with all features and providers
- Add comprehensive docs/: architecture, API reference, database schema,
authentication, AI providers, speech, learning hub, configuration, deployment
- Full model management UI in admin panel: toggle models on/off, add custom
model IDs (any OpenRouter/Bedrock ID), set admin-configured default model
- /api/models now returns admin-set default model, frontend respects it
- Toggle switch CSS for clean enable/disable UX
- Backend already had the API endpoints, this adds the missing UI
- Add local Whisper (whisper.cpp / faster-whisper) as transcription provider
Set TRANSCRIBE_PROVIDER=local with configurable model size and binary path
- Upgrade all refine/instruction inputs to resizable textareas across
encounter, dictation, hospital course, chart review, well visit, sick visit
- Make AI memory injection flexible: physician preferences and corrections
are now actively applied (not just "formatting reference"), while still
overridable by current prompt instructions
- Add type="button" to all CMS action buttons to prevent default submit
- Change overflow:hidden to overflow:visible on .cms-main to prevent clipping
- Add z-index to .cms-editor-actions for reliable click targeting
- Replace AI "Topic" text input with a descriptive textarea context box
so users can tell AI what to generate (AI creates its own title)
- Rename AI tab from "By Topic" to "Describe Content"
Bug: classList.toggle('hidden') was silently ignored because
.lh-ai-opt-field{display:flex} is defined at CSS line 758, after
.hidden{display:none} at line 242 — same specificity, later rule wins.
Fix: updateAiOptions() now uses element.style.display directly (inline
styles always override class rules).
Also fix initial state: lh-ai-slides-field now uses style="display:none"
instead of class="hidden" so it's correctly hidden on load.
Panel syncs content type from editor on open (editorType → ctype select
→ updateAiOptions called immediately).
Per type:
- Quiz: word count hidden, slides hidden, quiz card visible (no toggle)
- Presentation: word count hidden, slides visible, quiz card hidden
- Article/Pearl: word count visible, slides hidden, quiz card with toggle
Quiz card redesign: gradient blue/indigo background, white header with
icon, clean body — replaces the plain grey rectangle.
REVERT: httpOnly cookie auth removed — reverts to JWT in localStorage.
The cookie implementation broke the login page (always-hidden auth screen).
localStorage token approach is restored fully:
- Login/register return token in JSON body
- getAuthHeaders() sends Authorization: Bearer header
- Session check reads localStorage token on load
- clearSession() removes localStorage token
Fix: slide preview padding — section elements now have 40px 48px padding
so text is not flush against the frame edges.
Kept from v3.14: AI panel context-aware options (word count, slide count,
quiz question toggle), delete confirmation wording per content type.
Delete bar bug:
- Root cause: .lh-delete-confirm-bar{display:flex} defined after .hidden{display:none}
so flex overrode hidden (no !important on global .hidden rule)
- Fix: remove display:flex from base rule, use .lh-delete-confirm-bar:not(.hidden){display:flex}
so hidden class always wins regardless of CSS order
Slide preview (replace window.open + document.write approach):
- Backend /preview-slides now returns JSON: {css, slides[]} (individual <section> HTML)
- In-page full-screen modal with dark overlay, white slide frame
- Prev/Next arrow buttons, dot indicators, slide counter
- Keyboard navigation (arrow keys, Escape to close)
- Touch/swipe support for mobile
- Overlay click closes modal
- No popups required, works in all browsers
Marp textarea:
- Placeholder updated to guide users: "Click AI Generate above to create slides"
- Makes it clear AI is the primary authoring path; textarea for review/edits
Delete confirmation:
- Replaced browser confirm() popup with inline red warning bar in editor
- Shows content title, Cancel and Delete buttons inline in the editor header
- Category delete uses double-click pattern with toast feedback
Login screen:
- Changed from dark blue/purple gradient to light gray background (var(--g50))
- Card now matches 404 page style: white, subtle blue shadow, indigo border
Presentation content type:
- New 'Presentation' button in CMS toolbar (green style)
- Marp markdown textarea editor (dark code editor style) shown when type=presentation
- Body/Tiptap editor hidden; Marp section shown (toggleEditorMode)
- Preview button: renders Marp HTML via @marp-team/marp-core, opens in new tab
- Download PPTX: pure-JS pptxgenjs (no Chromium), parses slides, exports .pptx
- AI generate: returns Marp markdown directly when contentType=presentation
- Body column stores Marp markdown for presentations
Backend:
- POST /api/admin/learning/generate-pptx — pptxgenjs PPTX from Marp markdown
- POST /api/admin/learning/preview-slides — Marp HTML preview
GitHub repo set to private via API.
Backend (src/routes/learningAI.js):
- POST /api/admin/learning/ai-generate
* Accepts: topic text, uploaded file (PDF/TXT/MD/HTML), or Nextcloud WebDAV path
* Extracts text from PDFs via pdf-parse; plain text read directly
* Fetches WebDAV files using stored Nextcloud credentials
* Prompts AI to return structured JSON: title, subject, body (HTML), questions[]
* Strips code fences, falls back to regex JSON extraction if needed
- POST /api/admin/learning/ai-refine — refine existing body with instructions
- GET /api/admin/learning/webdav-browse — PROPFIND-based Nextcloud file browser
- POST /api/admin/learning/webdav-path — save user's default browse path
Frontend:
- AI Generate button in CMS editor header (gradient purple→blue)
- Panel with 3 source tabs: By Topic / Upload File / Nextcloud
- Drag-and-drop file dropzone with DataTransfer API support
- WebDAV file browser: navigate folders, select files, breadcrumb path
- Options: question count, content type (article/quiz/pearl), model selector, instructions
- Refine Current Body: prompt-driven AI rewrite of existing content
- Generated content auto-fills title, subject, type, Tiptap body, quiz questions
Settings:
- Nextcloud section: "Learning Hub Default Browse Path" field (shown when connected)
- Saves to webdav_learning_path column via /api/admin/learning/webdav-path
DB: ALTER TABLE users ADD COLUMN IF NOT EXISTS webdav_learning_path TEXT