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:
SnapOtter
2026-07-17 23:53:26 +08:00
committed by GitHub
parent 80330a8b07
commit 3f7214bac2
7 changed files with 53 additions and 8 deletions
+3 -3
View File
@@ -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",
)}
>
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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)}
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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>
+40
View File
@@ -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);
});
}
});