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.

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.

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:

MeaningColour
Done / on trackemerald-500/15 tile, emerald-600 glyph
Available now / in progressprimary/10primary/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.

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.
  • Never seed useState from a server prop (P48) — that is a correctness rule, not a style one, and it is in CLAUDE.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.