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:
@@ -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