diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx index 8d8b5061..c6046290 100644 --- a/src/components/layout/app-shell.tsx +++ b/src/components/layout/app-shell.tsx @@ -10,6 +10,8 @@ import { MobileTopbar } from '@/components/layout/mobile/mobile-topbar'; import { MobileBottomTabs } from '@/components/layout/mobile/mobile-bottom-tabs'; import { MoreSheet } from '@/components/layout/mobile/more-sheet'; import { MobileSearchOverlay } from '@/components/search/mobile-search-overlay'; +import { Sheet, SheetContent } from '@/components/ui/sheet'; +import { useUIStore } from '@/stores/ui-store'; type SidebarProps = ComponentProps; type TopbarProps = ComponentProps; @@ -32,30 +34,51 @@ interface AppShellProps { children: ReactNode; } -const MOBILE_QUERY = '(max-width: 1023.98px)'; +// Three tiers, aligned with Tailwind and the `useViewportTier` hook: +// mobile : < 768px (sm and below) → mobile shell (bottom tabs, slide-over more sheet) +// tablet : 768-1023 (md) → desktop shell, sidebar wrapped in Sheet, logo trigger in topbar +// desktop : >= 1024 (lg and up) → desktop shell, sidebar always visible +// +// Previously the app used a binary `(max-width: 1023.98px)` split, which +// rendered the mobile shell on iPad portrait AND on a half-screen 13" +// browser. Neither is really "mobile"; the tablet tier fills that gap. +const MOBILE_QUERY = '(max-width: 767.98px)'; +const TABLET_QUERY = '(min-width: 768px) and (max-width: 1023.98px)'; + +type Tier = 'mobile' | 'tablet' | 'desktop'; + +function computeInitialTier(initialFormFactor: 'mobile' | 'desktop'): Tier { + // Server UA classification is binary; we can only distinguish "looks + // like a phone/tablet UA" from "looks like a desktop UA." Tablets get + // classified as 'mobile' by the UA token check (iPad/Android), so + // initialFormFactor=mobile on first paint covers both mobile + tablet. + // The matchMedia subscription below corrects to the precise tier on + // hydration without a flash because useSyncExternalStore is mismatch-safe. + return initialFormFactor === 'mobile' ? 'mobile' : 'desktop'; +} /** * #26 + H-09: single-tree responsive shell with stable children subtree. * * The shell renders ONE `
` and ONE `` at all - * viewports — only the chrome (sidebar+topbar vs mobile-topbar+bottom-tabs) - * conditionally renders. Two payoffs: + * viewports — only the chrome (sidebar+topbar vs mobile-topbar+bottom-tabs + * vs tablet's hidden-sidebar-via-Sheet) conditionally renders. Three payoffs: * - * - #26 / first ship: no double-mount of chrome subtrees (Sidebar + - * MobileTopbar both running fetches / providers in parallel like the - * old layout did). + * - #26 / first ship: no double-mount of chrome subtrees. * - H-09: `{children}` stays mounted across viewport flips. A rep * editing an inline field on desktop who resizes through the mobile - * breakpoint no longer loses the draft mid-edit — the children tree's - * position in the DOM is invariant, so React preserves its state. + * breakpoint no longer loses the draft mid-edit. + * - Tier-aware sidebar: tablet width gets the desktop shell with + * sidebar hidden behind a Sheet (slide-over from left, opened by + * the topbar logo button) instead of falling all the way back to + * the mobile shell. Closes the half-screen-on-13"-Mac usability gap. * * The mobile-only floating panels (MoreSheet, MobileSearchOverlay) only - * mount in the mobile branch — they have no desktop counterpart and would - * be wasteful to keep mounted otherwise. + * mount in the mobile branch — they have no desktop counterpart. * * SSR safety: the server passes its UA-classified hint via `initialFormFactor`; * the first client render uses the same value so hydration matches. After - * mount, a matchMedia subscription overrides if the viewport disagrees. + * mount, a matchMedia subscription overrides to the precise tier. */ export function AppShell({ portRoles, @@ -66,35 +89,69 @@ export function AppShell({ initialFormFactor, children, }: AppShellProps) { - const [isMobile, setIsMobile] = useState(initialFormFactor === 'mobile'); + const [tier, setTier] = useState(computeInitialTier(initialFormFactor)); const [moreOpen, setMoreOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false); + const [tabletSidebarOpen, setTabletSidebarOpen] = useState(false); + const currentPortSlug = useUIStore((s) => s.currentPortSlug); + const logoUrl = currentPortSlug ? portLogoUrls[currentPortSlug] : null; useEffect(() => { - const mq = window.matchMedia(MOBILE_QUERY); - const update = () => setIsMobile(mq.matches); + const mqMobile = window.matchMedia(MOBILE_QUERY); + const mqTablet = window.matchMedia(TABLET_QUERY); + const update = () => { + if (mqMobile.matches) setTier('mobile'); + else if (mqTablet.matches) setTier('tablet'); + else setTier('desktop'); + }; update(); - mq.addEventListener('change', update); - return () => mq.removeEventListener('change', update); + mqMobile.addEventListener('change', update); + mqTablet.addEventListener('change', update); + return () => { + mqMobile.removeEventListener('change', update); + mqTablet.removeEventListener('change', update); + }; }, []); - // Build the chrome subtree based on form factor; the children's parent - // chain (MobileLayoutProvider > div > main) is invariant across both - // branches, so React reconciliation keeps the children subtree mounted - // when isMobile flips. - const chrome = isMobile ? ( - <> - - - ) : ( - - ); + const isMobile = tier === 'mobile'; + const isTablet = tier === 'tablet'; + + // Close the tablet sheet when crossing breakpoints so it doesn't stay + // "open" after a resize back to desktop (Sheet keeps its open prop). + useEffect(() => { + if (!isTablet && tabletSidebarOpen) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setTabletSidebarOpen(false); + } + }, [isTablet, tabletSidebarOpen]); + + const sidebarProps: SidebarProps = { + portRoles, + isSuperAdmin, + user, + ports, + portLogoUrls, + }; + + // Chrome subtree per tier. + let chrome: ReactNode = null; + if (isMobile) { + chrome = ; + } else if (isTablet) { + // Tablet: sidebar lives inside a left-side Sheet, opened by the + // topbar's leading logo button. SheetContent has its own width; + // override to match the inline Sidebar's width so the layout + // reads consistent when the sheet is open. + chrome = ( + + + + + + ); + } else { + chrome = ; + } const footer = isMobile ? ( <> @@ -107,7 +164,46 @@ export function AppShell({ ) : null; - const desktopTopbar = !isMobile ? : null; + // Desktop topbar; on tablet it gains a leading logo button that + // opens the sidebar Sheet. + const desktopTopbar = !isMobile ? ( + setTabletSidebarOpen(true)} + className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md hover:bg-accent transition-colors" + > + {logoUrl ? ( + + ) : ( + // Neutral fallback when the port has no branding logo yet — + // a three-bar menu icon keeps the affordance discoverable. + + + + + + )} + + ) : null + } + /> + ) : null; return ( diff --git a/src/components/layout/topbar.tsx b/src/components/layout/topbar.tsx index dff2adaa..4088783f 100644 --- a/src/components/layout/topbar.tsx +++ b/src/components/layout/topbar.tsx @@ -3,6 +3,7 @@ import { ChevronLeft, Plus } from 'lucide-react'; import { useRouter, usePathname } from 'next/navigation'; import type { Route } from 'next'; +import type { ReactNode } from 'react'; import { useUIStore } from '@/stores/ui-store'; import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider'; @@ -27,9 +28,13 @@ import type { Port } from '@/lib/db/schema/ports'; interface TopbarProps { ports: Port[]; user?: { name: string; email: string }; + /** Optional leading slot rendered before the breadcrumbs on tablet + * viewports — used by AppShell to mount a sidebar trigger button + * (logo) when the sidebar is hidden behind a slide-over Sheet. */ + leadingSlot?: ReactNode; } -export function Topbar({ ports, user }: TopbarProps) { +export function Topbar({ ports, user, leadingSlot }: TopbarProps) { const router = useRouter(); const pathname = usePathname(); const currentPortSlug = useUIStore((s) => s.currentPortSlug); @@ -55,8 +60,9 @@ export function Topbar({ ports, user }: TopbarProps) { // The brand logo lives in the sidebar header (per design feedback) so the // topbar center is dedicated to the global search bar.
- {/* LEFT: optional back button + breadcrumbs / page title */} + {/* LEFT: optional sidebar trigger (tablet) + optional back button + breadcrumbs */}
+ {leadingSlot} {showBackButton && (