From aa1e75e399046fb71b72c3e90e7811d3239c0793 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 3 Oct 2026 16:08:04 +0200 Subject: [PATCH] Mobile: the menu button was indenting every page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole site sat 36px to the right on phones and tablets, with a narrow empty gutter down the left that held nothing but the hamburger. Sidebar rendered the mobile trigger as a plain child of the content flex row in the root layout. Below lg the desktop rail is display:none and the drawer is position:fixed, so that button was the only sibling still taking width before
— and main is flex-1, so it filled only what was left. Measured on the dev server: at 375px main was x=36 w=339; at 768px x=36 w=732. 1280px was correct (x=256 w=1024), because the button is lg:hidden there. This was never a horizontal-scroll bug — scrollWidth equalled clientWidth at both widths. It was a permanent indent, and the content was 36px narrower than the screen. The side menu itself was fine: the drawer is fixed, so it contributed nothing to layout and opened at x=0, 288x812, with scroll lock working. Only its trigger was in the wrong place. - Sidebar now exports the desktop rail on its own — hidden lg:block, so it can never take width on mobile — plus MobileNav, which owns the trigger and drawer. - The trigger moved into the full-bleed header, so the button costs no content width, and it is w-11 h-11 (44x44) with -ml-3, which puts its icon's left edge on the logo's 16px edge without moving the logo. - The drawer and overlay are portalled to document.body. The header carries backdrop-blur, and a backdrop-filter makes an element the containing block for its position:fixed descendants — inline, the drawer would have sized itself to the 64px header box. Commented in place so the portal is not removed later. - The desktop rail and the drawer now render one shared NavList, so the two navs cannot drift apart. - Header row gap is gap-2 on mobile: the 44px trigger narrows the search field from 247px to 215px at 375px, and the search is the only thing that yields. Verified in the browser: main is x=0 w=375 at 375px and x=0 w=768 at 768px, with no page overflow at either. The drawer is still full-viewport height and paints correctly above Leaflet on /plant/map; tapping a drawer link navigates and closes it; the scroll lock releases. /income/invoices, the widest table, has no element wider than the viewport. Typecheck and build green. --- app/layout.tsx | 12 +++- components/Sidebar.tsx | 157 ++++++++++++++++++++++++++--------------- 2 files changed, 110 insertions(+), 59 deletions(-) diff --git a/app/layout.tsx b/app/layout.tsx index 43c8ca6..f102972 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -11,7 +11,7 @@ import { MapPin, Boxes, } from "lucide-react"; -import { Sidebar, type NavItem } from "@/components/Sidebar"; +import { Sidebar, MobileNav, type NavItem } from "@/components/Sidebar"; import { ThemeProvider } from "@/components/ThemeProvider"; import { ThemeToggle } from "@/components/ThemeToggle"; import { GlobalSearch } from "@/components/GlobalSearch"; @@ -120,7 +120,15 @@ export default async function RootLayout({ children }: { children: React.ReactNo the Plant Map scrolled straight over it. Must stay above 1000, and below the mobile nav drawer (z-[1200]) in Sidebar.tsx. */}
-
+ {/* gap-2, not gap-3: the 44px trigger eats ~32px of the header row + after its -ml-3, and the search field is the only thing that + yields. Recovering 4px per gap keeps it usable at 375px. */} +
+ {/* The hamburger must live HERE, in the full-bleed header — not in + the content flex row below, where it indented every page on + mobile. See MobileNav for the portal detail. */} + + , pathname: stri ); } -export function Sidebar({ items }: { items: NavItem[] }) { +// The desktop rail and the mobile drawer render the same list from this one +// component, so the two navs can never drift apart. +function NavList({ items, onNavigate }: { items: NavItem[]; onNavigate?: () => void }) { const pathname = usePathname(); - const [mobileOpen, setMobileOpen] = useState(false); - - // Close drawer on navigation - useEffect(() => { - setMobileOpen(false); - }, [pathname]); - - // Lock body scroll when drawer is open - useEffect(() => { - document.body.style.overflow = mobileOpen ? "hidden" : ""; - return () => { - document.body.style.overflow = ""; - }; - }, [mobileOpen]); - - const renderNav = (onNavigate?: () => void) => ( + return ( ); +} + +/** + * Desktop rail only. It is `hidden lg:block`, so it contributes ZERO width below + * `lg` and must stay a pure sibling of `
` — anything visible on mobile + * rendered in this flex row steals width from the page. The mobile trigger lives + * in the header instead; see `MobileNav`. + */ +export function Sidebar({ items }: { items: NavItem[] }) { + return ( + + ); +} + +/** + * Mobile nav: the hamburger plus the drawer it opens. Rendered INSIDE the header, + * which is the whole point — the old version put the button in the content flex + * row, where its 36px was subtracted from every page below `lg` and indented the + * entire site on phones and tablets. + * + * The header is full-bleed, so a 44px trigger there costs no content width. + * `-ml-3` pulls the hit area into the header's own `px-4`, which lands the icon's + * left edge exactly on the logo's (16px) without changing the logo's position. + */ +export function MobileNav({ items }: { items: NavItem[] }) { + const pathname = usePathname(); + const [open, setOpen] = useState(false); + const [mounted, setMounted] = useState(false); + + // Close drawer on navigation + useEffect(() => { + setOpen(false); + }, [pathname]); + + // Scroll lock, plus the SSR gate for the portal below — `document` does not + // exist while this component is server-rendered. + useEffect(() => { + setMounted(true); + document.body.style.overflow = open ? "hidden" : ""; + return () => { + document.body.style.overflow = ""; + }; + }, [open]); return ( <> - {/* Desktop: persistent sidebar (hidden on mobile) */} - - - {/* Mobile: hamburger in the top bar triggers a drawer */} - {/* The drawer must out-rank Leaflet. Leaflet's own `.leaflet-top` / - `.leaflet-bottom` control panes are `z-index: 1000` and its map panes - run 400–700, all in the root stacking context — so the old z-40/z-50 - pair lost and the Plant Map painted straight over the open menu. - Keep these values above 1000, and above the header's z-[1100]. */} - {mobileOpen && ( -
setMobileOpen(false)} - /> - )} - ); }