mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix(web): use h-dvh for app shells so mobile tool controls stay reachable (#559)
On mobile browsers 100vh is the tall viewport (URL bar retracted), so the full-height shells (h-screen + overflow-hidden) rendered their bottom strip below the visible area with no way to scroll to it. After an upload the tool page's bottom control is the "Process" peek bar, so it landed off-screen on every tool. Switch the fixed-height shells from h-screen (100vh) to h-dvh (100dvh), which tracks the visible viewport. AppLayout covers every tool page; the same sweep covers the editor, login, change-password, 404, and the error/loading screens. On desktop dvh equals vh, so desktop is unchanged. Adds a source-level guard (tests/unit/web/app-shell-viewport.test.ts) since headless Chromium has no URL bar and cannot reproduce the bug.
This commit is contained in:
@@ -64,7 +64,7 @@ class ErrorBoundary extends Component<
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background text-foreground">
|
||||
<div className="flex h-dvh items-center justify-center bg-background text-foreground">
|
||||
<div className="text-center space-y-4 max-w-md px-6">
|
||||
<h1 className="text-xl font-semibold">{en.common.somethingWentWrong}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -108,7 +108,7 @@ function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background text-foreground">
|
||||
<div className="flex h-dvh items-center justify-center bg-background text-foreground">
|
||||
<div className="text-center space-y-3">
|
||||
<div className="h-8 w-8 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto" />
|
||||
<p className="text-sm text-muted-foreground">{en.common.loading}</p>
|
||||
@@ -132,7 +132,7 @@ function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
// Single page-level loading fallback — shown while JS for a route downloads.
|
||||
function PageLoader() {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background text-foreground">
|
||||
<div className="flex h-dvh items-center justify-center bg-background text-foreground">
|
||||
<div className="h-8 w-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -35,9 +35,14 @@ export function AppLayout({ children, breadcrumb, navVariant }: AppLayoutProps)
|
||||
}, [fetchSettings]);
|
||||
|
||||
return (
|
||||
// h-dvh (dynamic viewport height), not h-screen/100vh: on mobile browsers
|
||||
// 100vh is the *tall* viewport (URL bar retracted), so with overflow-hidden
|
||||
// the fixed bottom nav and the in-flow "Process" peek bar render below the
|
||||
// visible area and can't be scrolled to after a file is uploaded. dvh tracks
|
||||
// the actual visible viewport, keeping the bottom controls reachable.
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col h-screen bg-background text-foreground overflow-hidden",
|
||||
"flex flex-col h-dvh bg-background text-foreground overflow-hidden",
|
||||
bannerVisible && "pt-9",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -118,7 +118,7 @@ export function ChangePasswordPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
<div className="flex h-dvh bg-background">
|
||||
<div className="flex-1 flex items-center justify-center p-8">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div>
|
||||
|
||||
@@ -157,7 +157,7 @@ export function EditorPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex flex-col h-screen overflow-hidden bg-background text-foreground">
|
||||
<main className="flex flex-col h-dvh overflow-hidden bg-background text-foreground">
|
||||
<h1 className="sr-only">{t.editor.welcome.heading}</h1>
|
||||
<EditorMenuBar
|
||||
onNewDocument={() => setShowNewDocument(true)}
|
||||
|
||||
@@ -246,7 +246,7 @@ export function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex h-screen bg-background">
|
||||
<main className="flex h-dvh bg-background">
|
||||
<div className="flex-1 flex items-center justify-center p-8">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div>
|
||||
|
||||
@@ -5,7 +5,7 @@ export function NotFoundPage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background text-foreground">
|
||||
<div className="flex h-dvh items-center justify-center bg-background text-foreground">
|
||||
<div className="text-center space-y-4 max-w-md px-6">
|
||||
<h1 className="text-6xl font-bold text-primary">404</h1>
|
||||
<h2 className="text-xl font-semibold">{t.common.pageNotFound}</h2>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Regression guard for the mobile "can't reach the Process button" bug.
|
||||
*
|
||||
* The full-height, overflow-hidden app shells must size to the *dynamic* viewport
|
||||
* (`h-dvh` / 100dvh), never `h-screen` (100vh). On mobile browsers 100vh is the
|
||||
* tall viewport measured with the URL bar retracted, so a 100vh shell with
|
||||
* `overflow: hidden` pushes its bottom strip (the fixed bottom nav and the in-flow
|
||||
* tool "Process" peek bar) below the visible area with no way to scroll to it.
|
||||
* `dvh` tracks the actual visible viewport, keeping those controls reachable.
|
||||
*
|
||||
* This can't be caught by a behavioral e2e test: headless Chromium has no dynamic
|
||||
* URL bar, so `100vh === visible height` there and the bug never reproduces. A
|
||||
* source-level guard is the only thing that stops a well-meaning "cleanup" from
|
||||
* reverting `h-dvh` to `h-screen`.
|
||||
*/
|
||||
const SHELL = "flex flex-col h-dvh";
|
||||
const BROKEN = "flex flex-col h-screen";
|
||||
|
||||
const SHELLS = {
|
||||
"app-layout.tsx": "../../../apps/web/src/components/layout/app-layout.tsx",
|
||||
"editor-page.tsx": "../../../apps/web/src/pages/editor-page.tsx",
|
||||
} as const;
|
||||
|
||||
describe("full-height app shells use the dynamic viewport unit", () => {
|
||||
for (const [name, rel] of Object.entries(SHELLS)) {
|
||||
const src = readFileSync(fileURLToPath(new URL(rel, import.meta.url)), "utf8");
|
||||
|
||||
it(`${name} sizes the shell with h-dvh`, () => {
|
||||
expect(src).toContain(SHELL);
|
||||
});
|
||||
|
||||
it(`${name} does not use h-screen for the shell`, () => {
|
||||
expect(src).not.toContain(BROKEN);
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user