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

@@ -159,6 +159,37 @@ Nunito is self-hosted in `public/fonts/` (one variable file per subset, ~74 kB)
than pulled from Google. The mouse may have no internet, and a cached shell rendering in than pulled from Google. The mouse may have no internet, and a cached shell rendering in
a fallback face looks broken. a fallback face looks broken.
## Layout on phones
Phones (`usePhoneLayout`: narrow, or short landscape on touch) get the touch layout in
`components/mobile/`; tablets and the kiosk keep the keyboard one. The home-screen app
on iOS has caught every shortcut taken here, so these are the rules - follow them rather
than patching one element at a time:
- **The status bar is opaque** (`apple-mobile-web-app-status-bar-style: black`). The page
starts below it, so nothing can end up under the clock. `black-translucent` looks
nicer, but it ran the page behind the status bar, and since iOS 26 it also sizes the
app one status bar short, leaving a strip at the bottom that CSS cannot paint
([WebKit 301108](https://bugs.webkit.org/show_bug.cgi?id=301108)). The workarounds for
that (`100lvh`, measuring `screen` in JS) each broke something else. Installed apps
read this tag once: after changing it, remove the app and add it again.
- **`viewport-fit=cover` stays**, so the sea fills the rounded corners and the home
indicator area, which means:
- **Anything placed against a screen edge adds that edge's inset**: `--safe-top`,
`--safe-right`, `--safe-bottom` and `--safe-left` in `app.css`, always the variables
and never a bare `env()`. That covers `top`/`bottom`/`left`/`right` offsets of
positioned elements, and the padding of bars and scroll containers that reach the
edge. Scrolling content clears what floats over it (`--m-dock-clearance`). The top
inset is 0 with the opaque status bar, but stays in the formulas for Safari tabs and
landscape.
- **The stage is pinned, not sized**: `position: fixed; inset: 0` on phones, with the sea
colour on `html`/`body` so any gap would show sea, not white. No viewport-height unit
is trusted in the home-screen app.
- **The keyboard is measured, not assumed**: iOS slides it over the page without resizing
anything, so the dock is raised by `visualViewport` (`useKeyboardInset`) and drops its
home-indicator padding while the keyboard is up
([safe-area-inset-bottom does not update for the keyboard](https://webventures.rejh.nl/blog/2025/safe-area-inset-bottom-does-not-update/)).
## Not implemented ## Not implemented
The mockup's second page, "Mein Zimmer" (Hue lights, a roller shutter and scenes), is The mockup's second page, "Mein Zimmer" (Hue lights, a roller shutter and scenes), is

View File

@@ -2,7 +2,8 @@
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<!-- viewport-fit=cover so the sea reaches under the iPhone's rounded corners; <!-- viewport-fit=cover so the sea reaches under the rounded corners and the home
indicator - edge-anchored elements clear them with the --safe-* insets;
user-scalable=no because a pinch-zoom on a big pink play button is a misfire. --> user-scalable=no because a pinch-zoom on a big pink play button is a misfire. -->
<meta <meta
name="viewport" name="viewport"
@@ -18,8 +19,13 @@
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Musik" /> <meta name="apple-mobile-web-app-title" content="Musik" />
<!-- black-translucent lets the page's own gradient run behind the status bar. --> <!-- An opaque status bar, so the page starts below it - not black-translucent. That
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> one ran the page under the clock, which put every top-anchored control behind
it unless each remembered its inset, and since iOS 26 it also sizes the app one
status bar short with an unpaintable strip at the bottom (WebKit bug 301108).
See "Layout on phones" in README.md. Home-screen installs read this tag once:
remove and re-add the app after changing it. -->
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="icon" href="/icon-192.png" type="image/png" /> <link rel="icon" href="/icon-192.png" type="image/png" />
<link rel="stylesheet" href="/fonts/nunito.css" /> <link rel="stylesheet" href="/fonts/nunito.css" />

View File

@@ -643,7 +643,7 @@ export function App() {
<div <div
style={{ style={{
position: "absolute", position: "absolute",
bottom: 24, bottom: "calc(24px + var(--safe-bottom))",
left: "50%", left: "50%",
transform: "translateX(-50%)", transform: "translateX(-50%)",
zIndex: 7, 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 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). */} not on the typing page either (TippenApp has its own help and owns every key). */}
{!phone && ( {!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 && ( {PERF_ENABLED && !perfOpen && (
<ToolButton label="Leistungseinstellungen" onClick={() => setPerfOpen(true)}> <ToolButton label="Leistungseinstellungen" onClick={() => setPerfOpen(true)}>
⚡ ⚡
@@ -844,8 +853,8 @@ function CornerButton({
title={label} title={label}
style={{ style={{
position: "absolute", position: "absolute",
top: 18, top: "calc(18px + var(--safe-top))",
[side]: 24, [side]: `calc(24px + var(--safe-${side}))`,
zIndex: 3, zIndex: 3,
width: 44, width: 44,
height: 44, height: 44,

View File

@@ -54,7 +54,7 @@ export function AlbumModal({
style={{ style={{
zIndex: 4, zIndex: 4,
background: "oklch(15% 0.03 var(--hue) / .6)", 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} onClick={onClose}
> >
@@ -65,7 +65,7 @@ export function AlbumModal({
style={{ style={{
background: "oklch(96% 0.012 var(--hue))", background: "oklch(96% 0.012 var(--hue))",
borderRadius: phone ? "26px 26px 0 0" : 26, 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, width: phone ? "100%" : 640,
maxWidth: "100%", maxWidth: "100%",
maxHeight: "100%", maxHeight: "100%",

View File

@@ -27,8 +27,8 @@ export function AppHeader({
display: "flex", display: "flex",
justifyContent: "center", justifyContent: "center",
padding: status padding: status
? `calc(${compact ? 8 : 10}px + env(safe-area-inset-top)) 16px 0` ? `calc(${compact ? 8 : 10}px + var(--safe-top)) 16px 0`
: "env(safe-area-inset-top) 0 0", : "var(--safe-top) 0 0",
flex: "none", flex: "none",
}} }}
> >
@@ -47,8 +47,8 @@ export function AppHeader({
gap: compact ? 10 : 16, gap: compact ? 10 : 16,
// The status bar is translucent in standalone mode, so make room for it. // The status bar is translucent in standalone mode, so make room for it.
padding: compact padding: compact
? "calc(12px + env(safe-area-inset-top)) 16px 4px" ? "calc(12px + var(--safe-top)) 16px 4px"
: "calc(18px + env(safe-area-inset-top)) 32px 6px", : "calc(18px + var(--safe-top)) 32px 6px",
flex: "none", flex: "none",
position: "relative", 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 className="m-scroll" style={{ padding: "0 var(--m-gutter) calc(var(--m-dock-clearance) + 24px)" }}>
<div <div
style={{ style={{
padding: "calc(16px + env(safe-area-inset-top)) 0 16px", padding: "calc(16px + var(--safe-top)) 0 16px",
fontSize: 24, fontSize: 24,
fontWeight: 900, fontWeight: 900,
color: "var(--paper)", 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)" title="Einer Fernbedienungs-Taste zuweisen (oder: A und dann eine Zahl, Shift+A für nur dieses Lied)"
style={{ style={{
position: "absolute", position: "absolute",
bottom: 28, bottom: "calc(28px + var(--safe-bottom))",
right: 32, right: "calc(32px + var(--safe-right))",
border: "none", border: "none",
cursor: "pointer", cursor: "pointer",
width: 52, width: 52,

View File

@@ -50,7 +50,7 @@ export function PlayerBar({
backdropFilter: SHOW_GLASS_BLUR ? "blur(6px)" : undefined, backdropFilter: SHOW_GLASS_BLUR ? "blur(6px)" : undefined,
padding: "16px clamp(12px, 3vw, 32px)", padding: "16px clamp(12px, 3vw, 32px)",
// Clear of the iPhone home indicator when installed to the home screen. // 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", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",

View File

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

View File

@@ -22,7 +22,7 @@ export function AppHeader({ title = SKIN.tippenName, status, compact = false, on
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
gap: 16, 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", flex: "none",
position: "relative", position: "relative",
}} }}

View File

@@ -6,8 +6,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { fullHeight } from "../lib/fullHeight";
export function useKeyboardInset(): number { export function useKeyboardInset(): number {
const [inset, setInset] = useState(0); const [inset, setInset] = useState(0);
@@ -15,9 +13,7 @@ export function useKeyboardInset(): number {
const viewport = window.visualViewport; const viewport = window.visualViewport;
if (!viewport) return; if (!viewport) return;
const measure = () => const measure = () =>
// Measured against the height the stage actually has (see lib/fullHeight.ts), not setInset(Math.max(0, Math.round(window.innerHeight - viewport.height - viewport.offsetTop)));
// `innerHeight`, which can be a status bar short in the home-screen app.
setInset(Math.max(0, Math.round(fullHeight() - viewport.height - viewport.offsetTop)));
viewport.addEventListener("resize", measure); viewport.addEventListener("resize", measure);
viewport.addEventListener("scroll", measure); viewport.addEventListener("scroll", measure);
return () => { 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 { App } from "./App";
import { PERF } from "./lib/perfSettings"; import { PERF } from "./lib/perfSettings";
import { installFullHeight } from "./lib/fullHeight";
import { applySkin } from "./lib/skins"; import { applySkin } from "./lib/skins";
import "./styles/app.css"; import "./styles/app.css";
import "./styles/mobile.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. // Before the first render, so nothing ever paints in the wrong skin's colours.
applySkin(); applySkin();
installFullHeight();
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>

View File

@@ -8,6 +8,14 @@
--accent-dim: oklch(78% 0.14 var(--accent-hue)); --accent-dim: oklch(78% 0.14 var(--accent-hue));
--sea-deep: oklch(20% 0.045 var(--hue)); --sea-deep: oklch(20% 0.045 var(--hue));
--shadow: oklch(15% 0.05 var(--hue) / 0.35); --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 { .clock-badge {
position: absolute; position: absolute;
top: calc(18px + env(safe-area-inset-top)); top: calc(18px + var(--safe-top));
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
padding: 1.2vmin 3vmin; padding: 1.2vmin 3vmin;

View File

@@ -8,41 +8,25 @@
--m-glass-fill: oklch(32% 0.04 var(--hue) / 0.42); --m-glass-fill: oklch(32% 0.04 var(--hue) / 0.42);
--m-glass-rim: oklch(100% 0 0 / 0.22); --m-glass-rim: oklch(100% 0 0 / 0.22);
--m-glass-shadow: 0 10px 30px oklch(10% 0.04 var(--hue) / 0.45); --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. */ /* 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 - /* The stage pinned to the viewport rather than sized by `100dvh`, and the sea colour
`100dvh`, `innerHeight` and `position: fixed; inset: 0` all come back short, and behind it, so nothing white can ever show around the edges. */
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. */
html, html,
body { body {
background: var(--stage-deep, #001b21); background: var(--stage-deep, #001b21);
} }
:root[data-standalone] {
height: max(100vh, var(--full-height, 0px));
overflow: hidden;
}
.stage[data-phone] { .stage[data-phone] {
position: fixed; position: fixed;
inset: 0; inset: 0;
height: auto; height: auto;
} }
:root[data-standalone] .stage[data-phone] {
bottom: auto;
height: max(100vh, var(--full-height, 0px));
}
.m-glass { .m-glass {
background: var(--m-glass-fill); background: var(--m-glass-fill);
backdrop-filter: blur(14px) saturate(1.7); backdrop-filter: blur(14px) saturate(1.7);
@@ -107,7 +91,7 @@ body {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 2; 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 { .m-sticky::before {
@@ -127,7 +111,7 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding-top: calc(12px + env(safe-area-inset-top)); padding-top: calc(12px + var(--safe-top));
} }
.m-navbar-title { .m-navbar-title {
@@ -349,7 +333,7 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; 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; pointer-events: none;
transition: bottom 0.2s ease; transition: bottom 0.2s ease;
} }
@@ -590,3 +574,35 @@ body {
transform-origin: left; transform-origin: left;
transition: transform 0.5s linear; 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;
}
}