Phone layout: fill the screen in the home-screen app, drop the title row
In the iOS home-screen app the layout viewport - `100dvh`, `innerHeight`, even 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. In standalone mode the stage now takes its height from `screen` instead (swapped for landscape, since iOS reports it in portrait), via a `--full-height` variable that lib/fullHeight.ts sets only there; the keyboard inset is measured against the same height. A Safari tab, Android and the kiosk are unchanged. The mascot and app title above the group switch only cost a row on a phone, so they are gone; the connection/figure pill moves above the group switch, centred, and only shows while it has something to say. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -31,7 +31,7 @@ interface Props {
|
|||||||
typed: string;
|
typed: string;
|
||||||
mode: Mode;
|
mode: Mode;
|
||||||
currentAlbumId: string | null;
|
currentAlbumId: string | null;
|
||||||
/** Pill next to the title: connection lost, or the figure on the reader. Primitives
|
/** Pill above the group switch: connection lost, or the figure on the reader. Primitives
|
||||||
* rather than an element, so `memo` still holds across position frames. */
|
* rather than an element, so `memo` still holds across position frames. */
|
||||||
statusLabel: string | null;
|
statusLabel: string | null;
|
||||||
offline: boolean;
|
offline: boolean;
|
||||||
@@ -135,16 +135,12 @@ function MobileBrowseImpl({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="m-scroll" key={group}>
|
<div className="m-scroll" key={group}>
|
||||||
<div className="m-title-row">
|
<div className="m-sticky">
|
||||||
<img src={SKIN.mascot} alt="" className="m-title-mascot" />
|
|
||||||
<div className="m-title">{SKIN.appName}</div>
|
|
||||||
{statusLabel && (
|
{statusLabel && (
|
||||||
<div className="m-status" data-offline={offline}>
|
<div className="m-status" data-offline={offline}>
|
||||||
{statusLabel}
|
{statusLabel}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
<div className="m-sticky">
|
|
||||||
<div className="m-segmented">
|
<div className="m-segmented">
|
||||||
{GROUPS.map((candidate) => (
|
{GROUPS.map((candidate) => (
|
||||||
<Segment
|
<Segment
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { fullHeight } from "../lib/fullHeight";
|
||||||
|
|
||||||
export function useKeyboardInset(): number {
|
export function useKeyboardInset(): number {
|
||||||
const [inset, setInset] = useState(0);
|
const [inset, setInset] = useState(0);
|
||||||
|
|
||||||
@@ -13,7 +15,9 @@ export function useKeyboardInset(): number {
|
|||||||
const viewport = window.visualViewport;
|
const viewport = window.visualViewport;
|
||||||
if (!viewport) return;
|
if (!viewport) return;
|
||||||
const measure = () =>
|
const measure = () =>
|
||||||
setInset(Math.max(0, Math.round(window.innerHeight - viewport.height - viewport.offsetTop)));
|
// 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)));
|
||||||
viewport.addEventListener("resize", measure);
|
viewport.addEventListener("resize", measure);
|
||||||
viewport.addEventListener("scroll", measure);
|
viewport.addEventListener("scroll", measure);
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
37
web/src/lib/fullHeight.ts
Normal file
37
web/src/lib/fullHeight.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/** 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);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
|
|||||||
|
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
import { PERF } from "./lib/perfSettings";
|
import { PERF } from "./lib/perfSettings";
|
||||||
|
import { installFullHeight } from "./lib/fullHeight";
|
||||||
import { applySkin } from "./lib/skins";
|
import { applySkin } from "./lib/skins";
|
||||||
import "./styles/app.css";
|
import "./styles/app.css";
|
||||||
import "./styles/mobile.css";
|
import "./styles/mobile.css";
|
||||||
@@ -11,6 +12,7 @@ import "./styles/tippen.css";
|
|||||||
|
|
||||||
// Before the first render, so nothing ever paints in the wrong skin's colours.
|
// Before the first render, so nothing ever paints in the wrong skin's colours.
|
||||||
applySkin();
|
applySkin();
|
||||||
|
installFullHeight();
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|||||||
@@ -13,11 +13,12 @@
|
|||||||
--m-dock-clearance: calc(96px + env(safe-area-inset-bottom));
|
--m-dock-clearance: calc(96px + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* iOS can leave the layout viewport shorter than the screen - after the keyboard has
|
/* iOS can leave the layout viewport shorter than the screen in a home-screen app -
|
||||||
been up, `100dvh` in a home-screen app comes back one status bar short - and the
|
`100dvh`, and even `position: fixed; inset: 0`, come back one status bar short - so
|
||||||
page behind the stage showed as a white strip along the bottom. Pinning the stage to
|
the dock floated that far above the bottom with an empty strip below it. There the
|
||||||
the viewport's edges does not depend on that height at all, and the sea colour
|
stage takes its height from the screen instead (`--full-height` and
|
||||||
underneath covers whatever might still peek through. */
|
`data-standalone`, set by lib/fullHeight.ts only in standalone mode); everywhere else it pins to the viewport.
|
||||||
|
The sea colour underneath covers whatever might still peek through. */
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
background: var(--stage-deep, #001b21);
|
background: var(--stage-deep, #001b21);
|
||||||
@@ -29,6 +30,11 @@ body {
|
|||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-standalone] .stage[data-phone] {
|
||||||
|
bottom: auto;
|
||||||
|
height: var(--full-height);
|
||||||
|
}
|
||||||
|
|
||||||
.m-glass {
|
.m-glass {
|
||||||
background: var(--m-glass-fill);
|
background: var(--m-glass-fill);
|
||||||
backdrop-filter: blur(14px) saturate(1.7);
|
backdrop-filter: blur(14px) saturate(1.7);
|
||||||
@@ -62,30 +68,12 @@ body {
|
|||||||
|
|
||||||
/* ------------------------------------------------------------------ header -- */
|
/* ------------------------------------------------------------------ header -- */
|
||||||
|
|
||||||
.m-title-row {
|
/* Connection lost, or the figure on the reader - centred above the group switch, and
|
||||||
display: flex;
|
only there while it has something to say. No app title above it: that only cost a
|
||||||
align-items: center;
|
row of space on a phone. */
|
||||||
gap: 10px;
|
|
||||||
padding: calc(14px + env(safe-area-inset-top)) var(--m-gutter) 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-title-mascot {
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
object-fit: contain;
|
|
||||||
filter: drop-shadow(0 3px 8px oklch(15% 0.05 var(--hue) / 0.5));
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-title {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 900;
|
|
||||||
color: var(--paper);
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
text-shadow: 0 2px 10px oklch(15% 0.05 var(--hue) / 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-status {
|
.m-status {
|
||||||
|
width: fit-content;
|
||||||
|
margin: 0 auto 8px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: var(--paper);
|
color: var(--paper);
|
||||||
@@ -114,12 +102,6 @@ body {
|
|||||||
padding: calc(10px + env(safe-area-inset-top)) var(--m-gutter) 14px;
|
padding: calc(10px + env(safe-area-inset-top)) var(--m-gutter) 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Under the title row, overlap the row by the status bar's height: the bar keeps its
|
|
||||||
place in the layout but already covers the status bar once it sticks. */
|
|
||||||
.m-title-row + .m-sticky {
|
|
||||||
margin-top: calc(-1 * env(safe-area-inset-top));
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-sticky::before {
|
.m-sticky::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
Reference in New Issue
Block a user