Fix vertical mobile overflow

This commit is contained in:
Pavel committed 2026-09-26 21:25:21 +04:00
1 parent 44b7059075
commit 325c841e3b
14 files changed
+68 -22

No files matched your search

+25 -3
View File
@@ -833,9 +833,9 @@ dark:bg-black/50`).
Every phone bottom sheet has one shape: `bg-card`, 16px top corners, no top
border, `max-h-sheet` (the visible viewport less the top safe-area inset and a
3rem strip, so the scrim above it can always be tapped to close), and bottom
padding that clears the iPhone home indicator. Never cap a sheet with `vh`: on
iOS Safari it is the viewport with the toolbars hidden, so a `90vh` sheet slides
under the URL bar and covers its scrim. `SheetContent side="bottom"` gives the
padding that clears the iPhone home indicator. Never cap a sheet with `vh` (see
Viewport heights): a `90vh` sheet slides under Safari's URL bar and covers its
scrim. `SheetContent side="bottom"` gives the
shape with `pb-safe-0` (the bare inset); pass `handle` for the grab bar, which
also hides the X (the overlay dismisses it; the handle is only a cue and doesn't
drag; pass `showCloseButton` to keep an X).
@@ -916,6 +916,28 @@ values (`h-[calc(100dvh-64px)]`, `max-w-[520px]`) and motion values
padding, colours and radii in brackets are not; pick the nearest scale step
or add a token.
### Viewport heights
Never size anything with `vh` or the `h-screen` family (`h-`, `min-h-`,
`max-h-screen`, which are `100vh`). On iOS Safari `vh` is the viewport with the
toolbars hidden, so while the URL bar and tab bar show, a `vh` height is about
140px taller than the screen: a centred message sits low, a panel or dropdown
runs under the tab bar, and the app shell can be dragged and bounce. ESLint
(`no-restricted-syntax`) rejects both. Pick by what the height does:
- **`dvh`** (the visible viewport, follows the toolbars): the app shell
(`h-dvh`), full-screen states (`min-h-dvh`: 404, MobileBlocker, the `screen`
`LoadingState`), and caps on things that pop up over the page (a search
dropdown's `max-h-[calc(100dvh-200px)]`, `Modal`'s `85dvh`).
- **`svh`** (the smallest viewport, steady): a fixed-size panel inside a page
that scrolls (the Logs panel's `h-[55svh]`, a list's `max-h-[45svh]`, the
chunk editor's `min-h`), so it doesn't resize while Safari's bars slide in
and out.
- **`max-h-sheet`**: every bottom sheet (see Modal, not Dialog).
- A row or control has a fixed height (`h-12`), never a share of the viewport.
`w-screen` is `vw`, which the toolbars don't change, so it stays allowed.
### Typography roles
- **Page title**: `SectionPageHeader` (24px bold); the only other text at
+22
View File
@@ -84,6 +84,28 @@ export default [
],
},
],
// On iOS Safari vh (and h-screen, which is 100vh) is the viewport with
// the toolbars hidden, so a vh height overflows the visible screen.
// w-screen is vw, which the toolbars don't change, so it stays allowed.
'no-restricted-syntax': [
'error',
...[
'Literal[value=/\\dvh\\b/]',
'TemplateElement[value.raw=/\\dvh\\b/]',
].map((selector) => ({
selector,
message:
'vh is the toolbar-hidden viewport on iOS Safari. Use dvh for the app shell and caps on things that pop up, svh for a fixed-size panel in a page that scrolls, or max-h-sheet for a bottom sheet. See DESIGN.md.',
})),
...[
'Literal[value=/(^|[\\s:])(min-|max-)?h-screen\\b/]',
'TemplateElement[value.raw=/(^|[\\s:])(min-|max-)?h-screen\\b/]',
].map((selector) => ({
selector,
message:
"Tailwind's screen heights (h-/min-h-/max-h-screen) are the toolbar-hidden viewport on iOS Safari, taller than the visible screen. Use h-dvh / min-h-dvh, or svh for a fixed panel. See DESIGN.md.",
})),
],
// Design-system rules (@shadcn/lint). Tokens, variants and the
// approved exceptions are documented in DESIGN.md.
'shadcn/no-restyle': [
+3 -3
View File
@@ -64,7 +64,7 @@ function AuthWrapper({ children }: { children: React.ReactNode }) {
? t('auth.accountDisabled')
: t('auth.signInToContinue');
return (
<div className="flex h-screen flex-col items-center justify-center gap-6">
<div className="flex h-dvh flex-col items-center justify-center gap-6">
<img
src={isDarkTheme ? DocsGPTMarkWhite : DocsGPTMark}
alt="DocsGPT"
@@ -105,12 +105,12 @@ function MainLayout() {
return (
<SidebarLevelProvider>
<div className="bg-background relative h-screen overflow-hidden">
<div className="bg-background relative h-dvh overflow-hidden">
<Navigation navOpen={navOpen} setNavOpen={setNavOpen} />
<ActionButtons showShare={!inSection} />
<div
className={cn(
'h-[calc(100dvh-56px)] overflow-auto transition-[margin] duration-300 ease-in-out lg:h-screen',
'h-[calc(100dvh-56px)] overflow-auto transition-[margin] duration-300 ease-in-out lg:h-dvh',
!isMobile ? (navOpen ? 'lg:ml-72' : 'lg:ml-14') : 'ml-0 lg:ml-16',
)}
>
+1 -1
View File
@@ -489,7 +489,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
>
<div
className={
'visible mt-2 flex h-[6vh] w-full items-center justify-between gap-1 px-2 md:h-12'
'visible mt-2 flex h-12 w-full items-center justify-between gap-1 px-2'
}
>
<div className="min-w-0 flex-1">
+1 -1
View File
@@ -5,7 +5,7 @@ export default function PageNotFound() {
const { t } = useTranslation();
return (
<div className="bg-background grid min-h-screen">
<div className="bg-background grid min-h-dvh">
<div className="text-foreground bg-muted mx-auto my-auto mt-20 flex w-full max-w-6xl flex-col place-items-center gap-6 rounded-3xl p-6 lg:p-10 xl:p-16">
<h1 className="text-3xl">{t('pageNotFound.title')}</h1>
<p>{t('pageNotFound.message')}</p>
@@ -251,7 +251,7 @@ export default function GuardrailEvents({ agentId }: Props) {
</div>
<div className="border-border bg-card mt-3 w-full overflow-hidden rounded-xl border">
<div className="max-h-[45vh] overflow-y-auto">
<div className="max-h-[45svh] overflow-y-auto">
{loading ? (
<div className="p-3">
<SkeletonLoader count={3} />
@@ -1778,7 +1778,7 @@ function WorkflowBuilderInner() {
return (
<>
<MobileBlocker />
<div className="bg-background fixed inset-0 z-50 hidden h-screen w-full flex-col lg:flex">
<div className="bg-background fixed inset-0 z-50 hidden h-dvh w-full flex-col lg:flex">
<div className="border-border bg-card flex items-center justify-between border-b px-6 py-4">
<div className="flex items-center gap-4">
{canManageAgent ? (
@@ -2136,7 +2136,7 @@ function WorkflowBuilderInner() {
</IconButton>
</div>
<div className="max-h-[calc(100vh-200px)] overflow-y-auto p-4">
<div className="max-h-[calc(100dvh-200px)] overflow-y-auto p-4">
<div className="mb-4 flex flex-col gap-5">
<div className="bg-muted rounded-lg p-3">
<div className="text-muted-foreground mb-1 text-xs">
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
export default function MobileBlocker() {
const { t } = useTranslation();
return (
<div className="bg-background flex min-h-screen flex-col items-center justify-center px-6 text-center lg:hidden">
<div className="bg-background flex min-h-dvh flex-col items-center justify-center px-6 text-center lg:hidden">
<div className="bg-secondary mb-6 flex size-20 items-center justify-center rounded-2xl">
<Monitor className="text-primary size-10" />
</div>
+5 -3
View File
@@ -98,7 +98,9 @@ const LineNumberedTextarea: React.FC<LineNumberedTextareaProps> = ({
<textarea
className={cn(
'text-foreground focus-visible:ring-ring/50 focus-visible:border-ring h-(--numbered-height) w-full resize-none overflow-hidden border-none bg-transparent pl-8 text-sm leading-5 outline-none focus-visible:ring-3 lg:pl-12',
isMobile ? 'min-h-[calc(100vh-200px)]' : 'min-h-[calc(100vh-300px)]',
isMobile
? 'min-h-[calc(100svh-200px)]'
: 'min-h-[calc(100svh-300px)]',
!editable && 'select-none',
)}
value={value}
@@ -511,7 +513,7 @@ const Chunks: React.FC<ChunksProps> = ({
{fileSearchQuery && (
<div className="border-border bg-popover text-popover-foreground absolute top-full right-0 left-0 z-20 mt-1 w-full overflow-hidden rounded-xl border shadow-md">
<CommandList className="max-h-[calc(100vh-200px)]">
<CommandList className="max-h-[calc(100dvh-200px)]">
{fileSearchResults.length === 0 ? (
<CommandEmpty>{t('settings.sources.noResults')}</CommandEmpty>
) : (
@@ -603,7 +605,7 @@ const Chunks: React.FC<ChunksProps> = ({
<EmptyState
size="sm"
title={t('settings.sources.noChunks')}
className="col-span-full min-h-[50vh] w-full"
className="col-span-full min-h-[50svh] w-full"
/>
) : (
filteredChunks.map((chunk, index) => (
@@ -240,7 +240,7 @@ export function MultiSelectPopover({
align={align}
side={side}
className={cn(
'flex max-h-[min(600px,80vh)] w-[min(462px,calc(100vw-20px))] flex-col overflow-hidden p-0',
'flex max-h-[min(600px,80dvh)] w-[min(462px,calc(100vw-20px))] flex-col overflow-hidden p-0',
className,
)}
>
+1 -1
View File
@@ -593,7 +593,7 @@ const TreeBrowser: React.FC<TreeBrowserProps> = ({
{searchQuery && (
<div className="border-border bg-popover text-popover-foreground absolute top-full right-0 left-0 z-20 mt-1 w-full overflow-hidden rounded-xl border shadow-md">
<CommandList className="max-h-[calc(100vh-200px)] overscroll-contain">
<CommandList className="max-h-[calc(100dvh-200px)] overscroll-contain">
{searchResults.length === 0 ? (
<CommandEmpty>{t('settings.sources.noResults')}</CommandEmpty>
) : (
@@ -15,12 +15,12 @@ describe('LoadingState', () => {
expect(html.match(/role="status"/g)).toHaveLength(1);
});
it('uses py-10 for block and h-screen for screen', () => {
it('uses py-10 for block and the visible viewport (h-dvh) for screen', () => {
expect(renderToStaticMarkup(<LoadingState fill="block" />)).toContain(
'py-10',
);
expect(renderToStaticMarkup(<LoadingState fill="screen" />)).toContain(
'h-screen',
'h-dvh',
);
});
+1 -1
View File
@@ -9,7 +9,7 @@ import { Spinner, type SpinnerProps } from './spinner';
// section with none takes `block`.
const FILL_CLASSES = {
parent: 'h-full',
screen: 'h-screen',
screen: 'h-dvh',
block: 'py-10',
} as const;
+2 -2
View File
@@ -250,13 +250,13 @@ function LogsTable({
}, []);
return (
<div className="border-border bg-card h-[55vh] w-full overflow-hidden rounded-xl border font-mono">
<div className="border-border bg-card h-[55svh] w-full overflow-hidden rounded-xl border font-mono">
<div className="bg-muted flex h-8 flex-col items-start justify-center">
<p className="text-muted-foreground px-3 text-xs">
{tableHeader ? tableHeader : t('settings.logs.tableHeader')}
</p>
</div>
<div className="relative flex h-[51vh] grow flex-col items-start gap-2 overflow-y-auto overscroll-contain bg-transparent p-4">
<div className="relative flex h-[51svh] grow flex-col items-start gap-2 overflow-y-auto overscroll-contain bg-transparent p-4">
{!loading && logs.length === 0 && (
<EmptyState
size="xs"