Skip to main content

Responsive Design — Mobile UI Conventions

The app was originally desktop-only; it was made responsive in July 2026 (see the executed plan in TODO/mobile-ui-improvements.md). This page captures the conventions every new component must follow so the UI stays responsive. Read this before writing any new UI component or touching layout code.

Breakpoint convention

MUI's default breakpoints are used — do not customize theme.breakpoints in src/themes/createAppTheme.ts. Two of them carry project-wide meaning:

BreakpointValueRole
md900pxLayout switch — drawer variant, footer span, shell margins. Portrait tablets (768px) deliberately get the mobile shell: a 300px permanent drawer in 768px of width is worse.
sm600pxDialog fullScreen switch — only true phones get forced-fullscreen dialogs; tablets keep windowed dialogs.

The shared hook useIsMobile(bp: "sm" | "md" = "md") (src/lib/hooks/useIsMobile.ts, exported from the hooks barrel) wraps useMediaQuery(theme.breakpoints.down(bp)) — see the hooks docs.

The SSR rule (the most important rule on this page)

Server rendering cannot know the viewport, so useIsMobile returns false (desktop) on the server and only becomes correct after hydration. Therefore:

  • Styling change only → use CSS breakpoint values in sx/styled: { xs: ..., md: ... } or [theme.breakpoints.down("md")]: {...}. These compile to media queries, so the very first server-rendered paint is already correct. This must always be the default choice.
  • Component structure or behavior must change → use useIsMobile. In the entire app this is intentionally confined to two patterns: the drawer variant and dialog fullScreen. Think hard before adding a third.
  • Where the hook's wrong first frame would be visible, guard with CSS (e.g. the permanent drawer carries display: { xs: "none", md: "block" } so CSS hides it before JS runs). Do not reach for useMediaQuery's noSsr option.

Shell patterns

Responsive drawer (src/lib/ui/MainPage/MiniDrawer.tsx)

  • Below md: a variant="temporary" overlay drawer, paper width min(DRAWERWIDTH_OPENED, 85vw), ModalProps={{ keepMounted: true }} (preserves DrawerList's nuqs-synced filter state), always rendered expanded.
  • From md up: the original permanent mini-variant, untouched.
  • DrawerContext is unchanged: open means "overlay visible" on mobile and "expanded" on desktop. The hamburger stays visible below md even while the drawer is open.
  • Every click inside the drawer that navigates must close the drawer on mobile (if (isMobile) setOpen(false)) — temporary drawers close on navigation, permanent ones never do. See DrawerList.tsx and ModeSwitcher.tsx for the pattern.

Dialogs: fullScreen on phones

Every dialog follows:

const isMobile = useIsMobile("sm");
const fullScreen = isFullScreen || isMobile; // isFullScreen = manual toggle
<Dialog fullScreen={fullScreen} ... />

and the manual fullscreen toggle button is CSS-hidden below sm (display: { xs: "none", sm: "inline-flex" }) — it is meaningless when fullscreen is forced. The shared toggle lives in TemplateEditorDialogTitle.tsx; a few dialogs have inline toggles with the same CSS. Paper maxHeight/minHeight styles must key off the combined fullScreen value, not isFullScreen alone.

Menus/popovers cannot go fullscreen — cap their paper instead: maxWidth: { xs: "calc(100vw - 32px)", sm: <desktop value> }.

Variant for content-heavy dialogs (ActivityDetails): also force fullscreen on short viewports — useMediaQuery("(max-height: 599.95px)") OR the sm width check — so phones held in landscape (wide but ~400px tall) get fullscreen too, while tablets stay windowed (both iPad dimensions are ≥ 600px).

Grids and widths

  • Grid items use responsive size objects: size={{ xs: 12, sm: 6, md: <desktop cols> }}.
  • Never hard-code widths without a breakpoint escape: width: { xs: "100%", md: <desktop value> }, minWidth: { xs: 0, sm: <desktop value> }. Viewport percentages count as hard-coded too: width: "30%" on a dialog paper is ~430px on desktop but 117px on a phone — it clipped both buttons of ConfirmDialog behind an internal scrollbar. Scope such proportions to { md: "30%" } so phones fall back to MUI's standard fullWidth/maxWidth dialog sizing.
  • Flex children that must not force horizontal overflow need minWidth: 0 (this — not overflow — is what stops flex-child overflow; see <main> in src/app/[locale]/(app)/layout.tsx).

Forms: the table-to-stack recipe

The edit forms are HTML <Table> layouts. They reflow below md with CSS display overrides only, keeping desktop rendering pixel-identical (every md-and-up value reproduces the original declaration). The canonical example is src/lib/forms/organizationForm/FieldRow.tsx:

  • Row: display: { xs: "flex", md: "table-row" }, flexWrap: { xs: "wrap", md: "nowrap" } — or xs: "block" when plain stacking is enough.
  • Cells: display: { xs: "block", md: "table-cell" }, width: { xs: "100%" | "auto", md: "<original %>" }.
  • A cell that should drop to its own full-width line: flexBasis: { xs: "100%" } (+ order to reposition it).
  • Spacer/empty cells: display: { xs: "none", md: "table-cell" }.
  • Table wrappers (Table, TableBody, …) become display: { xs: "block", md: "table" / "table-row-group" / … } so the overridden rows lay out predictably.
  • Borders drawn per-cell must move to the row at xs, otherwise wrapped cells draw partial border fragments.
  • Touch targets in reflowed rows need ≥ 44px on phones: p: { xs: 1.25, md: 0 } on compact IconButtons (desktop unchanged).

Beware one CSS-specificity trap: a blanket "& td": { width: ... } rule on the Table outranks each cell's own sx class at every breakpoint — set responsive widths per cell, and use table-level blankets only for properties the cells don't set themselves.

Buttons

ButtonStack (src/lib/ui/buttongroups/ButtonStack.tsx) — which nearly every button row funnels through — wraps by default (flexWrap: "wrap" + useFlexGap), so rows flow onto extra lines instead of overflowing on narrow viewports. It resets the defaultSxProps left margin (ml: 0) to keep spacing gap-controlled; pass margins via buttonSx/item sx if genuinely needed, and stackProps={{ flexWrap: "nowrap" }} to opt out. The shared button default width is { xs: "100%", sm: "150px" } — new fixed button sizes must follow the same shape.

Gotcha — width: "100%" needs a definite parent width. A flex child with width: "100%" (as defaultSxProps gives every shared button on xs) resolves against its parent's content-box width. If the parent is itself content-sized — e.g. a ButtonStack inside a centered, shrink-to-fit CardActions — that width is indefinite, the 100% collapses, and the buttons shrink (they fell to ~150px in twoButtonToast, clipping longer labels on phones). Fix: give the intermediate container a definite width (sx={{ width: "100%" }} on the stack), so the buttons' 100% has something to resolve against and they go genuinely full-width. For rows that must stack on phones (like the toast's two action buttons), don't rely on flexWrap + width resolution producing the stack as a side effect — set it explicitly: flexDirection: { xs: "column", sm: "row" } on the stack sx makes side-by-side layout structurally impossible below sm. This was originally mis-diagnosed as an MUI breakpoint-object resolution problem in the toast portal; a same-build A/B test showed breakpoint objects compile to correct media queries there just fine — the real cause was the missing definite width.

Gotcha — react-hot-toast flex-shrinks custom toast content. <Toaster> renders each toast as a flex item inside a container already fixed at 100vw - 32px (hardcoded 16px inset), so any wider width you declare on toast content is silently compressed back down by the default flex-shrink — invisibly: getComputedStyle(el).width still reports your declared value while the layout box doesn't honor it. Treat calc(100vw - 32px) as the effective maximum toast width (as twoButtonToast does), or set flexShrink: 0 on the content root if exceeding it is ever truly necessary.

Verification

  • playwright-tests/mobile-ui.spec.ts runs on three projects — desktop Chrome, iPhone 14 (390px) and iPad Mini (768px) — asserting no horizontal overflow, the drawer behavior per §Shell patterns, and dialog fullScreen only below sm (see tier-5 E2E docs). Run a specific tenant with PW_BASE_URL=http://demo.localhost:3000.
  • For one-off checks, plain setViewportSize is not full phone emulation — spread a device descriptor instead (test.use({ ...devices["Pixel 7"] })): it adds isMobile (viewport meta honored), devicePixelRatio, touch, and the mobile user agent.
  • src/lib/hooks/useIsMobile.test.tsx unit-tests the breakpoint behavior at 375/768/1280px.
  • Adding a vi.mock("@/lib/hooks", ...) factory to a component test? It must include useIsMobile: () => false or every hook consumer in the file fails.