diff --git a/web/README.md b/web/README.md index fd72465..77618f3 100644 --- a/web/README.md +++ b/web/README.md @@ -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 diff --git a/web/index.html b/web/index.html index 6db22ea..03bc59f 100644 --- a/web/index.html +++ b/web/index.html @@ -2,7 +2,8 @@ - - - + + diff --git a/web/src/App.tsx b/web/src/App.tsx index bc47a8a..38ea81d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -643,7 +643,7 @@ export function App() {
+
{PERF_ENABLED && !perfOpen && ( 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, diff --git a/web/src/components/AlbumModal.tsx b/web/src/components/AlbumModal.tsx index 977383a..6283d45 100644 --- a/web/src/components/AlbumModal.tsx +++ b/web/src/components/AlbumModal.tsx @@ -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%", diff --git a/web/src/components/AppHeader.tsx b/web/src/components/AppHeader.tsx index f5d5456..37398a2 100644 --- a/web/src/components/AppHeader.tsx +++ b/web/src/components/AppHeader.tsx @@ -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", }} diff --git a/web/src/components/ParentPanel.tsx b/web/src/components/ParentPanel.tsx index 7b8300d..1c915f8 100644 --- a/web/src/components/ParentPanel.tsx +++ b/web/src/components/ParentPanel.tsx @@ -143,7 +143,7 @@ export function ParentPage({ clock, onOpenPerf }: { clock: ClockState | null; on
- // 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; + } +}