Skip to content

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:

ScopeHome
Used by two or more appspackages/ui/src/components/
Used by two or more pages of one appapps/{app}/components/
Used oncebeside 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 at h-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 with ml-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's text-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. A StatusPill beside 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-14 circle, bg-primary/10 text-primary, glyph at h-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. Tabs switch between pages and are never a form field; a pair of toggled Buttons 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:

MeaningColour
Done / on trackemerald-500/15 tile, emerald-600 glyph
Available now / in progressprimary/10–primary/12 tile, primary glyph
Needs attention, not an erroramber-400/15 tile, amber-600 text
Locked / rest / inertbg-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.

LevelComponentLookUse
Page titlePageTitletext-2xl font-boldthe page's one h1
SectionSectionHeadingtext-xl font-semibolda section of the page, outside cards ("Săptămâna 2")
Card titleCardHeading, or the kit's CardTitletext-lg font-semibolda card's title
Sub-headingSubHeadingtext-base font-semibolda band or group inside a card
LabelEyebrowtext-sm uppercase, muteda 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, with group, hover:bg-muted/40, and focus-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.5 on group-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. RowLink prefetches only the row the pointer rests on. A single link (back, "view all") stays a <Link>.
  • Never seed useState from a server prop (P48) — that is a correctness rule, not a style one, and it is in CLAUDE.md.
  • A layout for a phone turned sideways uses phone-landscape: (landscape and at most 500px tall, defined in packages/ui globals), never bare landscape: — that also matches every desktop window, which has the height a phone lacks. Bare landscape: 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.