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

@@ -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;
}
}