- // 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 () => {
diff --git a/web/src/lib/fullHeight.ts b/web/src/lib/fullHeight.ts
deleted file mode 100644
index ef5b85e..0000000
--- a/web/src/lib/fullHeight.ts
+++ /dev/null
@@ -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 `` 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);
-}
diff --git a/web/src/main.tsx b/web/src/main.tsx
index 09ad22a..27ba63d 100644
--- a/web/src/main.tsx
+++ b/web/src/main.tsx
@@ -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(
diff --git a/web/src/styles/app.css b/web/src/styles/app.css
index 21f0d1c..b50c945 100644
--- a/web/src/styles/app.css
+++ b/web/src/styles/app.css
@@ -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);
}
* {
diff --git a/web/src/styles/clock.css b/web/src/styles/clock.css
index 2c98a6c..9f9e077 100644
--- a/web/src/styles/clock.css
+++ b/web/src/styles/clock.css
@@ -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;
diff --git a/web/src/styles/mobile.css b/web/src/styles/mobile.css
index ef19668..6b1d40e 100644
--- a/web/src/styles/mobile.css
+++ b/web/src/styles/mobile.css
@@ -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)
- `` 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;
+ }
+}