Skip to main content

Mobile UI Improvements — Step-by-Step Guide

Status: Implemented 2026-07-18 — pending only the human parts of the 7.5 manual checklist (real device, RTL, signed-in edit forms at 375px) · Author: AI Review · Date: 2026-07-15

Step-by-step guide to make the app usable on phones and tablets. An audit (2026-07-15) found the UI is effectively desktop-only: there are zero uses of useMediaQuery or theme.breakpoints anywhere in src/, and the entire responsive footprint is two label-hiding sx values (MainNavBar.tsx:115, LanguagePicker.tsx:135). Every item below was verified against the current codebase (file references inline).

Headline offenders:

  • The sidebar is a permanent MUI drawer with fixed pixel widths (src/lib/ui/MainPage/MiniDrawer.tsx, widths 300/70 from src/constants/constants.ts:84-85) — on a 375px phone the open drawer eats 80% of the screen and never overlays.
  • Fixed shell metrics: layout margins my: 10, mx: 5 (src/app/[locale]/(app)/layout.tsx:170), a 350px search box (MainNavBar.tsx:127), footer actions minWidth: 200 × 3 (Footer.tsx:51,57,63).
  • Card grids render 3–4 columns at every width (src/lib/ui/grids/ActivitiesGrid.tsx:55).
  • Forms are HTML <Table> layouts with fixed % cells (7/86/7 via src/lib/forms/organizationForm/FieldRow.tsx:50,58,80); one dialog hard-codes minWidth: 850 (organizationContactForm/OrganizationContactForm.tsx:364).
  • Dialog fullScreen exists only as a manual toggle button, never driven by viewport size.

Scope decisions (agreed 2026-07-15): viewers first — shell, dashboard and read-only views before edit surfaces; forms get a full responsive rewrite, done incrementally after the viewer-facing phases (with a scroll-container quick fix as interim fallback); Playwright mobile-viewport tests are in scope.

Sections are ordered for execution and each section is independently shippable. Do them in order; re-run the section 7 checks after each one.


0. Ground rules (read before touching code)

  • 0.1 Follow the repo AGENTS.md doc requirements (read 2026-07-18; applies to every section) — before every MUI edit, consult the mui-mcp MCP server (get_component_info, search_components, get_customization_guide) — especially for Drawer (temporary variant, ModalProps), useMediaQuery, Dialog (fullScreen), Grid (responsive size) and breakpoint syntax in sx; MUI v9 is newer than model training data. Read Next.js docs in node_modules/next/dist/docs/ for anything touching layouts or client/server boundaries; fetch next-intl docs (https://next-intl.dev/docs) if adding i18n strings.
  • 0.2 Apply the SSR rule everywhere (read 2026-07-18; applies to every section) — use CSS breakpoint values in sx/styled ({ xs: ..., md: ... }, theme.breakpoints.down("md")) wherever only styling changes; these render as media queries on the server, so the first paint is correct with zero hydration flash. Use the JS useIsMobile hook (section 1) only where the component structure or behavior must change: the drawer variant and dialog fullScreen. This confines hydration-mismatch risk to two well-understood spots.
  • 0.3 Breakpoint convention (confirmed 2026-07-18: no breakpoints customization exists in src/themes/) — md (900px, MUI default) is the layout switch (drawer, footer, margins): portrait tablets get the mobile shell, which beats a 300px drawer in 768px of width. sm (600px) is the dialog-fullScreen switch: only true phones get forced-fullscreen dialogs. Keep MUI's default breakpoints — do not customize theme.breakpoints in src/themes/createAppTheme.ts; there is no need and it would churn every future sx.

1. Foundation: shared useIsMobile hook

  • 1.1 Create src/lib/hooks/useIsMobile.ts (done 2026-07-18; v9 useMediaQuery signature verified via mui-mcp — unchanged, server default matches: false) — a "use client" hook wrapping MUI's useMediaQuery:

    "use client";
    import { useTheme, useMediaQuery } from "@mui/material";

    export const useIsMobile = (bp: "sm" | "md" = "md"): boolean => {
    const theme = useTheme();
    return useMediaQuery(theme.breakpoints.down(bp));
    };

    Verify the exact v9 useMediaQuery signature/SSR options against mui-mcp first. Server render returns false (desktop) until hydration — accept that; do not fight it with noSsr unless a flash is actually visible in testing (section 2 has a CSS guard for the one place it matters).

  • 1.2 Export from the hooks barrel (done 2026-07-18) — add to src/lib/hooks/index.ts following the existing pattern (useDrawer etc.; see hooks docs).

  • 1.3 Do NOT build a shared ResponsiveDialog wrapper (acknowledged 2026-07-18 — nothing built) — the dialogs live inside 700–900-line files with varied props and an existing manual fullscreen toggle; migrating them to a wrapper is high churn for no gain. The per-dialog change in section 4 is a one-liner. Revisit only if a later redesign consolidates dialogs.

2. App shell (highest user impact)

Files: src/lib/ui/MainPage/MiniDrawer.tsx, MainNavBar.tsx, Footer.tsx, DrawerList.tsx, src/app/[locale]/(app)/layout.tsx. src/constants/constants.ts widths stay as they are.

  • 2.1 MiniDrawer: temporary variant below md (done 2026-07-18; verified at 390px — modal drawer, 300px paper, backdrop) — the standard MUI responsive-drawer pattern. In MiniDrawer.tsx: const isMobile = useIsMobile("md");. When mobile, render a plain <MuiDrawer variant="temporary" open={open} onClose={() => setOpen(false)} ModalProps={{ keepMounted: true }}> with paper width min(${DRAWERWIDTH_OPENED}px, 85vw), containing the same DrawerHeader + DrawerList open + DrawerDocumentation open children. When desktop, keep the existing styled permanent mini-variant untouched. keepMounted preserves DrawerList's nuqs-synced filter state and speeds up opening.
  • 2.2 Hydration-flash guard (done 2026-07-18) — the server renders the permanent drawer (hook defaults to desktop); on a phone that shows a 70px sliver for one frame. Hide the permanent drawer with pure CSS — display: { xs: "none", md: "block" } on its root — so CSS hides it before JS runs.
  • 2.3 AppBar unshift (CSS-only) (done 2026-07-18) — in the existing styled(MuiAppBar) (MiniDrawer.tsx:54-70), inside the open block add [theme.breakpoints.down("md")]: { marginLeft: 0, width: "100%" } so the AppBar stops shifting by the drawer width on mobile.
  • 2.4 DrawerContext stays as-is (done 2026-07-18; hamburger kept visible below md while open) — no API change to src/lib/hooks/DrawerContext.tsx: open means "overlay visible" on mobile and "expanded" on desktop. The hamburger in MainNavBar.tsx:71-83 already calls setOpen; keep the hamburger visible below md regardless of open state.
  • 2.5 Close on navigate (mobile only) (done 2026-07-18; applied to EditOrg link, role/OU/showInactive filter navigations, and ModeSwitcher; nuqs URL sync regression-checked — ?ou= updates and drawer closes) — in src/lib/ui/MainPage/DrawerList.tsx, call setOpen(false) when a navigation item is clicked, only when mobile: temporary drawers must close on navigation, permanent ones must not. Regression-test the nuqs URL sync of the role/OU filters afterwards.
  • 2.6 Layout margins + flex overflow fix (done 2026-07-18; <main> is now <Box component="main"> with flexGrow: 1, minWidth: 0, pb: FOOTER_HEIGHT) — in src/app/[locale]/(app)/layout.tsx:170 change to sx={{ display: "flex", my: { xs: 4, md: 10 }, mx: { xs: 2, md: 5 } }} and give <main id="main-content"> flexGrow: 1, minWidth: 0 (minWidth: 0 is what actually stops flex-child horizontal overflow) plus bottom padding clearing the fixed footer.
  • 2.7 MainNavBar (done 2026-07-18; hamburger mr also made responsive; OrganizationAvatar left visible — revisit if 375px crowds) — search box width: 350 (MainNavBar.tsx:127) → width: { xs: "100%", sm: 300, md: 350 }, flex: { xs: 1, sm: "none" }; right-hand stack mr: 10mr: { xs: 0, md: 10 }. Only if the toolbar still crowds at 375px, hide OrganizationAvatar at xs.
  • 2.8 Footer (done 2026-07-18; verified full-width at 390px; SignUpBanner audited — no fixed widths, no change needed) — all CSS-only in Footer.tsx: left: open ? DRAWERWIDTH_OPENED : DRAWERWIDTH_CLOSEDleft: { xs: 0, md: open ? DRAWERWIDTH_OPENED : DRAWERWIDTH_CLOSED }; the three BottomNavigationActions minWidth: 200minWidth: { xs: 0, md: 200 }; Divider mx: 25mx: { xs: 2, md: 25 }. Check SignUpBanner.tsx for fixed widths while in there. (Decision default: footer stays fixed on mobile but shrunk; switch to position: static below md only if it proves to collide with browser chrome/safe areas on real devices.)

3. Dashboard & card grids (CSS-only)

Files: src/lib/ui/grids/ActivitiesGrid.tsx, ContractsGrid.tsx, PartnersGrid.tsx, OrganizationSettingsGrid.tsx (see grids docs).

  • 3.1 Responsive grid sizes (done 2026-07-18; v9 size object syntax verified via mui-mcp) — size={activityCount > 3 ? 3 : 4} (ActivitiesGrid.tsx:55) → size={{ xs: 12, sm: 6, md: activityCount > 3 ? 3 : 4 }}; same pattern at ContractsGrid.tsx:146 and in PartnersGrid.tsx / OrganizationSettingsGrid.tsx. Verify the MUI v9 Grid size object syntax via mui-mcp first.
  • 3.2 Responsive widths and spacing (done 2026-07-18) — the count-based width tricks (75%/60%/100% at ActivitiesGrid.tsx:35,50,74-82) → width: { xs: "100%", md: <existing expression> }; spacing={8}spacing={{ xs: 3, md: 8 }}.
  • 3.3 Card internals (audited 2026-07-18: no fixed widths in src/lib/ui/cards; OrgSettingsCaption minWidth: 200 buttons fit a full-width xs card — no change) — once cards go full-width at xs, check src/lib/ui/cards/ActivityCaption.tsx (and the other cards) for fixed widths that stretch or overflow.
  • 3.4 Dashboard action buttons (done 2026-07-18 — entirely inside ActivitiesGridButtons via stackProps.sx.flexWrap + responsive buttonSx; ButtonStack itself untouched, the parked 6.5/Appendix A patch remains undeployed) — src/lib/ui/buttongroups/ActivitiesGridButtons.tsx:16 renders up to four buttons of width: 200, height: 80 in a single ButtonStack row below the grid — an 800px+ row at 375px. Make the buttons responsive (width: { xs: "100%", sm: 200 }, smaller height at xs) and stack the row vertically or wrap it on mobile (see 6.5 for the ButtonStack change this builds on).

4. Dialogs: fullScreen on mobile (cheap; do before the form rewrite)

This makes the still-unconverted forms usable on phones immediately. In each file: const isMobile = useIsMobile("sm"); then fullScreen={isFullScreen || isMobile}; keep the manual fullscreen toggle button but hide it when isMobile (it is meaningless when fullscreen is forced).

  • 4.1 src/lib/ui/grids/ContractsGrid.tsx:161 (done 2026-07-18)
  • 4.2 src/lib/ui/grids/PartnersGrid.tsx:157 (done 2026-07-18)
  • 4.3 src/lib/ui/grids/OrganizationSettingsGrid.tsx:337 (done 2026-07-18)
  • 4.4 src/lib/ui/modals/TemplateValidation.tsx:101 (done 2026-07-18)
  • 4.5 src/lib/ui/modals/OrgLanguages.tsx:284 (done 2026-07-18)
  • 4.6 src/lib/ui/modals/DefaultActivityAttributes.tsx:118 (done 2026-07-18)
  • 4.7 src/lib/ui/modals/TemplateEditor.tsx:704 — Monaco in mobile fullscreen is functional-but-cramped; that is accepted scope ("desktop-recommended"), strictly better than overflow. (done 2026-07-18)
  • 4.8 src/lib/ui/modals/ActivityDetails.tsx (done 2026-07-18; no toggle existed — fullScreen={isMobile} directly; verified live at 390px/1440px)
  • 4.9 src/lib/forms/organizationAddressForm/OrganizationAddressFormPopup.tsx:98 — also relax the maxWidth: 600. (done 2026-07-18; note: this is a Menu, not a Dialog — no fullScreen exists, so only the width was relaxed: maxWidth: { xs: "calc(100vw - 32px)", sm: 600 })
  • 4.10 Interim overflow guard for the contact form — in src/lib/forms/organizationContactForm/OrganizationContactForm.tsx:364 change minWidth: 850minWidth: { xs: 0, md: 850 } and give the TableContainer (line 389) overflowX: "auto", so the form scrolls horizontally until section 5 reflows it properly. (done 2026-07-18)
  • 4.11 Sweep for fixed dialog paper widths (done 2026-07-18) — no fixed slotProps.paper/PaperProps dialog widths remained; ActivityPicker.tsx:265 and OrganizationPicker.tsx:134 are Menu papers at minWidth: 300 which fit 375px (popovers self-constrain) — left as-is. The sweep also found three dialogs with the same manual-toggle pattern missing from the list above; all three got the full 4.x treatment: TemplatesCaption.tsx, ActivityPublicationMethods.tsx, OrganizationImportValidation.tsx. The shared toggle in TemplateEditorDialogTitle.tsx and the three inline toggles are CSS-hidden below sm (display: { xs: "none", sm: "inline-flex" }).

5. Forms: full responsive rewrite (incremental, one form per commit)

Strategy: convert the table layouts to stack below md via CSS display overrides, keeping desktop rendering pixel-identical. Never rewrite the 800–1000-line files wholesale in one pass — each form ships independently, and section 4's fullscreen+scroll remains the fallback for not-yet-converted forms.

  • 5.1 Prove the pattern on FieldRow.tsx (done 2026-07-18; at xs the border moves from the cells to the row so wrapped cells don't draw partial lines; edit cell gets ml: "auto", value cell order: 1 + flexBasis: 100%) — src/lib/forms/organizationForm/FieldRow.tsx (99 lines, three cells: icon 7% / value 86% / edit 7%, lines 50/58/80) is shared by PartnerForm.tsx (1004 lines), so this single file reflows every PartnerForm row. Pattern: on the TableRow display: { xs: "flex", md: "table-row" }, flexWrap: { xs: "wrap" }; on the cells display: { xs: "block", md: "table-cell" }, width: { xs: "auto", md: "<existing %>" } with the value cell getting flexBasis: { xs: "100%" } (icon + edit button inline, value full-width below). Watch MUI Table border/semantics when overriding display; screenshot-diff desktop before propagating.
  • 5.2 src/lib/forms/vatForm/VatForm.tsx (done 2026-07-18; country picker and VAT input stack at xs, the "—" separator cell is hidden, table wrappers become blocks)
  • 5.3 src/lib/forms/textAttributeForm/TextAttributeForm.tsx (done 2026-07-18; also relaxed the Menu paper width: "50%"{ xs: "calc(100vw - 32px)", md: "50%" } — the real phone blocker)
  • 5.4 src/lib/forms/ouForm/OuForm.tsx (done 2026-07-18; header stays a single colored flex bar with the color picker right-aligned; language rows live in src/lib/ui/AttributeRow/LanguageRows.tsx, reflowed there)
  • 5.5 src/lib/forms/organizationAddressForm/OrganizationAddressForm.tsx (done 2026-07-18; the icon-column table already fits phones — only the two side-by-side field pairs needed flexWrap at xs)
  • 5.6 src/lib/forms/contractForm/ContractForm.tsx (audited 2026-07-18: no tables, already a single flex column of fullWidth fields inside the parent dialog — no change needed)
  • 5.7 src/lib/forms/organizationContactForm/OrganizationContactForm.tsx (798) — once reflowed, remove the minWidth entirely and replace height: "75vh" with maxHeight so short forms don't stretch. (done 2026-07-18; deviation: kept minWidth: { xs: 0, md: 850 } instead of removing it — removing entirely would shrink the desktop dialog to maxWidth="sm" (600px), violating the desktop pixel-identical rule; heightmaxHeight done)
  • 5.8 Remaining PartnerForm.tsx scaffolding (done 2026-07-18; table wrappers block at xs, the inline address row got the FieldRow recipe, and both ContactsSection.tsx and ContractsSection.tsx header/divider/content rows reflowed)
  • 5.9 src/lib/forms/organizationTextFieldForm/OrganizationTextFieldForm.tsx:145minWidth: 400minWidth: { xs: 0, sm: 400 }. (done 2026-07-18)
  • 5.10 Tap targets (done 2026-07-18; the FieldRow pencil trigger (OrganizationTextFieldForm, was p: 0) → p: { xs: 1.25, md: 0 } ≈ 44px on phones; contact-form add/delete small IconButtons → p: { xs: "13px", md: "5px" } via a table-level rule)

6. Stragglers

  • 6.1 src/lib/security/TurnstileChallenger.tsx:71minWidth: 320 is borderline at 375px minus the shell margins; verify, relax to maxWidth: "100%" if squeezed. (verified 2026-07-18: shell margins are now mx: 2 at xs (section 2), so 375px leaves 343px ≥ 320 — fits, no change)
  • 6.2 Touch DnD (audit only, out of scope) — audited 2026-07-18; the premise is outdated: DragAndDrop.tsx:341-353 and VerticalDnD.tsx:70-79 already configure both PointerSensor (distance: 8) and TouchSensor (delay: 250, tolerance: 5) activation constraints, and ActivityPicker.tsx contains no dnd-kit code at all. No follow-up code work identified — the only remaining unknown is real-device feel (long-press to drag), which belongs in the 7.5 manual checklist.
  • 6.3 Fixed-size buttons on Clerk-adjacent event pages (done 2026-07-18; verified live at 390px — no overflow) — the MUI buttons that open the Clerk sign-in modal hard-code width: "300px", height: "100px": src/app/[locale]/event/sign-out/page.tsx:35,40 and src/app/[locale]/event/org-not-available/page.tsx:27,34. Relax to width: { xs: "100%", sm: 300 }, maxWidth: 300 (height can shrink too) so they fit 375px minus the page margins.
  • 6.4 Clerk components: no changes needed — audited 2026-07-15. src/themes/clerkTheme.ts is cosmetic-only (colors, font, borderRadius, boxShadow on card/formButtonPrimary/ formFieldInput/footerActionLink) with zero fixed widths or heights, on Clerk's responsive experimental__simple base theme. All Clerk surfaces use responsive defaults: modal <SignIn>/<SignUp> (src/lib/users/AccountSwitcher.tsx:36, src/lib/ui/MainPage/SignUpBanner.tsx:64, openSignIn() on the event pages above), the <UserButton> popover, the <OrganizationSwitcher> popover in the drawer (DrawerList.tsx:198-209 — its only override is an invisible trigger overlay sized in %, the popover itself is Clerk-default), and the inline <SignIn> on /e (src/app/[locale]/(app)/e/page.tsx:22, centered in a full-viewport flex box). clerkLocalizationOverrides.ts is text-only and no CSS targets .cl-* internals. Caveat for future work: any new appearance.elements override must not introduce fixed px widths, or this conclusion no longer holds.
  • 6.5 Shared button primitives (audited 2026-07-15)deployed 2026-07-18: Appendix A patch applied to ButtonStack.tsx, companion test added (ButtonStack.test.tsx, 3/3 green), and the secondary defaultSxProps.ts fix applied (width: { xs: "100%", sm: "150px" }, maxWidth: "100%"; ml: 2 kept — ButtonStack resets it, raw call sites keep it). Full deployment checklist ran green: 169-test src/lib/ui suite, tsc, lint, and a live desktop check of the dashboard row (flex-wrap + 16px gap + ml: 0, geometry unchanged at 200×80/one row). — nearly every button row in the app funnels through src/lib/ui/buttongroups/ButtonStack.tsx: all ~15 buttongroup wrappers (ApplyCancelButtons, OuFormButtons, EditActivityButtons, ShowActivityButtons, the grid button sets, twoButtonToast, …) render via it, and app code imports those wrappers rather than raw buttons. Fix ButtonStack first — it covers the app in one file. The multiline (wrap) recipe below was validated against the installed MUI v9.1.2 source and the test suite on 2026-07-15, then parked pending review — it is implementation-ready. The exact verified patch and its test are saved in Appendix A for future deployment.
    • In the stackProps destructuring (ButtonStack.tsx:148-153) add flexWrap = "wrap" and useFlexGap = true defaults; put flexWrap first in stackSx (caller-overridable via stackProps) and pass useFlexGap={useFlexGap} to the <Stack>. Rows then wrap onto new lines instead of overflowing at 375px. useFlexGap: true is required for wrapping: MUI's default margin-based spacing mode gives no cross-axis spacing between wrapped lines (see node_modules/@mui/system/Stack/createStack.js — spacing is a margin-left on siblings plus a child margin: 0 reset).
    • Change the button merge at ButtonStack.tsx:222 to sx: { ml: 0, ...buttonSx, ...item.sx }. Rationale: the margin-based mode's higher-specificity & > :not(style) { margin: 0 } reset currently neutralizes the ml: 2 every shared button inherits from defaultSxProps.ts; with flex-gap that reset disappears and the ml would stack on top of the gap (double spacing). Use ml: 0, not m: 0 — spread merging keeps the first-insertion key position, so a blanket m: 0 would defeat caller margins while ml: 0 only cancels the known default.
    • Side effect (intended fix): ShowActivityButtons.tsx:15 already passes useFlexGap: true and therefore currently leaks ml: 2 on top of its gap; the reset makes its spacing uniform. Screenshot-check desktop button rows after the change (spacing source switches from margins to flex-gap).
    • Optionally also clamp widths in the same merge (maxWidth: "100%", width: { xs: "100%", sm: <caller value> }) so buttons shrink at xs regardless of what wrappers pass.
    • Test recipe: a rendered-CSS test (assert the stack computes flex-wrap: wrap, a non-normal gap, and buttons get margin-left: 0px) must mock next-intl — the shared Button calls useTranslations (Button.tsx:104); copy the mock pattern from ShowActivityButtons.test.tsx. Typecheck, lint and the 166-test src/lib/ui suite were all green with this change applied. Then the secondary fix for the ~10 direct raw-button call sites (Button in TemplateEditor, EditActivitySingleValued, ActivityPublicationMethods, ResetCorrupted, etc.): src/lib/ui/buttons/defaultSxProps.ts:2-3,7 bakes width: "150px", height: "50px", ml: 2 into every shared button (Button, ButtonCopy, ButtonExpand, ButtonHold, ButtonOpenLinkNewTab, ButtonTooltip, ButtonTranslate). Change the default to width: { xs: "100%", sm: "150px" }, maxWidth: "100%". Both edits reflow many consumers at once — screenshot-check the main surfaces (dialog action rows, form buttons) on desktop after.
  • 6.6 Two-button toast (done 2026-07-18) — src/lib/ui/buttongroups/twoButtonToast.tsx:85 hard-codes width: 550 on the toast Paper; it overflows any phone. Use width: "min(550px, calc(100vw - 32px))".
  • 6.7 Template caption buttons (done 2026-07-18) — src/lib/ui/buttongroups/TemplatesCaptionButtons.tsx:62,96 set minWidth: 220; two such buttons side by side overflow 375px. Relax to minWidth: { xs: 0, sm: 220 }. Fine as-is in this area (audited, no action): ButtonToggle (45×35 min — good tap target), SwitchAnt (decorative ~25px switch), ButtonHoldRaw (40px height), EditActivityMultiValuedButtons (100×40 buttons).

7. Verification

  • 7.1 Playwright mobile projects — add devices["iPhone 14"] (390px) and devices["iPad Mini"] (768px) projects to the Playwright config alongside desktop (see tier-5 E2E docs). (done 2026-07-18; both projects force browserName: "chromium" — the Apple descriptors default to webkit, which isn't installed — and are scoped via testMatch to mobile-ui.spec.ts so the existing desktop specs don't triple. baseURL is now overridable via PW_BASE_URL to target a tenant subdomain locally.)
  • 7.2 No-horizontal-overflow assertion (done 2026-07-18 in playwright-tests/mobile-ui.spec.ts: dashboard, activity dialog open, and the org-not-available event page, on all three projects) — for every key route (dashboard, activity detail, a form dialog open, footer visible): `expect(await page.evaluate(() => document.documentElement.scrollWidth
    • document.documentElement.clientWidth)).toBeLessThanOrEqual(0)`.
  • 7.3 Behavior specs (done 2026-07-18, same spec file; 15/15 green against PW_BASE_URL=http://demo.localhost:3000. Discovery: signed-out visitors get Turnstile-gated /{locale}/{id}?type=activity-declaration card links, not the ActivityDetails dialog — so the two dialog tests sign in via the shared clerkSignedIn fixture and skip when .env.e2e creds are absent) — at 390px: hamburger opens the temporary drawer, backdrop click closes it, nav click closes it, dialogs render fullScreen. At 1440px: permanent drawer behavior unchanged, dialogs windowed.
  • 7.4 Auth caveat (resolved 2026-07-18: Clerk E2E auth was already wired (fixtures.ts + .env.e2e), so the dialog specs use it directly; the useIsMobile Vitest unit test was added anyway — src/lib/hooks/useIsMobile.test.tsx, 3/3 green at 375/768/1280px) — Clerk in Playwright is the main setup cost; if E2E auth isn't wired for the target routes, start with unauthenticated/public routes plus a Vitest unit test for useIsMobile (see clerk-playwright).
  • 7.5 Manual checklist (run after every section)(partially covered 2026-07-18 by emulated checks: no horizontal scroll, drawer open/close/navigate + nuqs URL sync, grids at mobile, dialogs fullScreen, footer full-width, LanguagePicker menu fits at 390px. Still needs a human pass: one real device, RTL spot-check, signed-in edit forms at 375px, Clerk popovers/delete-account modal.) — Chrome DevTools at 375/768/1024 plus one real device: no body horizontal scroll; drawer open/close/navigate; drawer role/OU filters still sync to the URL (nuqs); search usable; footer links tappable; grids at 1/2/3/5 cards; each converted form fully editable at 375px; LanguagePicker menu doesn't overflow; Clerk surfaces fit at 375px — sign-in modal opens and fits, <UserButton> popover, <OrganizationSwitcher> popover (drawer) and the delete-account modal don't overflow (expected to pass unchanged, see 6.4); RTL spot-check (the theme supports direction: "rtl").

8. Documentation (final step — do not skip)

  • 8.1 Document useIsMobile (done 2026-07-18) in docs/dev/components/hooks/hooks.md: signature, the sm/md convention, and the SSR rule for when to use the hook vs. CSS breakpoints.
  • 8.2 Responsive-design guide (done 2026-07-18 as docs/dev/infrastructure/responsive-design.md, following the one-file-per-concern convention of that directory, plus a short "Responsive UI" pointer section in architecture.md) — add a new docs/dev/infrastructure/responsive-design.md (or a section in architecture.md) capturing the breakpoint convention (0.3), the SSR rule (0.2), the responsive-drawer pattern, and the dialog fullScreen pattern, so future components follow the same rules.
  • 8.3 Update component docs (done 2026-07-18; grids.md got the responsive size + fullScreen note, and the temporary-drawer semantics are documented on useDrawer in hooks.md — no dedicated shell doc exists) — refresh components/ui/grids.md for the responsive grid sizes, and note the mobile temporary-drawer behavior wherever the shell/drawer is documented.
  • 8.4 Close this TODO — tick items off with completion dates as they ship (house style, cf. performance-improvements-todo.md) and flip the Status header to Done when section 7 is fully green. (2026-07-18: all implementation items ticked; Status flips to Done once the human parts of 7.5 pass — real device, RTL, signed-in forms at 375px.)

Appendix A: ButtonStack multiline patch (verified) — DEPLOYED 2026-07-18

The exact change for item 6.5's primary fix, verified on 2026-07-15: applied to the working tree, npx tsc --noEmit clean, ESLint clean, the full 166-test src/lib/ui suite green, and the rendered-CSS test below passing (3/3). The patch applies cleanly to ButtonStack.tsx at commit ba22e52 — save to a file and deploy with git apply.

diff --git a/src/lib/ui/buttongroups/ButtonStack.tsx b/src/lib/ui/buttongroups/ButtonStack.tsx
--- a/src/lib/ui/buttongroups/ButtonStack.tsx
+++ b/src/lib/ui/buttongroups/ButtonStack.tsx
@@ -7,12 +7,23 @@
* Supports MUI Stack spacing, button size, tooltips, confirmation dialogs, and
* custom styles. Buttons are rendered using your custom Button component by default.
*
+ * Row stacks are multiline by default: spacing is flex-gap based
+ * (useFlexGap) and the stack wraps (flexWrap: "wrap"), so buttons that
+ * don't fit the container flow onto the next line instead of overflowing
+ * — the key behavior on narrow/mobile viewports. The default left margin
+ * from defaultSxProps is reset (ml: 0) to keep spacing gap-controlled,
+ * matching how MUI Stack reset child margins in its margin-based mode;
+ * margins passed via buttonSx or item sx still apply.
+ *
* 🔑 Props
* - stackProps: Object with optional MUI Stack props:
* - spacing: gap between buttons (default 2)
* - direction: "row" or "column" (default "row")
* - variant: MUI Button variant (default "contained")
* - buttonSx: sx applied to all stack buttons overrides defaultSxProps file
+ * - flexWrap: "wrap" | "nowrap" | "wrap-reverse" (default "wrap")
+ * - useFlexGap: boolean (default true) — required for wrapped lines
+ * to keep their spacing; set false only with flexWrap: "nowrap"
* - items: Array of button configuration objects:
* - ButtonText: string - text to display on the button
* - onClick: function - action when clicked
@@ -97,6 +108,8 @@ interface StackProps {
direction?: "row" | "column" | "row-reverse" | "column-reverse";
size?: string;
buttonSx?: SxProps<Theme>;
+ flexWrap?: "wrap" | "nowrap" | "wrap-reverse";
+ useFlexGap?: boolean;
justifyContent?: string;
alignItems?: string;
p?: number | string;
@@ -151,6 +164,8 @@ const ButtonStack = ({
direction = "row",
size = "large",
buttonSx,
+ flexWrap = "wrap",
+ useFlexGap = true,
// System props removed from Stack in MUI v9 — forward via sx instead
justifyContent,
alignItems,
@@ -173,6 +188,7 @@ const ButtonStack = ({
} = stackProps;

const stackSx = {
+ flexWrap,
...(justifyContent !== undefined && { justifyContent }),
...(alignItems !== undefined && { alignItems }),
...(p !== undefined && { p }),
@@ -196,6 +212,7 @@ const ButtonStack = ({
<Stack
direction={direction}
spacing={spacing}
+ useFlexGap={useFlexGap}
sx={stackSx}
{...restStackProps}
>
@@ -219,7 +236,10 @@ const ButtonStack = ({
const buttonProps = {
variant,
size,
- sx: { ...buttonSx, ...item.sx },
+ // ml: 0 replicates the child-margin reset MUI Stack applies in its
+ // margin-based (useFlexGap: false) spacing mode, so switching to
+ // flex-gap doesn't let defaultSxProps' ml leak in on top of the gap
+ sx: { ml: 0, ...buttonSx, ...item.sx },
...(shouldSetDefaultText && {
ButtonText: item.ButtonText || `Button #${index + 1}`,
}),

Companion test — add as src/lib/ui/buttongroups/ButtonStack.test.tsx when deploying (all three tests passed against the patch above):

// Verification for the ButtonStack multiline (wrap) patch — see
// docs/dev/TODO/mobile-ui-improvements.md item 6.5.
import { render } from "@testing-library/react";
import { vi, describe, it, expect } from "vitest";

// The shared Button calls useTranslations (Button.tsx) — mock next-intl
// following the pattern in ShowActivityButtons.test.tsx
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
useLocale: () => "en",
}));

import ButtonStack from "./ButtonStack";

describe("ButtonStack multiline behavior", () => {
it("renders flex-wrap: wrap and gap-based spacing by default", () => {
const { container } = render(
<ButtonStack
items={[
{ ButtonText: "One", onClick: () => {} },
{ ButtonText: "Two", onClick: () => {} },
]}
/>,
);
const stack = container.firstElementChild as HTMLElement;
const stackStyle = getComputedStyle(stack);
expect(stackStyle.flexWrap).toBe("wrap");
// spacing 2 → gap 16px (flex-gap mode instead of sibling margins)
expect(stackStyle.gap).toBe("16px");

// defaultSxProps' ml: 2 must be reset so it doesn't add to the gap
const button = stack.querySelector("button") as HTMLElement;
expect(getComputedStyle(button).marginLeft).toBe("0px");
});

it("caller margins passed via buttonSx still apply", () => {
const { container } = render(
<ButtonStack
stackProps={{ buttonSx: { ml: 1 } }}
items={[{ ButtonText: "One", onClick: () => {} }]}
/>,
);
const button = container.querySelector("button") as HTMLElement;
expect(getComputedStyle(button).marginLeft).toBe("8px");
});

it("caller can opt out via flexWrap nowrap", () => {
const { container } = render(
<ButtonStack
stackProps={{ flexWrap: "nowrap" }}
items={[{ ButtonText: "One", onClick: () => {} }]}
/>,
);
const stack = container.firstElementChild as HTMLElement;
expect(getComputedStyle(stack).flexWrap).toBe("nowrap");
});
});

Deployment checklist: apply patch → add test → npx vitest run src/lib/uinpx tsc --noEmitnpm run lint → desktop screenshot spot-check of button rows (spacing source switches from margins to flex-gap; ShowActivityButtons spacing becomes uniform, see 6.5).