MedLib UI

Navigation architecture

How a signed-in person moves through medlib, and why it is shaped this way. The decisions below are implemented in layouts/navigation.ts and visible in every room's shell — this page is the argument, the module is the source of truth.

The one idea

The API is organised by who you are acting as, not by table. Four scopes:

ScopeAPIUI routeBounded by
the bare login/account/accountcredentials — no role at all
me as a patient/patient/patientevery clinic that treats me
me as a clinician/practitioner/practitionerevery clinic I currently work at
staff of a clinic/workspaces/:id/workspace/:idthat workspace, plus permissions

/me was retired because a user can be both a doctor and a patient, so it could never say which one was asking. The scope switcher is therefore not a convenience feature bolted onto the top bar — it is the UI of the thing the API already models, and the route is what makes the acting-as explicit rather than inferring it from the session.

Note

The UI route is /workspace/:id singular where the API's is /workspaces/:id plural. An API path names a collection you index into; a UI path names the room you are standing in, and its siblings /patient and /practitioner are singular for the same reason.

Two menus, two questions

The top bar carries exactly two navigation controls when signed in, and the split between them is the load-bearing rule:

  • The scope switcher (left, next to the brand) answers which hat am I wearing. Only scoped things appear in it.
  • The account menu (right, under the avatar) answers who is this login. Only scopeless things appear in it — the account, and the way out.

Nothing appears in both. Profilul profesional used to sit in the account menu of the medic room; it is scoped — it is PUT /practitioner/profile, the practitioner's own qualifications — so it has moved to the sidebar of the scope that owns it.

1. What is / when signed in?

A redirect. Never a page.

A dashboard at / would have to merge hats, and a page that merges hats is /me rendered in HTML. Every card on it would have to re-declare which scope it belonged to, which is exactly what the route already does for free. It would also need a fan-out of calls — /patient/events, /practitioner/events, and one /workspaces/:id/events per employer — on the most-visited route in the product, with no aggregate endpoint to serve it.

The person the brief names — a doctor at two clinics who is a patient at a third — does not have one home. She has three, and she picks. The switcher is the picker. So / resolves a scope and redirects:

  1. the last scope this account used, if it still exists;
  2. else /practitioner, if there is a clinical profile;
  3. else /workspace/:id, if there is exactly one workspace and no clinical profile;
  4. else /patient.

The order runs most-used first: the room you spend a working day in beats the one you visit twice a year. Step 4 never fails, which is the subject of the next question.

2. What does a just-registered account see?

The premise in the brief is wrong, and the correction is the whole design.

"Every scope in the switcher is empty" is not a state this product can be in. POST /auth/register creates the auth.users row and the app.people row in one transaction, so the person record exists from the first second and GET /patient/profile answers immediately. Every account has the patient scope whether or not a clinic has ever treated it. What a fresh account lacks is not the scope — it is content.

Precisely, after registering:

auth.users✓ — email unverified
app.people✓ — family name, given name, sex
app.patients✗ — no clinic treats them
app.practitioners✗ — GET /practitioner/profile → 404, "the normal state for anyone who is not a clinician"
app.staff, workspaces✗ — GET /workspaces[]; a personal workspace is never auto-created

So first run is a state of the patient home, not a screen of its own: same route, same chrome, same sidebar, nothing in it yet. That also means / needs no special case — it redirects to /patient like any other account.

What the screen actually does is ask the question registration forgot to ask. The form captures name, sex, email, phone and password, and never asks what the person came here to do — yet the same form produces a patient looking for a doctor, a doctor who wants a practice, and a clinic manager. So the page offers three doors, each a real self-serve call: browse the directory (GET /directory/practitioners, needs nothing), declare your qualifications (PUT /practitioner/profile), or create a clinic (POST /workspaces).

Two things sit alongside the doors:

  • The email gate is a refusal, not a nag. POST /patient/appointments throws 403 without a verified email — refuse-before-book, no pending state, because a held slot is taken either way and squatting would work. The copy names the blocked action, because someone told they will merely miss a confirmation will try to book and hit a wall.
  • The claim prompt must be a question, and unconditional. A Romanian who has been treated before already has records, held under their CNP, and a new account does not connect to them. But the screen may not say "three clinics hold records for you" — answering "which clinics hold records for this CNP" before identity is confirmed would let anyone who types a CNP learn where that person is treated. The privacy of that answer is what the identity check exists to protect, so the screen can only ever ask.

3. One dropdown or two?

One, with patient, practitioner and every workspace together — settled by the human.

Choosing a scope swaps the sidebar underneath, which is AppShell's existing switcher behaviour: a child that carries children supplies the nav below the pill. Ordering inside the menu is structural, not alphabetical, and the group boundary is real — /patient and /practitioner are person-level scopes that need no id, while a workspace is an entity you address by id.

Where employer isolation actually binds. Listing two employers side by side is fine: the person is entitled to both sides. What is forbidden is anything inside a /workspace/:id room that reflects another workspace, and any chrome that summarises across them — a badge on the pill totalling two clinics' unread work, a combined count on a switcher row. Switcher rows therefore carry no counts, and none ever should.

Warning

This list used to include "a merged diary". That was wrong, and it is worth saying why. The charter is explicit: "A practitioner sees all of theirs, including across clinics, because they alone are entitled to both sides." The practitioner scope is a person-level union by designZiua mea merging two employers' appointments is the feature, not the leak. It is what the for-doctors pitch sells, and the cross-employer overlap warning depends on it. The rule is about what a WORKSPACE may learn, never about what the person may see of their own working life. The line falls at the workspace room, where a clinic sees only its own side.

The bell is the one legitimate aggregate, and only because it is person-level: GET /account/notifications is one list per user with a scope filter, a person has one bell even with three hats, and no clinic learns anything from it.

4. Where does /account live?

In the account menu, never in the switcher — which is where the brief put it, and it is right. /account is the one thing in the product with no scope at all. Listing it in the switcher would make it look like a fourth hat, which is the /me confusion in menu form.

The account room is the one place the sidebar is not a function of the switcher. The switcher keeps showing the scope you arrived in — you have not stopped being a doctor by opening your password page — while the sidebar shows the account's own pages. That is what the account is: not a hat you put on, but a drawer you open while wearing one. The exception is legible because of how you got there: from the right-hand menu, never the left. Choosing a scope leaves the room.

5. Where do the presentation pages live when signed in?

Settled: they stay reachable. One burger on mobile, a quiet footer on desktop.

What was proposed, and why it was refused

The original proposal was to drop the presentation menu entirely once signed in, on two arguments. The first was practical: the burger slot is already takenAppShell uses the hamburger as the mobile drawer trigger below 880px, so a second one means two burgers on a phone. A kebab beside the avatar would be free, but a kebab there reads as "more actions for this page", not "the marketing site". The second was about cost: the pitch is not navigation for someone who has already signed up, and a permanent seat in the most valuable strip of the UI is too expensive for a page a converted user never opens again.

The human overruled it, and the second argument survived while the first turned out to be false.

The two-burger objection was built on a false premise

There was never a second burger available to add. AppShell's drawer already renders every top-level section as an accordion, so a section placed in structure lands inside the one hamburger rather than beside it. The real question was only whether the drawer absorbs the section — and it does, for free. The objection described a problem the component does not have.

So on mobile: one burger, containing both. The room's own nav and, below it, Pentru cine as a single collapsible section. Nothing else moved.

The cost argument was right, so desktop does not spend the top bar. presentationSection() is passed show:'drawer' — a section that takes no top-bar tab at all and appears only in the drawer. On a wide screen the top bar is byte-for-byte what it was.

What carries the pages there is the footer (PresentationFooter.svelte), and the reason is the motion rather than the leftover space. Nobody signed in navigates to the pitch. They go looking for a link to send someone — a doctor forwarding it to a colleague, a patient checking what medlib does for clinics. That is an outbound-sharing errand, not navigation, and a footer is where people already look for it. A top-bar tab would answer, permanently and expensively, a question nobody asks twice.

Quiet is a specific claim, so it is specific in the CSS: muted foreground, no icons, no boxes, no accent, at the very bottom of the page — resolving to full contrast on hover and focus. Invisible to someone not looking is the goal; unreachable is not.

Note

This also makes an old claim true. The previous version of this page said the pages "stay reachable from the public footer" — there was no footer anywhere in layouts/. There is now, and the signed-out public shell renders the same one.

Signed out, nothing changed: the presentation pages keep their real labelled control, one top-bar dropdown listing all three audiences with the current one marked.

The admin room is the one signed-in room that gets neither. It is the platform's own cockpit, not a user's room, and its staff are not an audience the marketing site is addressing.

Open: which pitch is /?

Unsettled, and it needs a decision. The live app serves the for-doctors pitch at /. This mock space argued the opposite and wrote the argument into PublicAcasa.sdoc: a two-sided marketplace leads with the patient, because patients are the volume and the reason a doctor joins at all, with the doctor pitch on its own page.

Nothing in the navigation architecture turns on the answer — the presentation menu lists all three audiences and marks the current one either way. Only the home mapping moves.