diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index ce39c26f..7287e2a6 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -1,5 +1,5 @@ import { redirect } from 'next/navigation'; -import { headers } from 'next/headers'; +import { cookies, headers } from 'next/headers'; import { eq } from 'drizzle-orm'; import { auth } from '@/lib/auth'; @@ -37,7 +37,20 @@ export default async function DashboardLayout({ children }: { children: React.Re ? await db.query.ports.findMany({ orderBy: portsTable.name }) : portRoles.map((pr) => pr.port); - const initialFormFactor = classifyFormFactor(headerList.get('user-agent')); + // Prefer a previously-resolved tier from the client's cookie so the + // server renders the matching shell on first paint — eliminates the + // mobile↔desktop chrome flicker that happens when UA-based classification + // disagrees with the actual viewport (e.g. macOS Safari with the + // window dragged below 1024). AppShell writes the cookie after the + // first matchMedia evaluation; subsequent reloads use the hint. + const cookieStore = await cookies(); + const tierCookie = cookieStore.get('pn-crm.viewport-tier')?.value; + const initialFormFactor: 'mobile' | 'desktop' = + tierCookie === 'mobile' + ? 'mobile' + : tierCookie === 'tablet' || tierCookie === 'desktop' + ? 'desktop' + : classifyFormFactor(headerList.get('user-agent')); const user = { name: profile?.displayName ?? session.user.name ?? session.user.email, email: session.user.email, diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx index c6046290..2b0f6f1a 100644 --- a/src/components/layout/app-shell.tsx +++ b/src/components/layout/app-shell.tsx @@ -100,9 +100,15 @@ export function AppShell({ 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'); + const next: Tier = mqMobile.matches ? 'mobile' : mqTablet.matches ? 'tablet' : 'desktop'; + setTier(next); + // Persist for the next SSR pass so the server renders the + // matching shell on first paint — eliminates the chrome flicker + // on refresh when UA-based classification disagrees with the + // actual viewport (most common on macOS Safari at sub-1024 + // widths). 1-year expiry; SameSite=Lax is fine since the cookie + // is read by our own server only. + document.cookie = `pn-crm.viewport-tier=${next}; path=/; max-age=31536000; SameSite=Lax`; }; update(); mqMobile.addEventListener('change', update);