126 lines
4.1 KiB
TypeScript
126 lines
4.1 KiB
TypeScript
'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;
|
||
}
|