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>
This commit is contained in:
2026-10-02 23:05:41 +02:00
parent 21d65cad9e
commit 2a629faf50
64 changed files with 1257 additions and 486 deletions

View File

@@ -5,6 +5,7 @@ import { usePlaybackClock } from "../hooks/usePlaybackClock";
import { isBook, nowPlayingText } from "../lib/covers";
import { clock, remainingInAlbum } from "../lib/format";
import { groupOf } from "../lib/search";
import { SKIN } from "../lib/skins";
import { SHOW_DECORATIVE_ANIMATIONS } from "../lib/theme";
import { Cover } from "./Cover";
import { ProgressBar } from "./ProgressBar";
@@ -27,6 +28,12 @@ interface Props {
assignPending: boolean;
}
/** How the mascot crosses the play view - keyframes in app.css. */
const MASCOT_CROSSING = {
swim: "mascotSwim 62s linear infinite",
walk: "mascotWalk 62s linear infinite, mascotWaddle 0.8s ease-in-out infinite",
} as const;
export function PlayView({
state,
album,
@@ -66,7 +73,7 @@ export function PlayView({
}}
>
<img
src="/dolphin-mascot.png"
src={SKIN.mascot}
alt=""
style={{
position: "absolute",
@@ -78,11 +85,15 @@ export function PlayView({
zIndex: 0,
pointerEvents: "none",
opacity: 0.92,
animation: SHOW_DECORATIVE_ANIMATIONS ? "dolphinSwim 62s linear infinite" : "none",
animation: SHOW_DECORATIVE_ANIMATIONS ? MASCOT_CROSSING[SKIN.mascotMotion] : "none",
// `mascotSwim` assumes a drawing that faces right. `scale` composes with the
// keyframes' `transform` rather than replacing it, so a left-facing mascot
// mirrored here still turns round at the far edge with everyone else.
scale: SKIN.mascotFacesLeft ? "-1 1" : undefined,
// Freezes in its current pose rather than snapping back to frame 0 -
// `animation: "none"` would restart it, `animationPlayState` just pauses.
animationPlayState: state.playing ? "running" : "paused",
filter: "drop-shadow(0 10px 26px oklch(10% 0.05 210 / .45))",
filter: "drop-shadow(0 10px 26px oklch(10% 0.05 var(--hue) / .45))",
}}
/>
@@ -93,7 +104,7 @@ export function PlayView({
position: "relative",
zIndex: 1,
display: "flex",
filter: "drop-shadow(0 20px 40px oklch(10% 0.05 210 / .55))",
filter: "drop-shadow(0 20px 40px oklch(10% 0.05 var(--hue) / .55))",
}}
>
{album ? (
@@ -123,7 +134,7 @@ export function PlayView({
height: "100%",
aspectRatio: 1,
borderRadius: 28,
background: "oklch(35% 0.03 210)",
background: "oklch(35% 0.03 var(--hue))",
}}
/>
)}
@@ -145,7 +156,7 @@ export function PlayView({
style={{
fontSize: 20,
fontWeight: 700,
color: "oklch(88% 0.02 210 / .8)",
color: "oklch(88% 0.02 var(--hue) / .8)",
marginTop: 8,
}}
>
@@ -183,7 +194,7 @@ export function PlayView({
marginTop: 8,
fontSize: 14,
fontWeight: 800,
color: "oklch(88% 0.02 210 / .8)",
color: "oklch(88% 0.02 var(--hue) / .8)",
}}
>
<span style={{ fontFamily: "ui-monospace, Menlo, monospace" }}>
@@ -232,13 +243,13 @@ export function PlayView({
width: 52,
height: 52,
borderRadius: 999,
background: assignPending ? "var(--accent)" : "oklch(97% 0.01 210 / .95)",
background: assignPending ? "var(--accent)" : "oklch(97% 0.01 var(--hue) / .95)",
color: assignPending ? "#fff" : "var(--ink)",
fontSize: 22,
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: "0 8px 24px oklch(15% 0.05 210 / .4)",
boxShadow: "0 8px 24px oklch(15% 0.05 var(--hue) / .4)",
}}
>
🎛️