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:
@@ -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
|
||||
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
|
||||
|
||||
The mockup's second page, "Mein Zimmer" (Hue lights, a roller shutter and scenes), is
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
<html lang="de">
|
||||
<head>
|
||||
<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. -->
|
||||
<meta
|
||||
name="viewport"
|
||||
@@ -18,8 +19,13 @@
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="Musik" />
|
||||
<!-- black-translucent lets the page's own gradient run behind the status bar. -->
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<!-- An opaque status bar, so the page starts below it - not black-translucent. That
|
||||
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="icon" href="/icon-192.png" type="image/png" />
|
||||
<link rel="stylesheet" href="/fonts/nunito.css" />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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%",
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
* {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user