'use client'; import {useSyncExternalStore} from 'react'; /** * Shell breakpoints. * * These are LAYOUT decisions — they choose whether Loyaly AI is an inline * panel or a sheet, whether a table renders as rows or cards. Those are React * tree differences that CSS cannot express. * * Anything that CAN be done in CSS should be: use Grid's responsive `columns` * or a Tailwind breakpoint instead of reaching for this hook. Every consumer * here costs a client-side re-render on resize. */ export type Breakpoint = | 'mobile' | 'tablet' | 'laptop' | 'desktop' | 'ultrawide'; /** * Ordered widest-first — `read()` returns the first match. * * mobile <640 phones * tablet 640–1024 * laptop 1024–1440 * desktop 1440–1920 * ultrawide >1920 content gets capped rather than stretched */ export const BREAKPOINT_MIN = { ultrawide: 1920, desktop: 1440, laptop: 1024, tablet: 640, mobile: 0, } as const; const QUERIES: [Breakpoint, string][] = [ ['ultrawide', `(min-width: ${BREAKPOINT_MIN.ultrawide}px)`], ['desktop', `(min-width: ${BREAKPOINT_MIN.desktop}px)`], ['laptop', `(min-width: ${BREAKPOINT_MIN.laptop}px)`], ['tablet', `(min-width: ${BREAKPOINT_MIN.tablet}px)`], ]; function read(): Breakpoint { for (const [name, q] of QUERIES) { if (window.matchMedia(q).matches) return name; } return 'mobile'; } function subscribe(onChange: () => void) { const mqls = QUERIES.map(([, q]) => window.matchMedia(q)); mqls.forEach((m) => m.addEventListener('change', onChange)); return () => mqls.forEach((m) => m.removeEventListener('change', onChange)); } /** * SSR resolves to 'desktop'. The shell is flex-based, so when a narrow client * corrects on the first frame the only visible change is the Loyaly AI panel * dropping out — no reflow of the content column. */ export function useBreakpoint(): Breakpoint { return useSyncExternalStore(subscribe, read, () => 'desktop'); } /** * True when the side nav renders as an inline rail. Below this AppShell moves * it into the drawer, which is also the point where the workspace shell has to * put the top bar back at the shell root — see (workspace)/layout.tsx. * * Must stay in sync with AppShell's own `mobileNav.breakpoint`, which is * 'sm' (640px) — the same line as the mobile/tablet boundary. */ export function isSideNavInline(bp: Breakpoint): boolean { return bp !== 'mobile'; } /** * True when Loyaly AI renders as a third column. * * Tablet is excluded deliberately: at 640–1024 a 320px panel leaves under * 400px of workspace once the rail is out, which is narrower than a single * chart needs. There it becomes a sheet, same as on mobile. */ export function isPanelInline(bp: Breakpoint): boolean { return bp === 'laptop' || bp === 'desktop' || bp === 'ultrawide'; } /** Loyaly AI panel width. Laptop trades panel width for workspace. */ export function assistantWidth(bp: Breakpoint): number { if (bp === 'ultrawide') return 420; if (bp === 'desktop') return 380; return 320; } /** * Columns for a four-up metric row. * * Four KPIs must lay out 4-up, 2×2, or stacked — never 3+1, which reads as a * broken grid. Grid's `repeat: 'fit'` cannot express that: at a ~800px * workspace it yields exactly three tracks whatever minWidth you choose, and * the fourth card drops to an orphan row. So the count is computed. */ export function metricColumns(bp: Breakpoint, isPanelOpen: boolean): number { if (bp === 'mobile') return 1; if (bp === 'tablet') return 2; if (bp === 'laptop') return 2; // Desktop and ultrawide fit four unless Loyaly AI is taking 380–420px. return isPanelInline(bp) && isPanelOpen ? 2 : 4; } /** * Max content width above `desktop`. * * On a 2560px monitor an uncapped workspace stretches a 30-day line chart * across ~1900px, which flattens every trend it is supposed to show, and runs * body text past the ~90ch where reading breaks down. Capping and centring * costs nothing below the cap. */ export function contentMaxWidth(bp: Breakpoint): number | undefined { return bp === 'ultrawide' ? 1600 : undefined; }