Skip to content

Commit e538200

Browse files
authored
Merge pull request #66 from MrSuttonmann/ios-pwa-detail-panel-safe-area
Fix iOS PWA safe-area insets on detail panel, dialogs, mobile chrome
2 parents 89fb5ba + 388135d commit e538200

4 files changed

Lines changed: 87 additions & 3 deletions

File tree

CLAUDE.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,47 @@ Do not introduce `document.write`, and do not build a parallel
199199
sanitiser; `escapeHtml` is the one primitive. If you find yourself
200200
reaching for `DOMPurify` or similar, stop and ask.
201201

202+
### Frontend: iOS PWA safe-area insets
203+
204+
The site is installable as a standalone PWA
205+
(`apple-mobile-web-app-capable=yes`,
206+
`viewport-fit=cover`), which means content paints under the iPhone
207+
notch / status bar / home indicator. Any surface that pins to a
208+
viewport edge on mobile MUST factor in `env(safe-area-inset-*)` or
209+
its controls will sit under hardware that the user can't tap through.
210+
211+
Pattern, in order of preference:
212+
213+
1. **Pad the surface** that contains the controls — e.g.
214+
`#sidebar { padding-top: env(safe-area-inset-top); }`,
215+
`#sidebar-footer { padding-bottom: max(8px, env(safe-area-inset-bottom)); }`.
216+
This is the cleanest fix because the surface still extends
217+
visually behind the notch (background colour fills the gap) but
218+
its children stay in the safe area.
219+
2. **Offset position** when the element is `position: fixed`/`sticky`
220+
without a useful parent — e.g.
221+
`body.compact-mode .leaflet-top { top: env(safe-area-inset-top); }`,
222+
`#toast-host { top: calc(56px + env(safe-area-inset-top)); }`.
223+
3. **Shrink centred elements** so `margin: auto` keeps them inside
224+
the safe area — `<dialog>` uses
225+
`max-height: calc(100dvh - 20px - 2 * max(env(safe-area-inset-top), env(safe-area-inset-bottom)))`.
226+
The `2 * max(...)` is right because `margin: auto` on a centred
227+
block puts equal space above and below; we need that half-margin
228+
to be ≥ the larger inset, so the total reduction is twice that.
229+
230+
Sticky elements (`#detail-header`, dialog `.about-close-form`)
231+
ignore ancestor padding — apply padding *to the sticky element
232+
itself* so its content shifts. The `padding-top: calc(6px +
233+
env(safe-area-inset-top))` pattern on `#detail-header` is the
234+
canonical example.
235+
236+
When adding any new fullscreen mobile surface (anything that uses
237+
`inset: 0`, `position: fixed; top: 0`, full-viewport `<dialog>`,
238+
etc.), audit it against the four insets before merging. Test on a
239+
notched device (or the iPhone Pro simulator in Safari devtools) in
240+
PWA standalone mode — the browser tab itself reserves space for
241+
the status bar so the bug only manifests once installed.
242+
202243
### BEAST wire format (`dotnet/src/FlightJar.Decoder/Beast/BeastFrameReader.cs`)
203244

204245
Frames are `0x1A <type> <6B MLAT ts> <1B sig> <msg>`, where any `0x1A` in

app/static/app.css

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -650,7 +650,9 @@
650650
sit above every other surface (tooltips, dialogs, overlays).
651651
The host itself is click-through (pointer-events:none); each
652652
toast re-enables events so click-to-dismiss still works. */
653-
#toast-host { position: fixed; top: 56px; right: 14px; z-index: 10001;
653+
#toast-host { position: fixed;
654+
top: max(56px, calc(env(safe-area-inset-top) + 12px));
655+
right: calc(14px + env(safe-area-inset-right)); z-index: 10001;
654656
display: flex; flex-direction: column; gap: 10px;
655657
pointer-events: none; max-width: 360px; }
656658

@@ -825,6 +827,15 @@
825827
border-radius: 0 0 11px 11px;
826828
border-top: 0;
827829
}
830+
/* Compact mode hides the sidebar/footer so the map fills the
831+
viewport — Leaflet's zoom / layers / home / follow buttons live
832+
at top:0 / bottom:0 of the map, which on iOS PWA puts them
833+
under the notch and home indicator. Pad the four corner
834+
containers so every Leaflet control clears the safe area. */
835+
body.compact-mode .leaflet-top { padding-top: env(safe-area-inset-top); }
836+
body.compact-mode .leaflet-bottom { padding-bottom: env(safe-area-inset-bottom); }
837+
body.compact-mode .leaflet-left { padding-left: env(safe-area-inset-left); }
838+
body.compact-mode .leaflet-right { padding-right: env(safe-area-inset-right); }
828839
#sidebar-handle svg {
829840
display: block; color: var(--muted);
830841
transition: transform 0.22s ease, color 0.15s ease;
@@ -834,4 +845,16 @@
834845
body.compact-mode #sidebar-handle svg { transform: scaleY(-1); }
835846
#sidebar-handle:hover svg,
836847
#sidebar-handle:active svg { color: var(--text); }
848+
/* Compact mode hides the sidebar so the map fills the viewport.
849+
Leaflet's top-anchored controls and the toast stack default to
850+
y≈10px / 56px from the top edge, which on iOS PWA standalone
851+
lands under the status bar / notch. Nudge them down by the safe-
852+
area inset so the zoom buttons, layer toggle, and toasts stay
853+
reachable. */
854+
body.compact-mode .leaflet-top {
855+
top: env(safe-area-inset-top);
856+
}
857+
body.compact-mode #toast-host {
858+
top: calc(56px + env(safe-area-inset-top));
859+
}
837860
}

app/static/detail_panel.css

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,4 +418,20 @@
418418
/* Compact mode's `left: 10px` desktop override shouldn't fight the
419419
full-viewport mobile overlay. */
420420
body.compact-mode #detail-panel { left: 0; }
421+
/* iOS PWA standalone runs the panel to the physical edges of the
422+
device, so the sticky header lands under the notch / status bar
423+
and the home indicator overlaps the bottom of the content. Pad
424+
inner surfaces with safe-area insets so every interactive control
425+
stays reachable. The header's own padding is what pushes the
426+
close button down — sticky positioning ignores ancestor padding. */
427+
#detail-header {
428+
padding-top: calc(6px + env(safe-area-inset-top));
429+
padding-left: calc(6px + env(safe-area-inset-left));
430+
padding-right: calc(6px + env(safe-area-inset-right));
431+
}
432+
#detail-content {
433+
padding-left: calc(14px + env(safe-area-inset-left));
434+
padding-right: calc(14px + env(safe-area-inset-right));
435+
padding-bottom: calc(14px + env(safe-area-inset-bottom));
436+
}
421437
}

app/static/dialogs.css

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -393,14 +393,18 @@
393393
first child <form> — pin it so it stays reachable while the body
394394
scrolls underneath. `100dvh` tracks the *visible* viewport on
395395
mobile browsers (excludes the URL bar) so the dialog actually
396-
fits when the browser chrome is onscreen. */
396+
fits when the browser chrome is onscreen. The safe-area subtraction
397+
keeps the centered <dialog> out from under the iOS notch / home
398+
indicator in PWA standalone mode — `margin: auto` puts equal space
399+
above and below, so we shrink by 2× the larger inset to guarantee
400+
both edges clear. */
397401
#about-dialog,
398402
#stats-dialog,
399403
#watchlist-dialog,
400404
#alerts-dialog,
401405
#airspace-filters-dialog,
402406
#map-key-dialog {
403-
max-height: calc(100dvh - 20px);
407+
max-height: calc(100dvh - 20px - 2 * max(env(safe-area-inset-top), env(safe-area-inset-bottom)));
404408
overflow-y: auto;
405409
overscroll-behavior: contain;
406410
}

0 commit comments

Comments
 (0)