Skip to content

Components

These are the live MUI overrides we've committed to (see frontend/src/theme.ts). Anything not listed uses MUI defaults under our palette.

Surfaces

  • Paper / Card: elevation=0, 1px border, no backgroundImage. Always rely on the border for separation, never a shadow.
  • AppBar: elevation=0, white surface, hairline bottom border. Treated as a top divider, not a floating bar.

Actions & inputs

  • Button: disableElevation, sentence-case label, 6px radius. Variants:
  • contained — primary action, teal fill.
  • outlined — secondary action.
  • text — tertiary action (cancel, "more options").
  • color="secondary" (= ink) — high-contrast non-primary action; use sparingly.
  • color="error" outlined — destructive actions.
  • TextField: defaults to size="small" and variant="outlined". Don't change per-field unless there's a real reason.
  • Switch / Checkbox: inherit primary teal for the "on" state; no custom styling.

Data display

  • Table: defaults to size="small". Header cells render in the alt-surface fill, uppercase, 12px, weight 600, muted ink. Body cells use the standard border for row dividers.
  • Chip: 4px radius, weight 500. Use color to convey status (success / warning / default for neutral); use variant="outlined" for taxonomy/tag chips to distinguish them from status.

Feedback

  • Alert: stick to the four MUI severities (success / info / warning / error); the muted semantic colors are tuned for these. Don't custom-style alerts.

FormCard — focused single-purpose forms

For narrow, single-purpose forms (sign-in, sign-up, password reset, single-entity edit screens), wrap the form in FormCard. It owns the centered layout, the card frame, the title/subtitle, an error slot, and a footer slot — the page owns the <form> element, the fields, and the submit logic. We deliberately don't abstract the fields, because every form has its own validation and copy.

Anatomy:

  • ContainerContainer maxWidth="xs" by default (~444px); pass maxWidth="sm" for slightly wider forms.
  • Card — the standard hairline-border, elevation=0 paper. The form lives inside CardContent.
  • Titleh4, sits at the top of the card. Optional subtitle underneath in muted body 2.
  • Error slot — when error is set, an MUI Alert severity="error" renders above the fields. Don't render alerts manually inside children.
  • Children — the form fields, in order. FormCard wraps them in a Stack spacing={2}; each child becomes a row. Don't put the submit button here — use the submit slot.
  • Submit slot — the primary action button, rendered with extra top spacing (mt: 1, so the gap is wider than between fields) and forced to full width. This is intentional, not stylistic — see "Visual weight" below.
  • Footer slot — meta-navigation (e.g. "Already have an account?") rendered below the card, not inside it. The footer is not part of the form action; keeping it outside reinforces that.

Visual weight — why the submit slot exists

A naïve auth form puts a size="large" contained button in the same Stack as outlined inputs at default spacing. The button ends up chromatically louder than its siblings, and at the same gap as field-to-field it reads as just another row in the list rather than the action. Two layered fixes counter that, and FormCard enforces both:

  1. Extra-wide gap before the submit. Fields are siblings of each other (16px); the submit is a separate block — the action, not another input. FormCard separates the field stack from the submit by 32px (24px block tier + 8px breathing room), versus 16px between fields. The wider gap signals "you've finished filling, now act" instead of letting the button compete as the heaviest sibling in a uniform stack. We don't enlarge the button — we move it apart.
  2. Full-width submit on narrow cards. The submit slot forces width: 100%. A button that spans the field width stops being a small-but-heavy object floating in the form and integrates with the field stack as a horizontal action zone. Don't pass size="large" on the submit — full-width at default size is already optically prominent.

We deliberately keep TextFields at the project default (size="small"). The base type is 15px; a medium-size field carries 56px of chrome around two lines of label/value and feels oversized. Density and the spacing tiers do the work that field height would otherwise have to.

The complete spacing inside a FormCard, top to bottom: 4px between title and subtitle (cluster), 24px from the title block to the error/fields, 16px between fields (sibling), 32px from the last field to the submit (block + extra), 24px interior padding around all of it (matches the block tier so the gutter and the largest interior gap share one rhythm). Footer sits 16px below the card — close enough to read as belonging to it, separate enough to feel meta.

Why a card and not a plain page? Cards give the form an obvious focal boundary on the cool-gray page background, and visually separate the form action from the meta footer. They also keep narrow forms from feeling like full-width walls of input on wide screens.

Live example: /design (FormCard section). Used in production by SignInPage, SignUpPage, and VerifyPage.

When not to use FormCard:

  • Multi-section settings or "edit entity" pages where the form is one of several panels — those use Card directly inside the page layout.
  • Inline forms inside a table row or a modal — those have their own patterns.

FormSection — a section inside a larger form

A reusable building block for multi-section editors (patient, room, schedule template). Pairs a section title with optional descriptive copy and a stack of fields. See frontend/src/components/FormSection.tsx.

Anatomy (top to bottom):

  • Titleh5 (16px, weight 600). Section labels, not page titles. Don't use h6 here — that's the uppercase eyebrow style and it reads as metadata, not a heading.
  • Description — optional body2 muted. 4px below the title (cluster tier) so it reads as part of the title.
  • Fields stackStack spacing={2} (16px sibling tier) with the section's content. 24px below the title block (block tier).

FormSection is purely a layout primitive — it doesn't render dividers, padding, or borders. The parent decides how sections relate to each other.

EntityForm — multi-section editor with tabs

For editing complex entities (a patient, a room, a schedule), use a single card with these slots, top to bottom:

  1. Tabs row (MuiTabs, primary indicator) inside the card, followed by a full-width Divider. Tabs split the entity by facet, not by step — they're not a wizard. Only the active tab's content is mounted; an unimplemented tab can render a centered muted message instead of a panel.
  2. Tab panel — a CardContent with p: 3, containing a Stack of FormSection components separated by <Divider flexItem />. Use Stack spacing={4} (32px) so each divider has 16px of breathing room above and below — sections need a clearer break than fields do.
  3. Action barDivider + a horizontal Stack (right-aligned) with the cancel button (variant="text") and the save button (variant="contained"). Lives inside the card so it stays attached to the form even on tall pages. Padding p: 2 (16px) — the action zone is tighter than the content zone.

Why a single card with sections instead of one card per section:

  • The entity is one thing. Splitting it into separate cards visually fragments it and forces the eye to re-orient at every section boundary.
  • Tabs above the card would float without an obvious owner; tabs above stacked cards would look like they apply to all of them.
  • The action bar saves the entity, not a section. One card, one action bar.

Use stacked cards when the page contains unrelated groups (e.g. a settings page with billing, profile, and notifications) — different concerns, different cards.

Repeatable field groups

For collections (phone numbers, addresses, emergency contacts) inside a FormSection:

  • Single-line items (e.g. a phone number with a label dropdown): one Stack direction="row" per item with the inputs and a trailing IconButton carrying DeleteOutlineIcon. Items spaced at 12px (spacing={1.5}) — slightly tighter than the sibling tier because they read as one list rather than separate fields.
  • Multi-field groups (e.g. an address with line 1, line 2, city, postal code, country): wrap each item in a Paper variant="outlined" filled with the page background (bgcolor: 'background.default'). The inset surface signals "this is a sub-record, not a top-level field" without nesting cards.
  • Add control: a small Button with <AddIcon /> startIcon and label like "Add phone" / "Add address", placed at the bottom of the list. Use variant="text" or size="small" outlined — never contained, since adding is not the primary action.
  • Empty state: a single body2 muted line ("No phone numbers on file.") above the Add button. Don't hide the Add button when the list is empty.

Live example with all of the above: /design/patient.

Paginated table — the workhorse

Most data views are paginated tables. The pattern committed to in /design is the canonical shape:

  • Wrap the table in a Paper.
  • Header row inside the paper: title (h5) on the left, single text filter on the right.
  • Divider between header and table, and between table and TablePagination.
  • TablePagination with rowsPerPageOptions={[5, 10, 25]} and reset to page 0 whenever the filter changes.
  • Status as a Chip (small, semantic color); monospaced font for codes (room IDs, etc.).
  • Empty state: a single centered row inside TableBody — never an empty card.

When a screen needs richer filtering than a single text box, layer it above the table rather than redesigning the table itself.