diff --git a/src/admin/components/odin/OdinChat.tsx b/src/admin/components/odin/OdinChat.tsx index 8950490..1850d59 100644 --- a/src/admin/components/odin/OdinChat.tsx +++ b/src/admin/components/odin/OdinChat.tsx @@ -437,8 +437,13 @@ export default function OdinChat() { // page never scrolls and the chat's message list scrolls internally. // Desktop: svh === vh. // Mobile is immersive (AppShell hides its header and main padding on - // /odin): the chat owns the whole viewport, full-bleed. - height: { xs: "100svh", md: "calc(100svh - 100px)" }, + // /odin): the chat owns the whole viewport, full-bleed. --app-height + // is AppShell's live window.innerHeight measurement — standalone-PWA + // viewports misreport svh/dvh (MIUI), only the measured value fits. + height: { + xs: "var(--app-height, 100svh)", + md: "calc(100svh - 100px)", + }, display: "flex", // Longhand on purpose: a responsive `border` shorthand lands in a // media query AFTER borderColor and resets the color to black. diff --git a/src/admin/ui/AppShell.tsx b/src/admin/ui/AppShell.tsx index df71825..f1e7f5a 100644 --- a/src/admin/ui/AppShell.tsx +++ b/src/admin/ui/AppShell.tsx @@ -28,6 +28,27 @@ export default function AppShell() { // button. Desktop keeps the normal shell. const immersiveOnMobile = location.pathname === "/odin"; + // Installed-PWA (standalone) viewports lie to CSS units: on MIUI/Android + // svh/dvh are computed against a viewport that includes system UI the real + // layout viewport doesn't have, leaving scroll room no unit can remove + // (and Chrome can serve a stale viewport after minimize/restore). Measure + // the truth — window.innerHeight — into --app-height and keep it fresh; + // immersive layouts size from it with an svh fallback. + useEffect(() => { + if (!immersiveOnMobile) return; + const root = document.documentElement; + const set = () => + root.style.setProperty("--app-height", `${window.innerHeight}px`); + set(); + window.addEventListener("resize", set); + window.visualViewport?.addEventListener("resize", set); + return () => { + window.removeEventListener("resize", set); + window.visualViewport?.removeEventListener("resize", set); + root.style.removeProperty("--app-height"); + }; + }, [immersiveOnMobile]); + const handleLogout = useCallback(() => { setLoggingOut(true); setMobileOpen(false); @@ -92,7 +113,7 @@ export default function AppShell() { // makes body scroll impossible regardless of dvh interpretation. minHeight: immersiveOnMobile ? { xs: 0, md: "100dvh" } : "100dvh", ...(immersiveOnMobile && { - height: { xs: "100svh", md: "auto" }, + height: { xs: "var(--app-height, 100svh)", md: "auto" }, overflow: { xs: "hidden", md: "visible" }, }), }}