fix(web): use dynamic viewport height for app shell (#5500)

* fix(web): use dynamic viewport height for app shell

On mobile web browsers, `100vh`/`h-screen` resolves to the large
viewport (as if the URL bar were hidden), so the app shell rendered
~40-100px taller than the visible area while the URL bar was shown,
forcing the user to scroll to see the full view.

Switch the app-shell heights (body, #root, .app-layout, the App root,
and the web connect/suspense screens) to dynamic viewport units
(dvh) so they track the currently-visible viewport. In Electron there
is no browser chrome, so dvh resolves identically to the window height
— native is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(web): guard dynamic viewport shell heights

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
gsxdsm 2026-06-17 04:16:29 +08:00 committed by GitHub
parent ef04f10dad
commit 53873f23fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 46 additions and 6 deletions

View File

@ -1855,7 +1855,7 @@ function App(): React.JSX.Element {
return (
<div
ref={setAppRootNode}
className="flex flex-col h-screen w-screen overflow-hidden"
className="flex flex-col h-dvh w-screen overflow-hidden"
style={
{
'--collapsed-sidebar-header-width': `${collapsedSidebarHeaderWidth}px`,

View File

@ -317,7 +317,11 @@
margin: 0;
padding: 0;
overflow: hidden;
height: 100vh;
/* Why: dvh tracks the visible viewport so the web app shell isn't taller
than the screen when a mobile browser's URL bar is shown (100vh = the
large viewport, which forces a ~40-100px scroll). In Electron there is
no browser chrome, so dvh resolves identically to the window height. */
height: 100dvh;
font-family: var(
--app-font-family,
'Geist',
@ -506,7 +510,7 @@
/* ── Layout ──────────────────────────────────────────── */
#root {
height: 100vh;
height: 100dvh;
width: 100vw;
overflow: hidden;
}
@ -514,7 +518,7 @@
.app-layout {
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh;
width: 100vw;
overflow: hidden;
}

View File

@ -71,7 +71,7 @@ export default function WebConnect({
}
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4 py-6 text-foreground">
<div className="flex min-h-dvh items-center justify-center bg-background px-4 py-6 text-foreground">
<div className="flex w-full max-w-[520px] flex-col gap-5 rounded-lg border border-border bg-card p-5 shadow-sm">
<div className="flex items-start gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-md border border-border bg-muted">

View File

@ -46,7 +46,7 @@ function WebRoot(): React.JSX.Element {
installWebPreloadApi()
return (
<Suspense fallback={<div className="min-h-screen bg-background" />}>
<Suspense fallback={<div className="min-h-dvh bg-background" />}>
<App />
</Suspense>
)

View File

@ -0,0 +1,36 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
function readSource(relativePath: string): string {
return readFileSync(join(process.cwd(), relativePath), 'utf8')
}
function cssBlock(css: string, selector: string): string {
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return css.match(new RegExp(`${escapedSelector}\\s*\\{(?<body>[^}]*)\\}`))?.groups?.body ?? ''
}
describe('web viewport shell', () => {
it('uses dynamic viewport height for document and app shell containers', () => {
const css = readSource('src/renderer/src/assets/main.css')
for (const selector of ['body', '#root', '.app-layout']) {
const block = cssBlock(css, selector)
expect(block).toContain('height: 100dvh;')
expect(block).not.toMatch(/height:\s*100vh\b/)
}
})
it('uses dynamic viewport Tailwind utilities for web shell entry points', () => {
const source = [
readSource('src/renderer/src/App.tsx'),
readSource('src/renderer/src/web/main.tsx'),
readSource('src/renderer/src/web/WebConnect.tsx')
].join('\n')
expect(source).toContain('h-dvh')
expect(source).toContain('min-h-dvh')
expect(source).not.toMatch(/\b(?:min-)?h-screen\b/)
})
})