Files
musicmouse/web/src/styles/mobile.css
Martin Bauer f67f84ded3 Phone layout: make the whole document reach the bottom in the home-screen app
Sizing the stage from the screen height was not enough on iOS 26: with the layout
viewport a status bar short, WebKit clips at the short viewport and draws nothing
below the document, so the dock still floated over a dark strip. In standalone mode
`<html>` now takes the larger of `100vh` and the screen height too, and the stage
follows it.

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`, so the
height is also re-applied on `pageshow` and `visibilitychange`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:29:50 +02:00

593 lines
12 KiB
CSS

/* The phone layout (components/mobile/). Every class is `m-` prefixed and only rendered
when `usePhoneLayout` says so, so none of this reaches the kiosk or a tablet. */
:root {
/* Liquid-glass surfaces over the sea gradient: a dark translucent fill, a strong
blur with a saturation boost so colour behind it still glows through, and a thin
bright rim along the top edge. */
--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;
/* Height of the floating dock, so scrolling content can clear it. */
--m-dock-clearance: calc(96px + env(safe-area-inset-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. */
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);
-webkit-backdrop-filter: blur(14px) saturate(1.7);
border: 1px solid var(--m-glass-rim);
box-shadow:
var(--m-glass-shadow),
inset 0 1px 0 oklch(100% 0 0 / 0.28),
inset 0 -1px 0 oklch(100% 0 0 / 0.06);
color: var(--paper);
}
.m-page {
position: relative;
z-index: 1;
height: 100%;
display: flex;
flex-direction: column;
}
.m-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
/* No top inset here: the sticky bar reaches up under the status bar itself. */
padding: 0 0 calc(var(--m-dock-clearance) + 84px);
animation: viewEnter 180ms ease-out;
}
/* ------------------------------------------------------------------ header -- */
/* Connection lost, or the figure on the reader - centred above the group switch, and
only there while it has something to say. No app title above it: that only cost a
row of space on a phone. */
.m-status {
width: fit-content;
margin: 0 auto 8px;
font-size: 13px;
font-weight: 800;
color: var(--paper);
background: oklch(97% 0.01 var(--hue) / 0.16);
padding: 5px 12px;
border-radius: 999px;
white-space: nowrap;
}
.m-status[data-offline="true"] {
color: oklch(80% 0.16 30);
}
/* The bar that stays put while the page scrolls under it. Sticks to the very top of
the screen, under the status bar, so content never shows behind the clock.
Frosted rather than painted: the background behind it is the ambience canvas,
which takes its colour from whatever is playing, so any fixed fill was the wrong
colour as soon as an album with different art started. A blur with a neutral
darkening suits every one of them, and the mask fades it out instead of ending in
a hard edge. */
.m-sticky {
position: sticky;
top: 0;
z-index: 2;
padding: calc(10px + env(safe-area-inset-top)) var(--m-gutter) 14px;
}
.m-sticky::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
background: oklch(0% 0 0 / 0.16);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
-webkit-mask-image: linear-gradient(180deg, #000 70%, transparent);
mask-image: linear-gradient(180deg, #000 70%, transparent);
pointer-events: none;
}
.m-navbar {
display: flex;
align-items: center;
gap: 10px;
padding-top: calc(12px + env(safe-area-inset-top));
}
.m-navbar-title {
flex: 1;
min-width: 0;
text-align: center;
font-size: 19px;
font-weight: 900;
color: var(--paper);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.m-subtitle {
text-align: center;
margin: -6px var(--m-gutter) 14px;
font-size: 13px;
font-weight: 700;
color: oklch(90% 0.02 var(--hue) / 0.75);
}
/* ------------------------------------------------------------- segmented -- */
.m-segmented {
display: flex;
gap: 2px;
padding: 3px;
border-radius: 999px;
background: oklch(20% 0.04 var(--hue) / 0.38);
backdrop-filter: blur(12px) saturate(1.6);
-webkit-backdrop-filter: blur(12px) saturate(1.6);
border: 1px solid oklch(100% 0 0 / 0.14);
box-shadow: inset 0 1px 2px oklch(10% 0.04 var(--hue) / 0.35);
}
.m-segment {
flex: 1;
min-width: 0;
border: none;
background: transparent;
color: oklch(95% 0.01 var(--hue) / 0.85);
font: inherit;
font-size: 14px;
font-weight: 800;
padding: 9px 6px;
border-radius: 999px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
transition: background 0.18s ease, color 0.18s ease;
}
.m-segment[data-active="true"] {
background: var(--paper);
color: var(--ink);
box-shadow: 0 3px 10px oklch(10% 0.04 var(--hue) / 0.35);
}
/* ------------------------------------------------------------------ grids -- */
.m-category-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 14px;
padding: 0 var(--m-gutter);
align-items: start;
}
.m-category-grid > .card {
content-visibility: auto;
contain-intrinsic-size: auto 230px;
}
.m-album-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 18px 12px;
padding: 0 var(--m-gutter);
align-items: start;
}
.m-album {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
border: none;
padding: 0;
background: none;
font: inherit;
text-align: left;
color: var(--paper);
cursor: pointer;
content-visibility: auto;
contain-intrinsic-size: auto 170px;
-webkit-tap-highlight-color: transparent;
}
.m-album:active .m-album-cover {
transform: scale(0.96);
}
.m-album-cover {
box-shadow: 0 6px 16px oklch(10% 0.04 var(--hue) / 0.45);
transition: transform 0.12s ease;
}
.m-album[data-current="true"] .m-album-cover {
outline: 3px solid var(--accent);
outline-offset: 2px;
}
.m-album-title {
font-size: 13px;
font-weight: 800;
line-height: 1.2;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.m-album-sub {
margin-top: -3px;
font-size: 11px;
font-weight: 800;
color: var(--accent-dim);
}
/* ------------------------------------------------------------------- songs -- */
.m-list {
display: flex;
flex-direction: column;
gap: 2px;
padding: 0 var(--m-gutter);
}
.m-song {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 10px;
border: none;
border-radius: 14px;
background: transparent;
color: var(--paper);
font: inherit;
text-align: left;
cursor: pointer;
}
.m-song:active,
.m-song[data-current="true"] {
background: oklch(97% 0.01 var(--hue) / 0.14);
}
.m-song-cover {
height: 44px;
flex: none;
display: flex;
}
.m-song-text {
flex: 1;
min-width: 0;
}
.m-song-title,
.m-song-sub {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.m-song-title {
font-size: 15px;
font-weight: 800;
}
.m-song-sub {
font-size: 12px;
font-weight: 700;
color: oklch(88% 0.02 var(--hue) / 0.65);
}
.m-song-time {
font: 700 12px ui-monospace, Menlo, monospace;
color: oklch(88% 0.02 var(--hue) / 0.6);
}
.m-hint {
text-align: center;
margin-top: 48px;
padding: 0 var(--m-gutter);
font-size: 18px;
font-weight: 800;
color: oklch(92% 0.02 var(--hue) / 0.8);
}
.m-hint img {
width: 96px;
height: 96px;
object-fit: contain;
opacity: 0.9;
display: block;
margin: 0 auto 8px;
}
/* -------------------------------------------------------------------- dock -- */
.m-dock {
position: absolute;
left: 0;
right: 0;
z-index: 3;
display: flex;
flex-direction: column;
gap: 10px;
padding: 0 12px calc(10px + env(safe-area-inset-bottom));
pointer-events: none;
transition: bottom 0.2s ease;
}
/* With the keyboard up the home indicator is hidden behind it, so no safe-area gap. */
.m-dock[data-keyboard="true"] {
padding-bottom: 8px;
}
.m-dock > * {
pointer-events: auto;
}
.m-dock-row {
display: flex;
align-items: center;
gap: 10px;
/* Lets the row itself ignore taps between its pills. */
pointer-events: none;
}
.m-dock-row > * {
pointer-events: auto;
}
.m-circle {
flex: none;
width: 58px;
height: 58px;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
cursor: pointer;
font: inherit;
}
.m-circle-small {
flex: none;
width: 40px;
height: 40px;
}
.m-tabs {
flex: 1;
display: flex;
padding: 4px;
border-radius: 999px;
height: 58px;
}
.m-tab {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1px;
border: none;
border-radius: 999px;
background: transparent;
color: oklch(95% 0.01 var(--hue) / 0.8);
font: inherit;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease;
}
.m-tab[data-active="true"] {
background: oklch(97% 0.01 var(--hue) / 0.2);
color: var(--paper);
box-shadow: inset 0 1px 0 oklch(100% 0 0 / 0.2);
}
.m-tab-icon {
font-size: 19px;
line-height: 1;
}
.m-tab[data-active="true"] .m-tab-icon {
color: var(--accent-dim);
}
.m-tab-label {
font-size: 11px;
font-weight: 800;
}
.m-search {
flex: 1;
min-width: 0;
height: 58px;
border-radius: 999px;
display: flex;
align-items: center;
gap: 10px;
padding: 0 10px 0 18px;
margin: 0;
font: inherit;
}
.m-search > svg {
flex: none;
opacity: 0.8;
}
.m-search input {
flex: 1;
min-width: 0;
height: 100%;
border: none;
outline: none;
background: transparent;
color: var(--paper);
font: inherit;
/* 16px or more, or iOS zooms the page into the field on focus. */
font-size: 17px;
font-weight: 700;
-webkit-appearance: none;
appearance: none;
}
.m-search input::placeholder {
color: oklch(95% 0.01 var(--hue) / 0.55);
}
.m-search input::-webkit-search-cancel-button {
display: none;
}
.m-search-closed {
cursor: pointer;
font-size: 17px;
font-weight: 700;
color: oklch(95% 0.01 var(--hue) / 0.7);
}
.m-search-clear {
flex: none;
width: 26px;
height: 26px;
border: none;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
background: oklch(97% 0.01 var(--hue) / 0.3);
color: var(--sea-deep);
cursor: pointer;
padding: 0;
}
/* ------------------------------------------------------------- mini player -- */
.m-mini {
position: relative;
display: flex;
align-items: center;
gap: 4px;
height: 60px;
padding: 0 8px 0 8px;
border-radius: 20px;
overflow: hidden;
}
.m-mini-main {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
height: 100%;
border: none;
background: none;
padding: 0;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.m-mini-cover {
height: 44px;
flex: none;
display: flex;
box-shadow: 0 3px 10px oklch(10% 0.04 var(--hue) / 0.4);
border-radius: 8px;
}
.m-mini-text {
min-width: 0;
}
.m-mini-title,
.m-mini-sub {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.m-mini-title {
font-size: 15px;
font-weight: 800;
}
.m-mini-sub {
font-size: 12px;
font-weight: 700;
color: oklch(90% 0.02 var(--hue) / 0.7);
}
.m-mini-button {
flex: none;
width: 44px;
height: 44px;
border: none;
border-radius: 999px;
background: transparent;
color: var(--paper);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
}
.m-mini-button:active {
background: oklch(97% 0.01 var(--hue) / 0.15);
}
.m-mini-progress {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 3px;
background: var(--accent);
transform-origin: left;
transition: transform 0.5s linear;
}