Web frontend: touch layout for phones

Phones got the kiosk's keyboard-first layout squeezed down: shelves cut off
by the tab rail and a header taking a fifth of the screen. They now get
their own layout, chosen by a media query (narrow, or short landscape on a
touch screen); tablets and desktop are unchanged.

- Pick a group (Musik/Hörbücher/Podcasts), then a category, then scroll
  through a grid of smaller albums.
- A floating glass dock: mini player, Musik/Zimmer tabs (no typing tab - a
  phone has no keyboard for it) and a search button that turns into a real
  search field, raised above the on-screen keyboard.
- Album sheet and room page get compact phone variants.

Browse state stays in the shared UiState, so resizing or rotating keeps
your place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 21:33:26 +02:00
parent 61104d1e25
commit 0f08e1d16c
11 changed files with 1232 additions and 28 deletions

561
web/src/styles/mobile.css Normal file
View File

@@ -0,0 +1,561 @@
/* 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 210 / 0.42);
--m-glass-rim: oklch(100% 0 0 / 0.22);
--m-glass-shadow: 0 10px 30px oklch(10% 0.04 210 / 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));
}
.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;
padding: env(safe-area-inset-top) 0 calc(var(--m-dock-clearance) + 84px);
animation: viewEnter 180ms ease-out;
}
/* ------------------------------------------------------------------ header -- */
.m-title-row {
display: flex;
align-items: center;
gap: 10px;
padding: 14px var(--m-gutter) 4px;
}
.m-title-mascot {
width: 44px;
height: 44px;
object-fit: contain;
filter: drop-shadow(0 3px 8px oklch(15% 0.05 210 / 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 210 / 0.45);
}
.m-status {
font-size: 13px;
font-weight: 800;
color: var(--paper);
background: oklch(97% 0.01 210 / 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. Fades from the top of the
sea into transparency rather than ending in a hard edge. */
.m-sticky {
position: sticky;
top: 0;
z-index: 2;
padding: 10px var(--m-gutter) 14px;
background: linear-gradient(180deg, #2f6f7aee 0%, #2f6f7a99 65%, #2f6f7a00 100%);
}
.m-navbar {
display: flex;
align-items: center;
gap: 10px;
padding-top: 12px;
}
.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 210 / 0.75);
}
/* ------------------------------------------------------------- segmented -- */
.m-segmented {
display: flex;
gap: 2px;
padding: 3px;
border-radius: 999px;
background: oklch(20% 0.04 210 / 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 210 / 0.35);
}
.m-segment {
flex: 1;
min-width: 0;
border: none;
background: transparent;
color: oklch(95% 0.01 210 / 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 210 / 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 210 / 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 210 / 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 210 / 0.65);
}
.m-song-time {
font: 700 12px ui-monospace, Menlo, monospace;
color: oklch(88% 0.02 210 / 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 210 / 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 210 / 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 210 / 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 210 / 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 210 / 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 210 / 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 210 / 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 210 / 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 210 / 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;
}