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.
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.
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.
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.
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. - Never seed
useStatefrom a server prop (P48) — that is a correctness rule, not a style one, and it is inCLAUDE.md.
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.