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 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 design — Ziua 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 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 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.
Desktop: present but quiet, and specifically a footer
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.