import { Sheet, SheetContent, SheetTrigger, } from "@/components/ui/overlay"; import { useAuth } from "@clerk/react"; import { Link, useLocation } from "wouter"; import { CimbaWordmark } from "@/components/ui/cimba-wordmark"; import { helpIdForRoute } from "@/lib/help/route-to-help-id"; import { Button } from "@/components/ui/button"; import { TooltipProvider } from "@/components/ui/tooltip"; import { Menu } from "lucide-react"; import { useEffect, useState } from "react"; import { closeFloating, pinSidebar, togglePinnedCollapsed, useSidebarState, } from "@/lib/sidebar-mode"; import { useHandedness } from "@/hooks/use-handedness"; import { useShouldUseCustomTitleBar } from "@/lib/pwa-title-bar"; import { Sidebar } from "./sidebar"; import { MobileBreadcrumb } from "./mobile-breadcrumb"; import { UtilityCluster } from "./utility-cluster"; import { useLiveUpdates } from "@/hooks/use-live-updates"; import { SystemNotificationsBridge } from "@/components/notifications/system-notifications-bridge"; import { NotificationsFirstRunPrompt } from "@/components/notifications/notifications-first-run-prompt"; import { usePushSubscriptionLifecycle } from "@/hooks/use-push-subscription-lifecycle"; import { HelpFeedbackCluster, HelpFeedbackInlineLink, useHelpFeedbackHidden, } from "@/components/help/help-feedback-cluster"; import { TopProgressBar } from "@/components/top-progress-bar"; import { SnapshotProgressInline } from "@/components/snapshot-progress"; import { BackgroundActivityPill } from "@/components/background-activity-pill"; import { HelpDialog } from "@/components/help/help-dialog"; import { HelpPickerOverlay } from "@/components/help/help-picker-overlay"; import { HelpProvider } from "@/components/help/help-provider"; import { BillingBanner } from "@/components/billing-banner"; import { usePracticeMode } from "@/lib/practiceMode"; import { useTenant } from "@/lib/tenantContext"; import { BreadcrumbProvider } from "@/lib/breadcrumb"; import { ModalDockProvider, ModalDock } from "@/lib/modal-dock-context"; /** * Task #1149 — thin wrapper that plays a fade + slight slide-up whenever * the route changes. Excluded from the layout: sidebar, header, and the * status bar at the bottom are all outside this component and are never * part of the transition. The `key={location}` re-mounts the wrapper on * every navigation event so the tailwindcss-animate enter classes * (`animate-in fade-in-0 slide-in-from-top-1`) replay. The * `motion-reduce:animate-none` modifier short-circuits the animation * for users with `prefers-reduced-motion: reduce`. */ function PageContent({ location, children, }: { location: string; children: React.ReactNode; }) { return ( // Task #1506 — `w-full clear-both` here is the shared root-cause fix. // The page-route Suspense fallback adds a floating spinner sibling that // would otherwise let the next page's table collapse to its content // width; clearing floats + forcing the wrapper to stretch keeps list // tables full-width on every route. The per-page `w-full clear-both` // on `overflow-x-auto` wrappers is belt-and-braces for tables that // live inside their own non-flex `` shells.
{children}
); } export function AppLayout({ children }: { children: React.ReactNode }) { const { isSignedIn } = useAuth(); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); // Reflects whether the user has fully hidden the floating Help & // Feedback cluster (third visibility state — beyond expanded / // collapsed). The footer "Help & Feedback" inline link is only // shown in that state, so it's the dedicated recovery path back // from "hidden". When the cluster is visible (either as the round // button or expanded pill) the link stays out of the status bar so // there's only one entry point on screen. const helpFeedbackHidden = useHelpFeedbackHidden(); // Three-mode sidebar: pinned-expanded (full docked), pinned-collapsed // (icon strip), or floating (undocked dropdown anchored to the title // bar logo). Persisted in localStorage so the user lands back on // their preferred docking each load. The mobile drawer always // renders the sidebar expanded — collapsed/floating only apply to // the desktop md+ layout. const sidebar = useSidebarState(); const useCustomTitleBar = useShouldUseCustomTitleBar(); // Floating mode only makes sense when the title bar is present // (that's where the trigger lives). On non-WCO browsers we treat a // stored "floating" mode as "pinned-expanded" so the user always // has a way to reach the menu — otherwise opening the app in a // regular browser tab after using PWA mode would leave them with // no nav at all. const effectiveMode = sidebar.mode === "floating" && !useCustomTitleBar ? "pinned-expanded" : sidebar.mode; const isFloating = effectiveMode === "floating"; const sidebarCollapsed = effectiveMode === "pinned-collapsed"; const floatingOpen = isFloating && sidebar.floatingOpen; const [location] = useLocation(); const isPopup = new URLSearchParams(window.location.search).get("popup") === "true"; const practice = usePracticeMode(); const tenant = useTenant(); // Sidebar palette has two orthogonal axes: // 1. Base palette — chambers/barrister identity. Practice mode (barrister // demo) always wins so the user's manual override isn't silently // replaced; otherwise we use the active tenant's role so the colour // tracks tenant switches and superadmin impersonation correctly. // 2. Elevation stripe — additive thin left-edge bar that signals // elevated privileges (superadmin → royal purple, tenant admin → // deep wine) WITHOUT overwriting the base palette. This way an // admin barrister still reads as a barrister at a glance, with // the stripe telling them they have admin powers. `isSupport` // is platform-only read access and intentionally gets no stripe. const basePaletteClass = practice.isBarristerMode || tenant.activeRole === "barrister" ? "role-barrister" : ""; const elevationClass = tenant.isSuperadmin ? "role-elevation-superadmin" : tenant.isTenantAdmin ? "role-elevation-admin" : (practice.isBarristerMode || tenant.activeRole === "barrister") ? "role-elevation-barrister" : "role-elevation-staff"; const roleClass = [basePaletteClass, elevationClass].filter(Boolean).join(" "); // Task #1373 — single role-aware sidebar; `practice.isBarristerMode` is // now consumed inside Sidebar itself rather than branching here. const SidebarComponent = Sidebar; const pageHelpId = helpIdForRoute(location); // Task #1150 — read the user's handedness preference so we can place the // hamburger button and sidebar drawer on the same side as their dominant // hand. Right-handed (default) → button + drawer on the right. // Left-handed → button + drawer on the left. const handedness = useHandedness(); // Close the floating dropdown when the user navigates to a new // page — otherwise the menu would stay open over the destination, // which feels broken. Pin / collapse toggles already close it via // the store actions. useEffect(() => { if (floatingOpen) closeFloating(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [location]); // Dismiss the floating dropdown on Escape — matches the implicit // contract of every other Radix popover in the app. useEffect(() => { if (!floatingOpen) return; function onKey(e: KeyboardEvent) { if (e.key === "Escape") closeFloating(); } window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [floatingOpen]); useLiveUpdates(); // Task #601 — keep the server's WebPush subscription record in sync // with the user's current opt-in / permission state. usePushSubscriptionLifecycle(); if (!isSignedIn) { return <>{children}; } if (isPopup) { return (
{children}
); } return ( {/* TooltipProvider wraps the whole shell so collapsed-sidebar nav icons (and any other tooltip-using components) can render tooltips without each call site re-declaring a provider. */} {/* Task #601 — bridges new SSE notification events to OS-level Web Notifications and prompts the user to opt in on first run. Both render nothing visible until the user interacts. */} {/* Outer shell height: use the dynamic viewport (`100dvh`) so the mobile URL bar collapsing/expanding doesn't leave a stray scrollbar, and subtract the published title-bar height so we fit inside the visible region when the WCO custom title bar is mounted (`tabbed-router-shell` already trims the outer window by the same amount). The variable is unset (→ 0px) in regular browser tabs and on mobile, so the calc collapses to `100dvh` there. Without this the footer would push the shell past the viewport and an extra scrollbar would appear on every page. */}
{/* Desktop Sidebar — only docked when the mode is pinned. In floating mode the aside is hidden entirely so the main content fills the full window width; the menu lives in the floating dropdown anchored to the title bar logo (rendered below). Width transitions between expanded (w-64, 16rem) and collapsed (w-16, 4rem ≈ icon strip) on `width` only so the inner sidebar contents can re-layout instantly without a janky text fade. */} {!isFloating && ( )} {/* Mobile Header & Sidebar — single inline row. Task #1150: layout adapts to handedness preference. Right-handed (default): [Cimba logo] [breadcrumb] [bell] [timer] [hamburger ▶] Left-handed: [◀ hamburger] [Cimba logo] [breadcrumb] [bell] [timer] The breadcrumb used to live on its own second row; folding it inline keeps the chrome compact on small screens while still surfacing where the user is in the app. */} {/* `relative z-30` keeps the mobile header — and crucially the hamburger button inside it — painted above any sibling overlay (e.g. the BillingBanner absolute wrapper in
, which is z-20 with a `pointer-events-auto` inner). Without this, taps near the right edge of the screen could be absorbed by the banner layer instead of the menu button. */}
{/* Task #1150 — hamburger on the LEFT for left-handed users */} {handedness === "left" && ( {/* The role class (`role-barrister` etc.) lives on the shell wrapper above, but SheetContent portals to document.body and so escapes that scope — without re-applying the class here the mobile drawer would always render in the default Chambers palette even when the user is in barrister mode. Task #1150: subtle shadow on inner edge so the drawer reads clearly as a floating layer. Left drawer → shadow on the right edge. */} setMobileMenuOpen(false)} /> )} {/* Wrap the breadcrumb in the flex-1 spacer rather than relying on the breadcrumb itself to claim the remaining space — `MobileBreadcrumb` returns null on routes with no useful trail (e.g. the dashboard root), and without this wrapper the bell / timer / hamburger would bunch left against the Cimba logo. */}
{/* Mobile chrome utility cluster — search, timer, bell. Shares UtilityCluster with the desktop overlay and the PWA title bar so the three icons always appear together in the same order on every surface. */} {/* Task #1150 — hamburger on the RIGHT for right-handed users (default) */} {handedness !== "left" && ( {/* Task #1150: subtle shadow on inner edge so the drawer reads clearly as a floating layer. Right drawer → shadow on the left edge. */} setMobileMenuOpen(false)} /> )}
{/* Main Content */}
{/* Subscription banner overlays the top of the main pane so its presence/absence never shifts page content up or down (it's now dismissable, so we explicitly do NOT want a relayout when the user closes it). Right-padding reserves clearance so the banner never tucks under the bell/timer cluster, and `z-20` keeps it below the cluster (z-30) so the dismiss + Manage billing buttons can't be obscured by the cluster either. */}
{/* Desktop bell + timer cluster fallback — only when the custom title bar isn't mounted (regular browser tab, no Window Controls Overlay). Anchored to the non-scrolling `
` element (rather than the inner scroll container) so it stays pinned at the top of the visible pane while the page content scrolls behind it. `z-30` puts it above page-level sticky bulk-action bars (which use `z-20`) AND above the billing banner (z-20), so the cluster is always reachable. The inner `md:pr-32` reserves horizontal space and `md:pt-12` reserves vertical space on the children wrapper so long headings and right-aligned page action buttons stay clear of the cluster. */} {!useCustomTitleBar && (
)}
{/* Bell + timer cluster clearance — only when the cluster is actually overlaid on this pane (non-WCO desktop). The previous approach wrapped the whole children tree in `md:pr-32 md:pt-12`, which reserved a 128px gutter all the way down the page, leaving a tall empty column on the right of every screen. A `float-right` spacer here only occupies the top-right corner: the page's first row of content (titles, action buttons, etc.) flows around it as if it were a sidebar word-wrap, and any content that extends past the spacer's height fills the full pane width. The spacer matches the cluster's bounding box: `top-3 right-4 md:right-8` + two ~36px icon buttons + a small backdrop pill ≈ 8rem wide × 3rem tall (h-12 / w-32, the same dimensions the old padding was using). `aria-hidden` because it carries no semantics. */} {!useCustomTitleBar && ( {/* Status bar — progress strip sits absolutely at the top of this relative container so it doesn't affect the height of the text row below it. Fixed height (h-6) + `whitespace-nowrap` + `overflow-hidden` so the bar can never word-wrap onto a second line at narrow widths. The version segment uses `truncate` + `min-w-0` so it's the piece that gets clipped if there genuinely isn't room — everything to its left (snapshot/activity pills and the Help & Feedback link) stays fully readable. */}
{/* Only show the inline "Help & Feedback" link when the floating cluster has been fully hidden by the user (via the EyeOff button in the expanded pill). In the default and collapsed states the round help button is already visible, so duplicating it here would be redundant noise. */} {helpFeedbackHidden && ( <> )} {__APP_VERSION__} · Published {new Date(__BUILD_TIME__).toLocaleString("en-AU", { day: "2-digit", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit", hour12: true })}
{/* Floating sidebar dropdown. Only mounted when the user is in floating mode AND has clicked the title-bar logo to open it. Rendered as a fixed-position panel in the top-left corner directly under the title bar. A backdrop catches outside clicks (no visible scrim — the user wanted the menu to feel weightless, not modal). IMPORTANT: this dropdown is ALWAYS anchored to the left edge (`left-2`) — it does NOT honour `handedness`. The anchor must visually drop out from underneath the dark Cimba logo in the title bar, which is itself always on the left. Handedness only flips the mobile drawer (Sheet) above, not this desktop/PWA dropdown. */} {floatingOpen && ( <> ); }