diff --git a/canvas/src/app/layout.tsx b/canvas/src/app/layout.tsx index 21ec7962..04786994 100644 --- a/canvas/src/app/layout.tsx +++ b/canvas/src/app/layout.tsx @@ -1,6 +1,22 @@ import type { Metadata } from "next"; +import { Inter, JetBrains_Mono } from "next/font/google"; import { cookies, headers } from "next/headers"; import "./globals.css"; + +// Self-hosted at build time → CSP-safe (font-src 'self' covers them +// because Next.js serves the .woff2 from /_next/static). Exposed as +// CSS variables so the mobile palette can reference them without +// importing this module. +const interFont = Inter({ + subsets: ["latin"], + display: "swap", + variable: "--font-inter", +}); +const monoFont = JetBrains_Mono({ + subsets: ["latin"], + display: "swap", + variable: "--font-jetbrains", +}); import { AuthGate } from "@/components/AuthGate"; import { CookieConsent } from "@/components/CookieConsent"; import { PurchaseSuccessModal } from "@/components/PurchaseSuccessModal"; @@ -79,7 +95,7 @@ export default async function RootLayout({ dangerouslySetInnerHTML={{ __html: themeBootScript }} /> - + {/* AuthGate is a client component; it checks the session on mount and bounces anonymous users to the control plane's login page diff --git a/canvas/src/app/page.tsx b/canvas/src/app/page.tsx index 0bf8f62c..28cb37d9 100644 --- a/canvas/src/app/page.tsx +++ b/canvas/src/app/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { Canvas } from "@/components/Canvas"; import { Legend } from "@/components/Legend"; import { CommunicationOverlay } from "@/components/CommunicationOverlay"; +import { MobileApp } from "@/components/mobile/MobileApp"; import { Spinner } from "@/components/Spinner"; import { connectSocket, disconnectSocket } from "@/store/socket"; import { useCanvasStore } from "@/store/canvas"; @@ -14,6 +15,23 @@ export default function Home() { const hydrationError = useCanvasStore((s) => s.hydrationError); const setHydrationError = useCanvasStore((s) => s.setHydrationError); const [hydrating, setHydrating] = useState(true); + // < 640px viewport renders the dedicated mobile shell instead of the + // desktop canvas. Tri-state: `null` until matchMedia has resolved, + // then `true|false`. While null we keep the existing loading spinner + // up — that way mobile devices never flash the desktop tree (which + // they would if we defaulted to `false` and only flipped post-mount). + const [isMobile, setIsMobile] = useState(null); + useEffect(() => { + if (typeof window === "undefined" || !window.matchMedia) { + setIsMobile(false); + return; + } + const mq = window.matchMedia("(max-width: 639px)"); + const update = () => setIsMobile(mq.matches); + update(); + mq.addEventListener("change", update); + return () => mq.removeEventListener("change", update); + }, []); // Distinct from hydrationError: platform-down is its own UX path // (different copy, different action — the user's next step is to // check local services, not to retry the API call). Tracked @@ -51,7 +69,10 @@ export default function Home() { }; }, []); - if (hydrating) { + // Hold the spinner while data hydrates OR while the viewport + // resolution hasn't settled yet (avoids a desktop-tree flash on + // mobile devices between SSR-paint and matchMedia). + if (hydrating || isMobile === null) { return (
@@ -66,6 +87,32 @@ export default function Home() { return ; } + if (isMobile) { + return ( + <> + + {hydrationError && ( +
+

{hydrationError}

+ +
+ )} + + ); + } + return ( <> diff --git a/canvas/src/components/Canvas.tsx b/canvas/src/components/Canvas.tsx index 5983b72f..888343b0 100644 --- a/canvas/src/components/Canvas.tsx +++ b/canvas/src/components/Canvas.tsx @@ -308,7 +308,9 @@ function CanvasInner() { showInteractive={false} />