UI conventions
The written half of the design system. packages/ui is the other half, and the Console's /palette route renders every theme token as a live swatch.
This file exists because the same card was being drawn three ways on three pages — one with 20px of padding, one with 16px, one with a 36px icon where its neighbour had 44px — and each difference was individually defensible and collectively a mess. When this file and a page disagree, the file wins and the page is the bug. Changing a shared component so that every page matches is the correct fix, not a regression.
The one rule
A thing the patient or staff member sees twice is ONE component. Not a copy tuned per page. If a surface needs a variant, the variant is a prop on the shared component, not a second implementation next to it.
Where shared things live:
| Scope | Home |
|---|---|
| Used by two or more apps | packages/ui/src/components/ |
| Used by two or more pages of one app | apps/{app}/components/ |
| Used once | beside its page |
A component that moves up a level keeps its behaviour: it owns its own useTranslations namespace rather than taking a t prop, so a caller in a different namespace needs no copy of the strings.
Spacing
Card padding is ONE number per app: p-5 in the Portal, p-4 (the kit's own) in Clinic and Console. The split is the same one the type scale makes and for the same reason — the Portal is read by elderly patients at arm's length, the staff apps are dense tools — and it is the ONLY axis on which the two may differ. Do not write p-6, sm:px-6, or a per-card exception: a page of cards is read down its left edge, and one card indented 4px further than the next is visible as a ragged line even to someone who cannot say what is wrong.
CardHeader/CardContent/CardFooter/CardSection carry the kit's p-4 (compressed to p-3 under size="sm"), so a Portal call site passes p-5 explicitly. If a third surface ever needs its own number, scope it the way type-comfortable scopes the type — never per card.
A card with stacked bands uses CardSection. Give the Card itself py-0 gap-0; each band is a <CardSection>, and <CardSection divided> draws the rule above it. The separator belongs to the LOWER band, so the first one never has to know it is first.
Vertical rhythm between page-level blocks: space-y-4 inside a group, mt-8 between sections that carry their own heading.
A page's toolbar that stays in view is a StickyBar (packages/ui): the card band pinned at top: var(--sticky-top, 0). DataTable fill draws its toolbar with it. A layout that pins something above the page sets --sticky-top to that thing's height, so the toolbar pins right under it: SectionLayout (Clinic) does this for a full-width tabbed section (Sales), pinning its tab strip and the open tab's toolbar together. A tab page never pins anything itself, and never hard-codes an offset. A row of controls is a Toolbar (packages/ui), and everything in it is h-9, gap-2 apart: buttons, filter triggers and a SegmentedControl alike, so every toolbar is one height. The tier is decided by the Toolbar, never at the call site: Button at sm / default / icon-sm / icon, and SegmentedControl, carry in-data-[slot=toolbar]: classes that size them up inside one, which brings every filter trigger and icon button along. What a control opens is portalled out of the toolbar and keeps its own size, and the same controls outside a toolbar (a sidebar's filters, a card's actions) keep theirs. Small differences between neighbours read as a mistake, so there is one height; a new toolbar control follows the same pattern rather than taking a size prop.
A toolbar's search is either an always-shown box (the default) or a ToolbarSearch: a button that opens a full-width box over the toolbar, an active search kept on the button as a solid pill, and the StickyBar's bottom line turning primary while it is on. Sales uses the button on every tab (DataTable globalFilterAs="button", UrlListTable searchAs="button"); lists whose search is the main way in (patients) keep the box.
A table that fills the page (DataTable fill) pins its header under its toolbar, at the toolbar's measured height, while the table fits the width; a table wider than the page keeps its horizontal scroll instead and its header scrolls with it.
Anatomy of a row
Every row that offers a thing — a session, an appointment, a programme — is built the same way, and the eye should not have to learn two:
[ tile ] Title [ action ]
secondary line · secondary line- Tile:
h-11 w-11 rounded-full, glyph ath-5 w-5. Tinted by meaning (see below). It is the first thing in the row. - Title:
text-base font-semibold, truncated. - Meta:
text-base text-muted-foreground, facts separated by·. - Action:
size="lg", full width on mobile (w-full sm:w-auto), pinned right withml-auto. - A row inside a card is a
<div>, not a nested<Card>. A bordered box inside a bordered box is two frames around one sentence. - A record in a Clinic card (an applied offer, a payment, a content grant, a past subscription) is
RecordRow(apps/clinic/components/record-row.tsx): the same anatomy at the Clinic'stext-sm, the date or action in its own right-hand column so it never wraps under the text. Facts go in the meta line, not in badges: five badges in a row read as five equally loud things. AStatusPillbeside the title is for the one thing that is NOT the normal case (refunded, ended, failed), and is absent otherwise.
Avatars are sized with a plain size-* class and NO size prop — the kit's size="lg" compiles to data-[size=lg]:size-10, an attribute selector that outranks any class passed in and silently pins the image at 40px.
Anatomy of an empty surface
A surface with nothing on it is EmptyState (apps/portal/components/empty-state.tsx), never a sentence in a card:
( icon )
Nothing here yet
one line saying why, or what happens next
[ somewhere to go ] ← only when there IS somewhere- Icon: optional,
size-14circle,bg-primary/10 text-primary, glyph ath-6 w-6. Pick one that names the missing thing, not a warning symbol — empty is not an error. - Title:
text-lg font-semibold, one clause. If the message is two sentences, the second one is the body, not a longer title. - Body: optional,
text-base text-muted-foreground. - Action: at most one,
size="lg".
The action is the part to get right. Add it only when there is a real door out — a place the patient can go that changes the emptiness. Leave it off when the surface is empty because the clinic has not acted yet (consultations, documents, assigned sessions): a button that cannot do anything is worse than a sentence saying who to ask. Leave it off too when the page header already offers the same action — Appointments has its booking button two rows above, and a second one reads as a different action.
An empty card on a page of cards (the Clinic patient tabs) is the kit's EmptyState variant="inset" inside CardContent: no frame, the card's own header icon faded at size-9, and one short muted line as the title — no body, no action (the card header already holds the card's action). It has a minimum height, so empty cards beside full ones line up instead of collapsing to a sentence.
The component takes resolved strings, not a t prop, because a dozen different messages share this one shape. A single message shared by two pages is the other case and gets its own wrapper that owns its namespace — NoActiveProgram is the example.
Choosing a value in a form
- Two or three short options (a kind, a mode, a list's view):
SegmentedControl(packages/ui/src/components/segmented-control.tsx), the same control the Sales views use.Tabsswitch between pages and are never a form field; a pair of toggledButtons is the drift this replaces. - Several options, or long labels:
RadioGroup, one option per line. - On / off:
Switch, with a label that names the current state.
Colour vocabulary
The same four states, everywhere, in both apps:
| Meaning | Colour |
|---|---|
| Done / on track | emerald-500/15 tile, emerald-600 glyph |
| Available now / in progress | primary/10–primary/12 tile, primary glyph |
| Needs attention, not an error | amber-400/15 tile, amber-600 text |
| Locked / rest / inert | bg-muted, text-muted-foreground |
Red (destructive) is for failure and destruction only. Being behind on a programme is not a failure — it is something the patient acts on — so it is amber, and it is never a full-width coloured block.
Never take a colour from a semantic token that the app redefines. The Portal mounts its shell in sidebar-light, which sets --sidebar to white, so bg-sidebar renders a white card there. The brand navy is bg-(--rx-navy-900); see ResumeCard for the worked example.
Typography — the Portal reads differently
Portal users are largely elderly. Body copy is text-base and never smaller — including the sidebar's own rows, which the kit sets at text-sm for the staff apps and type-comfortable lifts back to 16px; text-xs/text-sm are for micro-labels only (weekday initials, eyebrows, uppercase section headings). The Clinic and Console are staff tools and keep the kit's dense default. type-comfortable on the Portal shell already handles the shared components' own defaults.
Headings (Portal)
Four levels and one label, each a component in apps/portal/components/headings.tsx. A page never styles a heading with its own size or weight: the Portal had grown about nineteen combinations before this (2026-11-04), and a patient reads the difference as noise.
| Level | Component | Look | Use |
|---|---|---|---|
| Page title | PageTitle | text-2xl font-bold | the page's one h1 |
| Section | SectionHeading | text-xl font-semibold | a section of the page, outside cards ("Săptămâna 2") |
| Card title | CardHeading, or the kit's CardTitle | text-lg font-semibold | a card's title |
| Sub-heading | SubHeading | text-base font-semibold | a band or group inside a card |
| Label | Eyebrow | text-sm uppercase, muted | a small label over a list |
as changes the element when the outline needs another level than the look; className adds layout (margin, truncation) and never a size or weight. The kit's CardTitle is held to the same look by type-comfortable, so do not pass it a text-* class. The session player and kiosk screens are read at a distance and keep their own, larger type.
Form controls are one box: Input, and the native selects inside PhoneInput, DateSelects and the form filler, all take inputVariants, so a select beside an input never differs in fill, height or type size. Each takes the same size prop, and size="comfortable" (16px text at every breakpoint — the default drops to 14px from md up) is the patient size. /welcome uses it; the Portal profile, booking flow and form filler are still on the default. Mixed sizes in one form are a bug.
Patient-facing copy is one or two short blocks. Two sentences that say the same thing are one sentence.
Interaction
- A block that navigates is a
<Link>around the whole block, withgroup,hover:bg-muted/40, andfocus-visible:ring-2 ring-primary. Do not put a button inside it that goes to the same place — that is the same affordance twice, and on a phone it costs a full-width row. - A chevron or arrow at the trailing edge says "this is pressable"; it moves
translate-x-0.5ongroup-hover. - A link repeated once per row of a list is a
RowLink(apps/clinic/components/row-link.tsx), never a bare<Link>. A bare one prefetches every row in view, and a detail route's layout makes its API calls on prefetch: fifty patient rows cost two hundred calls before a click, enough to trip the per-person rate limit.RowLinkprefetches only the row the pointer rests on. A single link (back, "view all") stays a<Link>. - Never seed
useStatefrom a server prop (P48) — that is a correctness rule, not a style one, and it is inCLAUDE.md. - A layout for a phone turned sideways uses
phone-landscape:(landscape and at most 500px tall, defined inpackages/uiglobals), never barelandscape:— that also matches every desktop window, which has the height a phone lacks. Barelandscape:is right only where any wide screen should change, like the session player's overlaid chrome.
Dates and times
Dates render through next-intl's formatter, which the Portal pins to the CLINIC's timezone (i18n/request.ts). A raw toLocaleDateString() in a server component renders in the container's UTC and shows the wrong day for three hours every evening. An appointment time uses <AppointmentTime>, which names the zone and adds the reader's own when they differ — the clinic serves a large diaspora and a bare "14:00" is unreadable from Madrid.