Files
musicmouse/web/src/styles/mobile.css
Martin Bauer 2a629faf50 Web frontend: switchable dolphin/dinosaur skins
Two kids, two looks: ?theme=dolphin or ?theme=dinosaur picks one, and the
choice is remembered in the browser so the installed home-screen app keeps it.
Everything that differs per child lives in one Skin object per file under
web/src/lib/skins/; layout and feature toggles in lib/theme.ts stay shared.
The dolphin renders pixel-identical to before.

The hardcoded sea hues (210 music, 175 typing game, 340 accent) are now CSS
variables written once at startup, and the Gecko-friendly sRGB gradient stops
come from the skin. The dinosaur is jungle green with a lava-orange accent,
its own mascot, icons, manifest and browser colour.

Nothing underwater is left in the dinosaur skin: leaves instead of bubbles
(play-view canvas, room page, typing game), a walking mascot, lava blobs in
the bubble game, a hatching egg instead of the treasure chest, dino pets that
walk or fly, a prehistoric speed ladder with the T-Rex as the hero rung, and
renamed games and light colours. Pets and ladder rungs are keyed by the
backend's existing ids, used as slots, so the backend and save format are
unchanged. Pets show as emoji until drawings exist.

python-backend/.gitignore: ignore tippen-curriculum-dino.yml, the dino
device's personal curriculum, like the original.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:05:41 +02:00

603 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 can leave the layout viewport shorter than the screen - after the keyboard has
been up, `100dvh` in a home-screen app comes back one status bar short - and the
page behind the stage showed as a white strip along the bottom. Pinning the stage to
the viewport's edges does not depend on that height at all, and the sea colour
underneath covers whatever might still peek through. */
html,
body {
background: var(--stage-deep, #001b21);
}
.stage[data-phone] {
position: fixed;
inset: 0;
height: auto;
}
.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 -- */
.m-title-row {
display: flex;
align-items: center;
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 {
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;
}
/* 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 {
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;
}