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:
| Breakpoint | Value | Role |
|---|---|---|
md | 900px | Layout 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. |
sm | 600px | Dialog 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 dialogfullScreen. 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 foruseMediaQuery'snoSsroption.
Shell patterns
Responsive drawer (src/lib/ui/MainPage/MiniDrawer.tsx)
- Below
md: avariant="temporary"overlay drawer, paper widthmin(DRAWERWIDTH_OPENED, 85vw),ModalProps={{ keepMounted: true }}(preservesDrawerList's nuqs-synced filter state), always rendered expanded. - From
mdup: the original permanent mini-variant, untouched. DrawerContextis unchanged:openmeans "overlay visible" on mobile and "expanded" on desktop. The hamburger stays visible belowmdeven 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. SeeDrawerList.tsxandModeSwitcher.tsxfor 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
sizeobjects: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 ofConfirmDialogbehind an internal scrollbar. Scope such proportions to{ md: "30%" }so phones fall back to MUI's standardfullWidth/maxWidthdialog sizing. - Flex children that must not force horizontal overflow need
minWidth: 0(this — notoverflow— is what stops flex-child overflow; see<main>insrc/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" }— orxs: "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%" }(+orderto reposition it). - Spacer/empty cells:
display: { xs: "none", md: "table-cell" }. - Table wrappers (
Table,TableBody, …) becomedisplay: { 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.tsruns on three projects — desktop Chrome, iPhone 14 (390px) and iPad Mini (768px) — asserting no horizontal overflow, the drawer behavior per §Shell patterns, and dialogfullScreenonly belowsm(see tier-5 E2E docs). Run a specific tenant withPW_BASE_URL=http://demo.localhost:3000.- For one-off checks, plain
setViewportSizeis not full phone emulation — spread a device descriptor instead (test.use({ ...devices["Pixel 7"] })): it addsisMobile(viewport meta honored), devicePixelRatio, touch, and the mobile user agent. src/lib/hooks/useIsMobile.test.tsxunit-tests the breakpoint behavior at 375/768/1280px.- Adding a
vi.mock("@/lib/hooks", ...)factory to a component test? It must includeuseIsMobile: () => falseor every hook consumer in the file fails.