Phone layout: opaque status bar and one safe-area rule for every edge

Each fix to the home-screen app's layout on iOS broke something else, and they
all traced back to black-translucent: it runs the page under the clock, so any
top-anchored control without its own inset sat behind it (the play view's back
button), and since iOS 26 it sizes the app one status bar short with a strip at
the bottom that CSS cannot paint (WebKit 301108). The status bar is opaque now
("black"), the page starts below it, and lib/fullHeight.ts - the JS workaround
for the short viewport - is gone with the CSS that depended on it.

viewport-fit=cover stays, so everything against a screen edge now adds that
edge's inset from shared --safe-* variables: the corner back button, the
remote-assign button, the assign toast, the tool buttons and the tab rail did
not before. The rules are written down under "Layout on phones" in the README.

On a phone held sideways the mini player and the tabs share one row instead of
taking half the screen.

Home-screen installs read the status-bar tag once: remove and re-add the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 10:46:06 +02:00
parent 34f2f016f9
commit 4428357e13
16 changed files with 115 additions and 92 deletions

View File

@@ -643,7 +643,7 @@ export function App() {
<div
style={{
position: "absolute",
bottom: 24,
bottom: "calc(24px + var(--safe-bottom))",
left: "50%",
transform: "translateX(-50%)",
zIndex: 7,
@@ -762,7 +762,16 @@ export function App() {
that corner, and parent mode is a tab in it instead. The F1 sheet's button is
not on the typing page either (TippenApp has its own help and owns every key). */}
{!phone && (
<div style={{ position: "absolute", left: 8, bottom: 8, zIndex: 5, display: "flex", gap: 8 }}>
<div
style={{
position: "absolute",
left: "calc(8px + var(--safe-left))",
bottom: "calc(8px + var(--safe-bottom))",
zIndex: 5,
display: "flex",
gap: 8,
}}
>
{PERF_ENABLED && !perfOpen && (
<ToolButton label="Leistungseinstellungen" onClick={() => setPerfOpen(true)}>
⚡
@@ -844,8 +853,8 @@ function CornerButton({
title={label}
style={{
position: "absolute",
top: 18,
[side]: 24,
top: "calc(18px + var(--safe-top))",
[side]: `calc(24px + var(--safe-${side}))`,
zIndex: 3,
width: 44,
height: 44,

View File

@@ -54,7 +54,7 @@ export function AlbumModal({
style={{
zIndex: 4,
background: "oklch(15% 0.03 var(--hue) / .6)",
...(phone && { alignItems: "flex-end", padding: "calc(40px + env(safe-area-inset-top)) 0 0" }),
...(phone && { alignItems: "flex-end", padding: "calc(40px + var(--safe-top)) 0 0" }),
}}
onClick={onClose}
>
@@ -65,7 +65,7 @@ export function AlbumModal({
style={{
background: "oklch(96% 0.012 var(--hue))",
borderRadius: phone ? "26px 26px 0 0" : 26,
padding: phone ? "20px 16px calc(20px + env(safe-area-inset-bottom))" : 26,
padding: phone ? "20px 16px calc(20px + var(--safe-bottom))" : 26,
width: phone ? "100%" : 640,
maxWidth: "100%",
maxHeight: "100%",

View File

@@ -27,8 +27,8 @@ export function AppHeader({
display: "flex",
justifyContent: "center",
padding: status
? `calc(${compact ? 8 : 10}px + env(safe-area-inset-top)) 16px 0`
: "env(safe-area-inset-top) 0 0",
? `calc(${compact ? 8 : 10}px + var(--safe-top)) 16px 0`
: "var(--safe-top) 0 0",
flex: "none",
}}
>
@@ -47,8 +47,8 @@ export function AppHeader({
gap: compact ? 10 : 16,
// The status bar is translucent in standalone mode, so make room for it.
padding: compact
? "calc(12px + env(safe-area-inset-top)) 16px 4px"
: "calc(18px + env(safe-area-inset-top)) 32px 6px",
? "calc(12px + var(--safe-top)) 16px 4px"
: "calc(18px + var(--safe-top)) 32px 6px",
flex: "none",
position: "relative",
}}

View File

@@ -143,7 +143,7 @@ export function ParentPage({ clock, onOpenPerf }: { clock: ClockState | null; on
<div className="m-scroll" style={{ padding: "0 var(--m-gutter) calc(var(--m-dock-clearance) + 24px)" }}>
<div
style={{
padding: "calc(16px + env(safe-area-inset-top)) 0 16px",
padding: "calc(16px + var(--safe-top)) 0 16px",
fontSize: 24,
fontWeight: 900,
color: "var(--paper)",

View File

@@ -240,8 +240,8 @@ export function PlayView({
title="Einer Fernbedienungs-Taste zuweisen (oder: A und dann eine Zahl, Shift+A für nur dieses Lied)"
style={{
position: "absolute",
bottom: 28,
right: 32,
bottom: "calc(28px + var(--safe-bottom))",
right: "calc(32px + var(--safe-right))",
border: "none",
cursor: "pointer",
width: 52,

View File

@@ -50,7 +50,7 @@ export function PlayerBar({
backdropFilter: SHOW_GLASS_BLUR ? "blur(6px)" : undefined,
padding: "16px clamp(12px, 3vw, 32px)",
// Clear of the iPhone home indicator when installed to the home screen.
paddingBottom: "calc(16px + env(safe-area-inset-bottom))",
paddingBottom: "calc(16px + var(--safe-bottom))",
display: "flex",
alignItems: "center",
justifyContent: "center",

View File

@@ -27,7 +27,7 @@ export function TabRail({ page, onSelect, showRoom }: Props) {
style={{
position: "absolute",
top: "50%",
right: 16,
right: "calc(16px + var(--safe-right))",
transform: "translateY(-50%)",
zIndex: 3,
display: "flex",

View File

@@ -22,7 +22,7 @@ export function AppHeader({ title = SKIN.tippenName, status, compact = false, on
alignItems: "center",
justifyContent: "center",
gap: 16,
padding: compact ? "12px 28px 4px" : "calc(18px + env(safe-area-inset-top)) 32px 6px",
padding: compact ? "12px 28px 4px" : "calc(18px + var(--safe-top)) 32px 6px",
flex: "none",
position: "relative",
}}

View File

@@ -6,8 +6,6 @@
import { useEffect, useState } from "react";
import { fullHeight } from "../lib/fullHeight";
export function useKeyboardInset(): number {
const [inset, setInset] = useState(0);
@@ -15,9 +13,7 @@ export function useKeyboardInset(): number {
const viewport = window.visualViewport;
if (!viewport) return;
const measure = () =>
// Measured against the height the stage actually has (see lib/fullHeight.ts), not
// `innerHeight`, which can be a status bar short in the home-screen app.
setInset(Math.max(0, Math.round(fullHeight() - viewport.height - viewport.offsetTop)));
setInset(Math.max(0, Math.round(window.innerHeight - viewport.height - viewport.offsetTop)));
viewport.addEventListener("resize", measure);
viewport.addEventListener("scroll", measure);
return () => {

View File

@@ -1,41 +0,0 @@
/** The height the phone layout should fill, in CSS px - and, in the iOS home-screen app,
* a `--full-height` variable on `<html>` that mobile.css sizes the stage with.
*
* In the home-screen app (`navigator.standalone`, iOS only) the page runs edge to edge
* under a translucent status bar, so it should be exactly as tall as the screen. iOS does
* not always agree: the layout viewport - and with it `100dvh`, `innerHeight` and a
* `position: fixed; inset: 0` box - can come back one status bar short, which left the
* dock floating that far above the bottom of the screen with an empty strip below it.
* `screen` does not have that problem, and in standalone mode there is no browser chrome
* for it to overcount. iOS reports `screen` in portrait whatever the orientation, hence
* the swap.
*
* Everywhere else (a Safari tab, Android, the kiosk) this is just `innerHeight` and
* neither the variable nor `data-standalone` is set. */
const standalone =
typeof navigator !== "undefined" &&
(navigator as Navigator & { standalone?: boolean }).standalone === true;
export function fullHeight(): number {
if (!standalone) return window.innerHeight;
const landscape = window.innerWidth > window.innerHeight;
const screenHeight = landscape
? Math.min(screen.width, screen.height)
: Math.max(screen.width, screen.height);
return Math.max(window.innerHeight, screenHeight);
}
export function installFullHeight(): void {
if (!standalone) return;
const root = document.documentElement;
root.dataset.standalone = "";
const apply = () => root.style.setProperty("--full-height", `${fullHeight()}px`);
apply();
window.addEventListener("resize", apply);
window.addEventListener("orientationchange", apply);
// Coming back from the background (or a relaunch restoring the page) is when iOS is
// most likely to hand back a short viewport, and it does not always fire `resize`.
window.addEventListener("pageshow", apply);
document.addEventListener("visibilitychange", apply);
}

View File

@@ -3,7 +3,6 @@ import { createRoot } from "react-dom/client";
import { App } from "./App";
import { PERF } from "./lib/perfSettings";
import { installFullHeight } from "./lib/fullHeight";
import { applySkin } from "./lib/skins";
import "./styles/app.css";
import "./styles/mobile.css";
@@ -13,7 +12,6 @@ import "./styles/clock.css";
// Before the first render, so nothing ever paints in the wrong skin's colours.
applySkin();
installFullHeight();
createRoot(document.getElementById("root")!).render(
<StrictMode>

View File

@@ -8,6 +8,14 @@
--accent-dim: oklch(78% 0.14 var(--accent-hue));
--sea-deep: oklch(20% 0.045 var(--hue));
--shadow: oklch(15% 0.05 var(--hue) / 0.35);
/* The device's unsafe edges: home indicator, rounded corners, a notch in landscape.
Anything positioned against a screen edge adds the matching one to its offset or
padding - see "Layout on phones" in README.md. */
--safe-top: env(safe-area-inset-top, 0px);
--safe-right: env(safe-area-inset-right, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px);
}
* {

View File

@@ -38,7 +38,7 @@
.clock-badge {
position: absolute;
top: calc(18px + env(safe-area-inset-top));
top: calc(18px + var(--safe-top));
left: 50%;
transform: translateX(-50%);
padding: 1.2vmin 3vmin;

View File

@@ -8,41 +8,25 @@
--m-glass-fill: oklch(32% 0.04 var(--hue) / 0.42);
--m-glass-rim: oklch(100% 0 0 / 0.22);
--m-glass-shadow: 0 10px 30px oklch(10% 0.04 var(--hue) / 0.45);
--m-gutter: 16px;
/* Symmetric, and wide enough to clear a landscape notch on either side. */
--m-gutter: max(16px, var(--safe-left), var(--safe-right));
/* Height of the floating dock, so scrolling content can clear it. */
--m-dock-clearance: calc(96px + env(safe-area-inset-bottom));
--m-dock-clearance: calc(96px + var(--safe-bottom));
}
/* iOS 26 can leave the layout viewport of a home-screen app one status bar short -
`100dvh`, `innerHeight` and `position: fixed; inset: 0` all come back short, and
WebKit draws nothing below the document - so the dock floated that far above the
bottom over a dark strip. Sizing the stage alone was not enough: it got clipped at
the short viewport. The document itself has to reach the bottom of the screen, so in
standalone mode (`data-standalone` and `--full-height`, set by lib/fullHeight.ts)
`<html>` takes the larger of `100vh` and the screen's height, and the stage follows.
Everywhere else the stage just pins to the viewport. The sea colour underneath
covers whatever might still peek through. */
/* The stage pinned to the viewport rather than sized by `100dvh`, and the sea colour
behind it, so nothing white can ever show around the edges. */
html,
body {
background: var(--stage-deep, #001b21);
}
:root[data-standalone] {
height: max(100vh, var(--full-height, 0px));
overflow: hidden;
}
.stage[data-phone] {
position: fixed;
inset: 0;
height: auto;
}
:root[data-standalone] .stage[data-phone] {
bottom: auto;
height: max(100vh, var(--full-height, 0px));
}
.m-glass {
background: var(--m-glass-fill);
backdrop-filter: blur(14px) saturate(1.7);
@@ -107,7 +91,7 @@ body {
position: sticky;
top: 0;
z-index: 2;
padding: calc(10px + env(safe-area-inset-top)) var(--m-gutter) 14px;
padding: calc(10px + var(--safe-top)) var(--m-gutter) 14px;
}
.m-sticky::before {
@@ -127,7 +111,7 @@ body {
display: flex;
align-items: center;
gap: 10px;
padding-top: calc(12px + env(safe-area-inset-top));
padding-top: calc(12px + var(--safe-top));
}
.m-navbar-title {
@@ -349,7 +333,7 @@ body {
display: flex;
flex-direction: column;
gap: 10px;
padding: 0 12px calc(10px + env(safe-area-inset-bottom));
padding: 0 max(12px, var(--safe-right)) calc(10px + var(--safe-bottom)) max(12px, var(--safe-left));
pointer-events: none;
transition: bottom 0.2s ease;
}
@@ -590,3 +574,35 @@ body {
transform-origin: left;
transition: transform 0.5s linear;
}
/* A phone on its side: stacked, the mini player and the tab row took half the screen.
Side by side they take one row, and the scroll padding shrinks to match. */
@media (max-height: 500px) {
:root {
--m-dock-clearance: calc(28px + var(--safe-bottom));
}
.m-dock {
flex-direction: row;
align-items: center;
}
.m-dock > .m-mini {
flex: 1;
min-width: 0;
}
.m-dock-row {
flex: none;
}
/* Alone (search open, or nothing playing) the row takes the full width again. */
.m-dock-row:only-child {
flex: 1;
}
.m-dock-row:not(:only-child) .m-tabs {
flex: none;
width: 220px;
}
}