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:
| Scope | API | UI route | Bounded by |
|---|---|---|---|
| the bare login | /account | /account | credentials — no role at all |
| me as a patient | /patient | /patient | every clinic that treats me |
| me as a clinician | /practitioner | /practitioner | every clinic I currently work at |
| staff of a clinic | /workspaces/:id | /workspace/:id | that 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:
- the last scope this account used, if it still exists;
- else
/practitioner, if there is a clinical profile; - else
/workspace/:id, if there is exactly one workspace and no clinical profile; - 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/appointmentsthrows 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 that summarises across them — a badge on the pill totalling two clinics' unread work, a merged diary, a combined count on a switcher row. Switcher rows therefore carry no counts, and none ever should.
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. Burger or kebab?
Neither — and the presentation menu does not survive signed-in.
The decisive practical fact: the burger slot is already taken. AppShell 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 deeper reason is that the pitch is not navigation for someone who has already signed up. A permanent seat in the most valuable strip of the UI is too expensive for a page a converted user never opens again. The pages stay reachable by URL and from the public footer, and the one moment a signed-in person plausibly wants pentru clinici — just before creating a workspace — is served by a contextual link on that screen.
Signed out, the presentation pages get a real labelled control: one top-bar dropdown listing all three audiences with the current one marked. That is the button the brief asked for.
Important
This contradicts the brief, which said the presentation pages move under a burger or kebab when
signed in. If you disagree, the fallback is one line: add presentationSection() to the
signed-in structure in each shell and it becomes a labelled top-bar dropdown there too.
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.