diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index 4abb16fbf..e28186030 100644 --- a/src/components/layout/sidebar-nav.tsx +++ b/src/components/layout/sidebar-nav.tsx @@ -25,6 +25,7 @@ import { import { cn } from "@/lib/utils"; import { Logo } from "@/components/ui/logo"; import { useAuth, useLogout } from "@/hooks/use-auth"; +import { useIsMobile } from "@/hooks/use-is-mobile"; import { useMounted } from "@/hooks/use-mounted"; import { useTheme } from "@/components/providers"; import { useTranslations } from "@/lib/i18n/context"; @@ -252,35 +253,37 @@ export function SidebarNav() { () => visibleNavDestinations(user?.modules, mounted), [user?.modules, mounted], ); - const [collapsed, setCollapsed] = useState(() => { - if (typeof window === "undefined") return false; + // Collapsed = the user's stored choice, or — with no stored choice — a + // viewport default: tablet widths (md–lg, e.g. an iPad held upright) + // fall back to the icon rail so the content column keeps its room; a + // 256 px sidebar squeezes a 768 px viewport down to a 512 px column. + // Everything is gated on `mounted` (same pattern as the module filter + // above): SSR and the hydration render both paint the expanded shell, + // the stored pref / viewport default applies on the first client render + // after hydration settles. Branching earlier — localStorage or + // matchMedia inside the initial render — is a React #418 hydration + // mismatch, because the sidebar is only CSS-hidden below `md` and still + // hydrates its DOM there. + const tabletOrBelow = useIsMobile("lg"); + const [collapsedPref, setCollapsedPref] = useState(() => { + if (typeof window === "undefined") return null; try { const stored = localStorage.getItem(STORAGE_KEY); - if (stored !== null) return stored === "true"; + return stored === null ? null : stored === "true"; } catch { - return false; - } - // No stored preference: tablet widths (md–lg, e.g. an iPad held - // upright) start on the icon rail so the content column keeps its - // room — a 256 px sidebar squeezes a 768 px viewport down to 512 px. - // A manual toggle persists and wins on every later visit. - try { - return window.matchMedia("(max-width: 1023.98px)").matches; - } catch { - return false; + return null; } }); + const collapsed = mounted ? (collapsedPref ?? tabletOrBelow) : false; function toggleCollapsed() { - setCollapsed((prev) => { - const next = !prev; - try { - localStorage.setItem(STORAGE_KEY, String(next)); - } catch { - // Ignore storage errors - } - return next; - }); + const next = !collapsed; + setCollapsedPref(next); + try { + localStorage.setItem(STORAGE_KEY, String(next)); + } catch { + // Ignore storage errors + } } // v1.17.1 (F-1 residue) — the sidebar footer utility links derive from diff --git a/src/hooks/use-is-mobile.ts b/src/hooks/use-is-mobile.ts index ee3445dd3..0142cbb14 100644 --- a/src/hooks/use-is-mobile.ts +++ b/src/hooks/use-is-mobile.ts @@ -36,8 +36,13 @@ import { useSyncExternalStore } from "react"; * need a tighter cut (e.g. the Coach drawer, which flips to a * bottom-sheet only below `sm` / 640 px) pass an explicit breakpoint. */ -function getMediaQuery(breakpoint: "sm" | "md"): string { - const maxWidth = breakpoint === "sm" ? "639.98px" : "767.98px"; +function getMediaQuery(breakpoint: "sm" | "md" | "lg"): string { + const maxWidth = + breakpoint === "sm" + ? "639.98px" + : breakpoint === "md" + ? "767.98px" + : "1023.98px"; return `(max-width: ${maxWidth})`; } @@ -59,7 +64,7 @@ function getServerSnapshot(): boolean { return false; } -export function useIsMobile(breakpoint: "sm" | "md" = "md"): boolean { +export function useIsMobile(breakpoint: "sm" | "md" | "lg" = "md"): boolean { const query = getMediaQuery(breakpoint); return useSyncExternalStore( subscribe(query),