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>
593 lines
12 KiB
CSS
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;
|
|
}
|