Skip to content

Commit a26113c

Browse files
myieyeclaude
andcommitted
Frontend safe-area inset adjustments for Android edge-to-edge
Add `viewport-fit=cover` to the HTML meta viewport tag so the WebView renders into the full screen including the notch/status-bar area. Then apply the `--safe-area-inset-*` CSS custom properties (set by `AndroidEdgeToEdgeInsets`) throughout the viewer: - `app.css`: root vars + utility classes for padding/margin insets - `App.svelte` / `ShadcnProjectView.svelte`: top/bottom padding on main content areas - `sidebar.svelte` / `sidebar-provider.svelte`: fixed positioning accounts for top inset so the sidebar doesn't underlap the status bar - `sonner.svelte`: toaster offset respects bottom inset - `dialog-content.svelte`, `drawer-content.svelte`, `alert-dialog-content.svelte`, `sheet-content.svelte`: overflow and padding adjustments so content isn't clipped behind system bars - `fab-container.svelte`: FAB raised above the bottom nav bar - `HomeView.svelte`: home view padding Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 87ac419 commit a26113c

13 files changed

Lines changed: 220 additions & 21 deletions

File tree

frontend/viewer/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<head>
44
<meta charset="UTF-8"/>
55
<link rel="icon" type="image/svg+xml" href="/icon.svg"/>
6-
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>
77
<title>FieldWorks Lite</title>
88
<style>
99
body, html, #app {

frontend/viewer/src/App.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414

1515
<TooltipProvider delayDuration={300}>
16-
<div class="app">
16+
<div class="app min-h-dvh">
1717
<Router>
1818
<AppRoutes />
1919
</Router>

frontend/viewer/src/ShadcnProjectView.svelte

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@
6060
</script>
6161
<svelte:window on:message={onMessage}/>
6262
<DialogsProvider/>
63-
<div class="h-screen flex PortalTarget overflow-hidden shadcn-root" {...rest}>
63+
<!-- h-content-screen = safe viewport minus the IME so the entries list shrinks
64+
above the keyboard on Android. Chrome surfaces inside use h-safe-screen. -->
65+
<div class="h-content-screen flex PortalTarget overflow-hidden shadcn-root" {...rest}>
6466
<Sidebar.Provider bind:open>
6567
<ProjectSidebar/>
6668
<Sidebar.Inset class="flex-1 relative">

frontend/viewer/src/app.css

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,158 @@
3232
@apply bg-background text-foreground;
3333
}
3434

35+
/*
36+
Reserve space for system insets (status bar, gesture nav) when the WebView
37+
renders edge-to-edge - required since Android 15+ (SDK 35+) enforces this by
38+
default. Pairs with `viewport-fit=cover` in the MAUI BlazorWebView host page.
39+
Harmless on desktop where every term resolves to 0.
40+
41+
The --android-safe-* / --android-wide-* / --android-ime-bottom custom properties
42+
are written by the MAUI Android host (see AndroidEdgeToEdgeInsets.cs) from real
43+
WindowInsetsCompat values -- we don't trust CSS env(safe-area-inset-*) inside the
44+
Android System WebView because it has been observed to report 0 even with
45+
viewport-fit=cover. iOS WKWebView populates env() reliably so we fall back to it
46+
there, and finally to 0 on desktop.
47+
48+
--safe-area-inset-*: "chrome" inset (SystemBars + DisplayCutout only). For general
49+
page chrome -- status bar / nav bar / notch clearance. What `.app` pads with.
50+
--wide-area-inset-*: "wide" inset (chrome unioned with MandatorySystemGestures and
51+
TappableElement on Android). Use only for FLOATING elements (FABs, toasters)
52+
that need clearance from the gesture-nav tappable region, which can be wider
53+
than the visual handle in SystemBars. On iOS / desktop the env() fallback is
54+
the same as --safe-area-inset-* -- the distinction only matters on Android.
55+
56+
--safe-viewport-height: system-bar-only safe height. Use for chrome surfaces
57+
(sidebars, drawers, dialogs) that should stay put when the keyboard opens.
58+
--content-viewport-height: safe height minus IME. Use for scrollable content
59+
areas that should shrink to remain visible above the soft keyboard.
60+
*/
61+
:root {
62+
--safe-area-inset-top: var(--android-safe-top, env(safe-area-inset-top, 0px));
63+
--safe-area-inset-right: var(--android-safe-right, env(safe-area-inset-right, 0px));
64+
--safe-area-inset-bottom: var(--android-safe-bottom, env(safe-area-inset-bottom, 0px));
65+
--safe-area-inset-left: var(--android-safe-left, env(safe-area-inset-left, 0px));
66+
--wide-area-inset-top: var(--android-wide-top, env(safe-area-inset-top, 0px));
67+
--wide-area-inset-right: var(--android-wide-right, env(safe-area-inset-right, 0px));
68+
--wide-area-inset-bottom: var(--android-wide-bottom, env(safe-area-inset-bottom, 0px));
69+
--wide-area-inset-left: var(--android-wide-left, env(safe-area-inset-left, 0px));
70+
--ime-inset-bottom: var(--android-ime-bottom, 0px);
71+
--safe-viewport-height: calc(100dvh - var(--safe-area-inset-top) - var(--safe-area-inset-bottom));
72+
--content-viewport-height: calc(100dvh - var(--safe-area-inset-top) - var(--ime-inset-bottom));
73+
}
74+
75+
/*
76+
Padding goes on `.app` (not `body`) because some descendants are sized to the
77+
visual viewport and would otherwise render under the status bar / gesture nav,
78+
ignoring any padding on `body`. The height clamp is applied via `min-h-dvh` on
79+
the `.app` element; padding stays here so it doesn't have to repeat on every
80+
direction-utility.
81+
82+
Note: no padding-bottom. We deliberately let scroll content extend behind the
83+
gesture nav / FAB so the entries list visually continues into the floating
84+
chrome region. Floating elements (FAB, sonner) pay their own bottom inset.
85+
*/
86+
.app {
87+
padding: var(--safe-area-inset-top) var(--safe-area-inset-right) 0 var(--safe-area-inset-left);
88+
}
89+
90+
/*
91+
Status-bar band: a themed strip behind the OS status bar on Android edge-to-edge.
92+
Mirrors AppBar's background composite (primary @ 0.4 alpha over --background) so the
93+
band visually continues the AppBar through the status-bar region and re-themes
94+
automatically when the user toggles light/dark/accent in-app. Height collapses to 0
95+
on iOS / desktop where --safe-area-inset-top resolves to 0.
96+
97+
z-index 100 puts the band above everything in-app, including modal overlays
98+
(z-50). This is intentional: the band is OS-chrome-tier — its job is to keep
99+
the status bar icons legible regardless of what's open underneath. Modals
100+
darken in-app content; the status bar area stays bright.
101+
102+
Hidden on project view (see the `:has([data-variant=inset])` rule below):
103+
there, `.app`'s bg-sidebar already covers the safe-top region in a single
104+
continuous color, so the band would just paint a different-shaded strip on
105+
top of it.
106+
*/
107+
body::before {
108+
content: '';
109+
position: fixed;
110+
inset: 0 0 auto 0;
111+
height: var(--safe-area-inset-top);
112+
background:
113+
linear-gradient(oklch(from var(--primary) l c h / 0.4)),
114+
var(--background);
115+
z-index: 100;
116+
pointer-events: none;
117+
}
118+
119+
/*
120+
Project-view background: when the layout contains an inset-variant sidebar,
121+
paint `.app` AND `body` with bg-sidebar so the cutout / nav region and any
122+
area exposed when the IME shrinks `.app` (the strip alongside the keyboard
123+
in landscape, for instance) stay visually continuous with the sidebar
124+
instead of leaking through to bg-background. The themed status-bar band
125+
(body::before) still paints on top in the safe-top region.
126+
*/
127+
.app:has([data-variant=inset]),
128+
body:has([data-variant=inset]) {
129+
background-color: var(--sidebar);
130+
}
131+
132+
/*
133+
svelte-sonner offset / mobileOffset are configured as inline styles via the
134+
<Toaster> props (see lib/components/ui/sonner/sonner.svelte). External CSS-var
135+
overrides here would lose to the library's own inline `style:--offset-*=...`
136+
declarations on the <ol>, so we have to drive the values through the props.
137+
*/
138+
139+
/* Safe-area utilities. Apply at call sites instead of overriding slot selectors. */
140+
@utility min-h-safe-screen { min-height: var(--safe-viewport-height); }
141+
@utility h-safe-screen { height: var(--safe-viewport-height); }
142+
@utility max-h-safe-screen { max-height: var(--safe-viewport-height); }
143+
@utility min-h-content-screen { min-height: var(--content-viewport-height); }
144+
@utility h-content-screen { height: var(--content-viewport-height); }
145+
/* Vaul drawer baseline is max-h-[90dvh]; this preserves the breathing room. */
146+
@utility max-h-90-safe { max-height: calc(var(--safe-viewport-height) * 0.9); }
147+
@utility pt-safe { padding-top: var(--safe-area-inset-top); }
148+
@utility pr-safe { padding-right: var(--safe-area-inset-right); }
149+
@utility pb-safe { padding-bottom: var(--safe-area-inset-bottom); }
150+
@utility pl-safe { padding-left: var(--safe-area-inset-left); }
151+
@utility px-safe {
152+
padding-left: var(--safe-area-inset-left);
153+
padding-right: var(--safe-area-inset-right);
154+
}
155+
@utility py-safe {
156+
padding-top: var(--safe-area-inset-top);
157+
padding-bottom: var(--safe-area-inset-bottom);
158+
}
159+
@utility pb-ime { padding-bottom: var(--ime-inset-bottom); }
160+
161+
/*
162+
Wide-inset utilities. Mirror pb-safe / pr-safe but pull from --wide-area-inset-*
163+
so floating elements (FAB, sonner offset calc) clear the gesture-nav tappable
164+
region on Android. Identical to safe variants on iOS / desktop.
165+
*/
166+
@utility pb-wide { padding-bottom: var(--wide-area-inset-bottom); }
167+
@utility pr-wide { padding-right: var(--wide-area-inset-right); }
168+
169+
/*
170+
"Extra" wide insets: the difference between wide and chrome. Use on floating
171+
elements that already sit inside `.app` (which adds chrome padding) but need to
172+
clear the full wide-inset region. `max(0px, ...)` because the wide vars fall
173+
back via env() on iOS where it can theoretically resolve unequal to safe.
174+
*/
175+
@utility pb-wide-extra { padding-bottom: max(0px, calc(var(--wide-area-inset-bottom) - var(--safe-area-inset-bottom))); }
176+
@utility pr-wide-extra { padding-right: max(0px, calc(var(--wide-area-inset-right) - var(--safe-area-inset-right))); }
177+
178+
/*
179+
Re-center a fixed element within the safe rect instead of the full viewport.
180+
Use with `translate-x-[-50%] translate-y-[-50%]` (which still mean "shift by
181+
half of self"); only the 50% anchor is biased by the inset asymmetry. When
182+
insets are symmetric or zero, this collapses to plain `top: 50%; left: 50%`.
183+
*/
184+
@utility top-safe-center { top: calc(50% + (var(--safe-area-inset-top) - var(--safe-area-inset-bottom)) / 2); }
185+
@utility left-safe-center { left: calc(50% + (var(--safe-area-inset-left) - var(--safe-area-inset-right)) / 2); }
186+
35187
@layer base {
36188
/* shadcn generated styles */
37189
* {

frontend/viewer/src/home/HomeView.svelte

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,8 @@
181181
onclick={() => refreshProjects()}/>
182182
</div>
183183
<div>
184-
{#each projects.filter((p) => p.crdt) as project (project)}
184+
<!-- "?? project" seems to be needed sometimes. Probably just on dev machines. -->
185+
{#each projects.filter((p) => p.crdt) as project (project.id ?? project)}
185186
{@const server = project.server}
186187
{@const loading = deletingProject === project.id}
187188
<div out:send={{key: 'project-' + project.code}} in:receive={{key: 'project-' + project.code}}>

frontend/viewer/src/lib/components/fab/fab-container.svelte

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,18 @@
1010
const {children, class: className}: Props = $props();
1111
</script>
1212

13-
<div class={cn('absolute bottom-4 right-4 md:bottom-6 md:right-6 flex flex-col items-end z-10 gap-4', className)}>
13+
<!--
14+
pb-wide so the FAB sits above the Android gesture-nav tappable region.
15+
`.app` no longer pays the chrome inset on the bottom (scroll content extends
16+
behind the gesture nav by design), so the FAB pays the full wide value here.
17+
pr-wide-extra still adds only the differential because `.app` keeps its
18+
right chrome padding. No-op on desktop / iOS where wide == chrome.
19+
-->
20+
<div
21+
class={cn(
22+
'absolute bottom-4 right-4 md:bottom-6 md:right-6 pb-wide pr-wide-extra flex flex-col items-end z-10 gap-4',
23+
className,
24+
)}
25+
>
1426
{@render children?.()}
1527
</div>

frontend/viewer/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,11 @@
2323
<AlertDialogOverlay {...state.overlayProps} />
2424
<AlertDialogPrimitive.Content
2525
bind:ref
26+
data-slot="alert-dialog-content"
2627
{...mergeProps(state.contentProps, restProps)}
2728
class={cn(
28-
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
29-
'min-w-[min(calc(100%-32px),50rem)] max-w-[calc(100%-32px)]',
29+
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-safe-center left-safe-center z-50 grid translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
30+
'min-w-[min(calc(100%-32px),50rem)] max-w-[calc(100%-32px)] max-h-safe-screen',
3031
state.contentProps.class,
3132
className,
3233
)}

frontend/viewer/src/lib/components/ui/dialog/dialog-content.svelte

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,19 +27,31 @@
2727

2828
<DialogPortal {...portalProps}>
2929
<Dialog.Overlay {...state.overlayProps} />
30+
<!--
31+
overflow-y-auto lives on the INNER wrapper rather than the Content element
32+
because Chrome clips top/bottom borders to zero width when overflow:auto and
33+
border-radius are set on the same element. In portrait the dialog drops to
34+
full-width with no rounded corners (sm: doesn't apply) so the bug never
35+
triggers; in landscape sm:rounded-lg kicks in and the borders disappear.
36+
Moving overflow to a wrapper that has no border-radius avoids the clip.
37+
Padding + grid gap also move to the wrapper so DialogHeader/Footer keep
38+
their `gap-4` spacing. The close button stays a sibling of the wrapper --
39+
it's absolutely positioned relative to the (fixed) outer, unchanged.
40+
-->
3041
<DialogPrimitive.Content
3142
bind:ref
3243
data-slot="dialog-content"
3344
{...mergeProps(state.contentProps, restProps)}
3445
class={cn(
35-
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg',
36-
'max-sm:min-h-full min-w-full max-h-full max-w-full sm:min-w-[min(calc(100%-32px),50rem)] sm:max-h-[calc(100%-16px)] sm:max-w-[calc(100%-32px)]',
37-
'overflow-y-auto',
46+
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-safe-center left-safe-center z-50 flex flex-col translate-x-[-50%] translate-y-[-50%] border shadow-lg duration-200 sm:rounded-lg',
47+
'max-sm:min-h-safe-screen min-w-full max-w-full max-h-safe-screen sm:min-w-[min(calc(100%-32px),50rem)] sm:max-w-[calc(100%-32px)]',
3848
state.contentProps.class,
3949
className,
4050
)}
4151
>
42-
{@render children?.()}
52+
<div class="grid gap-4 p-6 overflow-y-auto flex-1 min-h-0">
53+
{@render children?.()}
54+
</div>
4355
{#if !hideClose}
4456
<DialogPrimitive.Close class="absolute inset-e-4 top-4">
4557
{#snippet child({props})}

frontend/viewer/src/lib/components/ui/drawer/drawer-content.svelte

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,11 @@
2424
bind:ref
2525
data-slot="drawer-content"
2626
class={cn(
27-
'group/drawer-content bg-background fixed z-50 flex h-auto flex-col',
28-
'data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[90dvh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b',
29-
'data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[90dvh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t',
30-
'data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:inset-e-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-s data-[vaul-drawer-direction=right]:sm:max-w-sm',
31-
'data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:inset-s-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-e data-[vaul-drawer-direction=left]:sm:max-w-sm',
27+
'group/drawer-content bg-background fixed z-50 flex h-auto flex-col pt-safe pb-safe px-safe',
28+
'data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-90-safe data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b',
29+
'data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-90-safe data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t',
30+
'data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:inset-e-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:max-h-safe-screen data-[vaul-drawer-direction=right]:border-s data-[vaul-drawer-direction=right]:sm:max-w-sm',
31+
'data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:inset-s-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:max-h-safe-screen data-[vaul-drawer-direction=left]:border-e data-[vaul-drawer-direction=left]:sm:max-w-sm',
3232
className,
3333
)}
3434
{...restProps}

frontend/viewer/src/lib/components/ui/sheet/sheet-content.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script lang="ts" module>
22
import {tv, type VariantProps} from 'tailwind-variants';
33
export const sheetVariants = tv({
4-
base: 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
4+
base: 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 pt-safe pb-safe px-safe',
55
variants: {
66
side: {
77
top: 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',

0 commit comments

Comments
 (0)