diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 7f52c2a..cffe9ac 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,6 +1,8 @@
import type { Metadata, Viewport } from 'next';
+import { headers } from 'next/headers';
import { Inter, JetBrains_Mono } from 'next/font/google';
import { Toaster } from 'sonner';
+import { classifyFormFactor } from '@/lib/form-factor';
import './globals.css';
const inter = Inter({
@@ -42,10 +44,16 @@ export const metadata: Metadata = {
},
};
-export default function RootLayout({ children }: { children: React.ReactNode }) {
+export default async function RootLayout({ children }: { children: React.ReactNode }) {
+ const headerList = await headers();
+ const formFactor = classifyFormFactor(headerList.get('user-agent'));
+
return (
-
+
{children}
diff --git a/src/lib/form-factor.ts b/src/lib/form-factor.ts
new file mode 100644
index 0000000..7d3b735
--- /dev/null
+++ b/src/lib/form-factor.ts
@@ -0,0 +1,14 @@
+export type FormFactor = 'mobile' | 'desktop';
+
+const MOBILE_TOKENS = ['Mobile', 'iPhone', 'iPad', 'Android'] as const;
+
+/**
+ * Classify a User-Agent string as 'mobile' or 'desktop'.
+ * Defaults to 'desktop' when the UA is missing or unrecognized — the CSS
+ * media-query fallback in globals.css handles desktop browsers resized below
+ * the lg breakpoint, so a wrong-but-defaultish classification never breaks UX.
+ */
+export function classifyFormFactor(userAgent: string | null | undefined): FormFactor {
+ if (!userAgent) return 'desktop';
+ return MOBILE_TOKENS.some((token) => userAgent.includes(token)) ? 'mobile' : 'desktop';
+}
diff --git a/tests/unit/lib/form-factor.test.ts b/tests/unit/lib/form-factor.test.ts
new file mode 100644
index 0000000..8d43405
--- /dev/null
+++ b/tests/unit/lib/form-factor.test.ts
@@ -0,0 +1,50 @@
+import { describe, it, expect } from 'vitest';
+import { classifyFormFactor } from '@/lib/form-factor';
+
+describe('classifyFormFactor', () => {
+ it('returns "mobile" for an iPhone UA', () => {
+ expect(
+ classifyFormFactor(
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148',
+ ),
+ ).toBe('mobile');
+ });
+
+ it('returns "mobile" for an iPad UA', () => {
+ expect(
+ classifyFormFactor(
+ 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148',
+ ),
+ ).toBe('mobile');
+ });
+
+ it('returns "mobile" for an Android UA', () => {
+ expect(
+ classifyFormFactor(
+ 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Mobile Safari/537.36',
+ ),
+ ).toBe('mobile');
+ });
+
+ it('returns "desktop" for a Mac Safari UA', () => {
+ expect(
+ classifyFormFactor(
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 Version/17.0 Safari/605.1.15',
+ ),
+ ).toBe('desktop');
+ });
+
+ it('returns "desktop" for a Linux Chrome UA', () => {
+ expect(
+ classifyFormFactor(
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36',
+ ),
+ ).toBe('desktop');
+ });
+
+ it('returns "desktop" for missing UA', () => {
+ expect(classifyFormFactor(null)).toBe('desktop');
+ expect(classifyFormFactor(undefined)).toBe('desktop');
+ expect(classifyFormFactor('')).toBe('desktop');
+ });
+});