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 fromsrc/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 actionsminWidth: 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 viasrc/lib/forms/organizationForm/FieldRow.tsx:50,58,80); one dialog hard-codesminWidth: 850(organizationContactForm/OrganizationContactForm.tsx:364). - Dialog
fullScreenexists 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-mcpMCP server (get_component_info,search_components,get_customization_guide) — especially forDrawer(temporary variant,ModalProps),useMediaQuery,Dialog(fullScreen),Grid(responsivesize) and breakpoint syntax insx; MUI v9 is newer than model training data. Read Next.js docs innode_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 JSuseIsMobilehook (section 1) only where the component structure or behavior must change: the drawer variant and dialogfullScreen. This confines hydration-mismatch risk to two well-understood spots. - 0.3 Breakpoint convention (confirmed 2026-07-18: no
breakpointscustomization exists insrc/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 customizetheme.breakpointsinsrc/themes/createAppTheme.ts; there is no need and it would churn every futuresx.
1. Foundation: shared useIsMobile hook
-
1.1 Create
src/lib/hooks/useIsMobile.ts(done 2026-07-18; v9useMediaQuerysignature verified via mui-mcp — unchanged, server defaultmatches: false) — a"use client"hook wrapping MUI'suseMediaQuery:"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
useMediaQuerysignature/SSR options against mui-mcp first. Server render returnsfalse(desktop) until hydration — accept that; do not fight it withnoSsrunless 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.tsfollowing the existing pattern (useDraweretc.; see hooks docs). -
1.3 Do NOT build a shared
ResponsiveDialogwrapper (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. InMiniDrawer.tsx:const isMobile = useIsMobile("md");. When mobile, render a plain<MuiDrawer variant="temporary" open={open} onClose={() => setOpen(false)} ModalProps={{ keepMounted: true }}>with paper widthmin(${DRAWERWIDTH_OPENED}px, 85vw), containing the sameDrawerHeader+DrawerList open+DrawerDocumentation openchildren. When desktop, keep the existing styled permanent mini-variant untouched.keepMountedpreservesDrawerList'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 theopenblock 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
mdwhile open) — no API change tosrc/lib/hooks/DrawerContext.tsx:openmeans "overlay visible" on mobile and "expanded" on desktop. The hamburger inMainNavBar.tsx:71-83already callssetOpen; keep the hamburger visible belowmdregardless ofopenstate. - 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) — insrc/lib/ui/MainPage/DrawerList.tsx, callsetOpen(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">withflexGrow: 1, minWidth: 0, pb: FOOTER_HEIGHT) — insrc/app/[locale]/(app)/layout.tsx:170change tosx={{ display: "flex", my: { xs: 4, md: 10 }, mx: { xs: 2, md: 5 } }}and give<main id="main-content">flexGrow: 1, minWidth: 0(minWidth: 0is what actually stops flex-child horizontal overflow) plus bottom padding clearing the fixed footer. - 2.7 MainNavBar (done 2026-07-18; hamburger
mralso made responsive;OrganizationAvatarleft visible — revisit if 375px crowds) — search boxwidth: 350(MainNavBar.tsx:127) →width: { xs: "100%", sm: 300, md: 350 }, flex: { xs: 1, sm: "none" }; right-hand stackmr: 10→mr: { xs: 0, md: 10 }. Only if the toolbar still crowds at 375px, hideOrganizationAvataratxs. - 2.8 Footer (done 2026-07-18; verified full-width at 390px;
SignUpBanneraudited — no fixed widths, no change needed) — all CSS-only inFooter.tsx:left: open ? DRAWERWIDTH_OPENED : DRAWERWIDTH_CLOSED→left: { xs: 0, md: open ? DRAWERWIDTH_OPENED : DRAWERWIDTH_CLOSED }; the threeBottomNavigationActionsminWidth: 200→minWidth: { xs: 0, md: 200 };Dividermx: 25→mx: { xs: 2, md: 25 }. CheckSignUpBanner.tsxfor fixed widths while in there. (Decision default: footer stays fixed on mobile but shrunk; switch toposition: staticbelowmdonly 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
sizeobject 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 atContractsGrid.tsx:146and inPartnersGrid.tsx/OrganizationSettingsGrid.tsx. Verify the MUI v9 Gridsizeobject syntax via mui-mcp first. - 3.2 Responsive widths and spacing (done 2026-07-18) — the count-based width
tricks (
75%/60%/100%atActivitiesGrid.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;OrgSettingsCaptionminWidth: 200buttons fit a full-widthxscard — no change) — once cards go full-width atxs, checksrc/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
ActivitiesGridButtonsviastackProps.sx.flexWrap+ responsivebuttonSx;ButtonStackitself untouched, the parked 6.5/Appendix A patch remains undeployed) —src/lib/ui/buttongroups/ActivitiesGridButtons.tsx:16renders up to four buttons ofwidth: 200, height: 80in a singleButtonStackrow below the grid — an 800px+ row at 375px. Make the buttons responsive (width: { xs: "100%", sm: 200 }, smaller height atxs) and stack the row vertically or wrap it on mobile (see 6.5 for theButtonStackchange 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 themaxWidth: 600. (done 2026-07-18; note: this is aMenu, not aDialog— nofullScreenexists, 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:364changeminWidth: 850→minWidth: { xs: 0, md: 850 }and give theTableContainer(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/PaperPropsdialog widths remained;ActivityPicker.tsx:265andOrganizationPicker.tsx:134are Menu papers atminWidth: 300which 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 inTemplateEditorDialogTitle.tsxand the three inline toggles are CSS-hidden belowsm(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; atxsthe border moves from the cells to the row so wrapped cells don't draw partial lines; edit cell getsml: "auto", value cellorder: 1+flexBasis: 100%) —src/lib/forms/organizationForm/FieldRow.tsx(99 lines, three cells: icon7%/ value86%/ edit7%, lines 50/58/80) is shared byPartnerForm.tsx(1004 lines), so this single file reflows every PartnerForm row. Pattern: on theTableRowdisplay: { xs: "flex", md: "table-row" }, flexWrap: { xs: "wrap" }; on the cellsdisplay: { xs: "block", md: "table-cell" }, width: { xs: "auto", md: "<existing %>" }with the value cell gettingflexBasis: { xs: "100%" }(icon + edit button inline, value full-width below). Watch MUI Table border/semantics when overridingdisplay; screenshot-diff desktop before propagating. - 5.2
src/lib/forms/vatForm/VatForm.tsx(done 2026-07-18; country picker and VAT input stack atxs, 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 paperwidth: "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 insrc/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 neededflexWrapatxs) - 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 theminWidthentirely and replaceheight: "75vh"withmaxHeightso short forms don't stretch. (done 2026-07-18; deviation: keptminWidth: { xs: 0, md: 850 }instead of removing it — removing entirely would shrink the desktop dialog tomaxWidth="sm"(600px), violating the desktop pixel-identical rule;height→maxHeightdone) - 5.8 Remaining
PartnerForm.tsxscaffolding (done 2026-07-18; table wrappers block atxs, the inline address row got the FieldRow recipe, and bothContactsSection.tsxandContractsSection.tsxheader/divider/content rows reflowed) - 5.9
src/lib/forms/organizationTextFieldForm/OrganizationTextFieldForm.tsx:145—minWidth: 400→minWidth: { xs: 0, sm: 400 }. (done 2026-07-18) - 5.10 Tap targets (done 2026-07-18; the FieldRow pencil trigger
(
OrganizationTextFieldForm, wasp: 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:71—minWidth: 320is borderline at 375px minus the shell margins; verify, relax tomaxWidth: "100%"if squeezed. (verified 2026-07-18: shell margins are nowmx: 2atxs(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-353andVerticalDnD.tsx:70-79already configure bothPointerSensor(distance: 8) andTouchSensor(delay: 250, tolerance: 5) activation constraints, andActivityPicker.tsxcontains 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,40andsrc/app/[locale]/event/org-not-available/page.tsx:27,34. Relax towidth: { 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.tsis cosmetic-only (colors, font, borderRadius, boxShadow oncard/formButtonPrimary/formFieldInput/footerActionLink) with zero fixed widths or heights, on Clerk's responsiveexperimental__simplebase 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.tsis text-only and no CSS targets.cl-*internals. Caveat for future work: any newappearance.elementsoverride 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 secondarydefaultSxProps.tsfix applied (width: { xs: "100%", sm: "150px" }, maxWidth: "100%";ml: 2kept — ButtonStack resets it, raw call sites keep it). Full deployment checklist ran green: 169-testsrc/lib/uisuite, 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 throughsrc/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. FixButtonStackfirst — 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
stackPropsdestructuring (ButtonStack.tsx:148-153) addflexWrap = "wrap"anduseFlexGap = truedefaults; putflexWrapfirst instackSx(caller-overridable viastackProps) and passuseFlexGap={useFlexGap}to the<Stack>. Rows then wrap onto new lines instead of overflowing at 375px.useFlexGap: trueis required for wrapping: MUI's default margin-based spacing mode gives no cross-axis spacing between wrapped lines (seenode_modules/@mui/system/Stack/createStack.js— spacing is amargin-lefton siblings plus a childmargin: 0reset). - Change the button merge at
ButtonStack.tsx:222tosx: { ml: 0, ...buttonSx, ...item.sx }. Rationale: the margin-based mode's higher-specificity& > :not(style) { margin: 0 }reset currently neutralizes theml: 2every shared button inherits fromdefaultSxProps.ts; with flex-gap that reset disappears and themlwould stack on top of the gap (double spacing). Useml: 0, notm: 0— spread merging keeps the first-insertion key position, so a blanketm: 0would defeat caller margins whileml: 0only cancels the known default. - Side effect (intended fix):
ShowActivityButtons.tsx:15already passesuseFlexGap: trueand therefore currently leaksml: 2on 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 atxsregardless of what wrappers pass. - Test recipe: a rendered-CSS test (assert the stack computes
flex-wrap: wrap, a non-normalgap, and buttons getmargin-left: 0px) must mocknext-intl— the sharedButtoncallsuseTranslations(Button.tsx:104); copy the mock pattern fromShowActivityButtons.test.tsx. Typecheck, lint and the 166-testsrc/lib/uisuite were all green with this change applied. Then the secondary fix for the ~10 direct raw-button call sites (ButtoninTemplateEditor,EditActivitySingleValued,ActivityPublicationMethods,ResetCorrupted, etc.):src/lib/ui/buttons/defaultSxProps.ts:2-3,7bakeswidth: "150px", height: "50px", ml: 2into every shared button (Button,ButtonCopy,ButtonExpand,ButtonHold,ButtonOpenLinkNewTab,ButtonTooltip,ButtonTranslate). Change the default towidth: { 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.
- In the
- 6.6 Two-button toast (done 2026-07-18) —
src/lib/ui/buttongroups/twoButtonToast.tsx:85hard-codeswidth: 550on the toastPaper; it overflows any phone. Usewidth: "min(550px, calc(100vw - 32px))". - 6.7 Template caption buttons (done 2026-07-18) —
src/lib/ui/buttongroups/TemplatesCaptionButtons.tsx:62,96setminWidth: 220; two such buttons side by side overflow 375px. Relax tominWidth: { 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) anddevices["iPad Mini"](768px) projects to the Playwright config alongside desktop (see tier-5 E2E docs). (done 2026-07-18; both projects forcebrowserName: "chromium"— the Apple descriptors default to webkit, which isn't installed — and are scoped viatestMatchtomobile-ui.spec.tsso the existing desktop specs don't triple.baseURLis now overridable viaPW_BASE_URLto 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-declarationcard links, not the ActivityDetails dialog — so the two dialog tests sign in via the sharedclerkSignedInfixture and skip when.env.e2ecreds 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; theuseIsMobileVitest 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 foruseIsMobile(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 supportsdirection: "rtl").
8. Documentation (final step — do not skip)
- 8.1 Document
useIsMobile(done 2026-07-18) indocs/dev/components/hooks/hooks.md: signature, thesm/mdconvention, 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 inarchitecture.md) — add a newdocs/dev/infrastructure/responsive-design.md(or a section inarchitecture.md) capturing the breakpoint convention (0.3), the SSR rule (0.2), the responsive-drawer pattern, and the dialogfullScreenpattern, 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 onuseDrawerin hooks.md — no dedicated shell doc exists) — refreshcomponents/ui/grids.mdfor 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/ui → npx tsc --noEmit → npm run lint → desktop screenshot
spot-check of button rows (spacing source switches from margins to
flex-gap; ShowActivityButtons spacing becomes uniform, see 6.5).