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, nobackgroundImage. 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 tosize="small"andvariant="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 tosize="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. Usecolorto convey status (success/warning/ default for neutral); usevariant="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:
- Container —
Container maxWidth="xs"by default (~444px); passmaxWidth="sm"for slightly wider forms. - Card — the standard hairline-border,
elevation=0paper. The form lives insideCardContent. - Title —
h4, sits at the top of the card. Optionalsubtitleunderneath in muted body 2. - Error slot — when
erroris set, an MUIAlert severity="error"renders above the fields. Don't render alerts manually insidechildren. - Children — the form fields, in order.
FormCardwraps them in aStack spacing={2}; each child becomes a row. Don't put the submit button here — use thesubmitslot. - 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:
- Extra-wide gap before the submit. Fields are siblings of each other (16px); the submit is a separate block — the action, not another input.
FormCardseparates 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. - 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 passsize="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
Carddirectly 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):
- Title —
h5(16px, weight 600). Section labels, not page titles. Don't useh6here — that's the uppercase eyebrow style and it reads as metadata, not a heading. - Description — optional
body2muted. 4px below the title (cluster tier) so it reads as part of the title. - Fields stack —
Stack 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:
- Tabs row (
MuiTabs, primary indicator) inside the card, followed by a full-widthDivider. 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. - Tab panel — a
CardContentwithp: 3, containing aStackofFormSectioncomponents separated by<Divider flexItem />. UseStack spacing={4}(32px) so each divider has 16px of breathing room above and below — sections need a clearer break than fields do. - Action bar —
Divider+ a horizontalStack(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. Paddingp: 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 trailingIconButtoncarryingDeleteOutlineIcon. 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
Buttonwith<AddIcon />startIcon and label like "Add phone" / "Add address", placed at the bottom of the list. Usevariant="text"orsize="small"outlined — never contained, since adding is not the primary action. - Empty state: a single
body2muted 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. Dividerbetween header and table, and between table andTablePagination.TablePaginationwithrowsPerPageOptions={[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.