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

@@ -2,12 +2,12 @@
the tokens below are the handful of values that repeat. */
:root {
--ink: oklch(30% 0.04 210);
--paper: oklch(97% 0.01 210);
--accent: oklch(70% 0.16 340);
--accent-dim: oklch(78% 0.14 340);
--sea-deep: oklch(20% 0.045 210);
--shadow: oklch(15% 0.05 210 / 0.35);
--ink: oklch(30% 0.04 var(--hue));
--paper: oklch(97% 0.01 var(--hue));
--accent: oklch(70% 0.16 var(--accent-hue));
--accent-dim: oklch(78% 0.14 var(--accent-hue));
--sea-deep: oklch(20% 0.045 var(--hue));
--shadow: oklch(15% 0.05 var(--hue) / 0.35);
}
* {
@@ -48,7 +48,7 @@ button {
}
}
@keyframes dolphinBob {
@keyframes mascotBob {
0%,
100% {
transform: translateY(0) rotate(-2deg);
@@ -58,7 +58,7 @@ button {
}
}
@keyframes dolphinSwim {
@keyframes mascotSwim {
0% { transform: translate(-20vw, 10vh) rotate(-3deg) scaleX(1); }
15% { transform: translate(2vw, 2vh) rotate(4deg) scaleX(1); }
30% { transform: translate(24vw, 12vh) rotate(-3deg) scaleX(1); }
@@ -71,6 +71,21 @@ button {
100% { transform: translate(-20vw, 10vh) rotate(-3deg) scaleX(-1); }
}
/* The walking mascot's crossing: the same timeline as `mascotSwim`, turn included, but
along one level line instead of in waves. `mascotWaddle` runs beside it on the
separate `rotate`/`translate` properties, which compose with this `transform`. */
@keyframes mascotWalk {
0% { transform: translate(-20vw, 10vh) scaleX(1); }
70% { transform: translate(84vw, 10vh) scaleX(1); }
74% { transform: translate(84vw, 10vh) scaleX(-1); }
100% { transform: translate(-20vw, 10vh) scaleX(-1); }
}
@keyframes mascotWaddle {
0%, 100% { rotate: -4deg; translate: 0 0; }
50% { rotate: 4deg; translate: 0 -6px; }
}
/* Entrance animations for the browse grid and the album modal - gated by
`ANIMATE_VIEW_TRANSITIONS` in `lib/theme.ts`. */
@keyframes viewEnter {
@@ -106,9 +121,10 @@ button {
overflow: hidden;
/* Gradient stops are plain sRGB hex, not oklch(): a gradient with an oklch stop
interpolates in Oklab, which Gecko expands stop by stop on the CPU every time the
display list is rebuilt - about 15% of a Pi-profile's busy time. Values are the
oklch(55% 0.07 210) / (38% 0.06 210) / (20% 0.045 210) they replace. */
background: linear-gradient(180deg, #397d88 0%, #0e4b54 45%, #001b21 100%);
display list is rebuilt - about 15% of a Pi-profile's busy time. The skin supplies
them (`stageGradient` in lib/skins/), pre-converted from oklch(55% 0.07 hue) /
(38% 0.06 hue) / (20% 0.045 hue). */
background: linear-gradient(180deg, var(--stage-top) 0%, var(--stage-mid) 45%, var(--stage-deep) 100%);
}
/* Still used by the room-control page's own `<Bubbles>` field (`RoomView.tsx`) - the
@@ -124,6 +140,26 @@ button {
pointer-events: none;
}
/* A leaf (Drift.tsx, the dinosaur's answer to `.bubble`) tumbling down the stage and
swaying on its way. Transform only: each leaf's opacity is its own inline style. */
@keyframes leafFall {
0% { transform: translate(0, 0) rotate(0deg); }
25% { transform: translate(28px, 32vh) rotate(80deg); }
50% { transform: translate(-6px, 64vh) rotate(170deg); }
75% { transform: translate(24px, 96vh) rotate(250deg); }
100% { transform: translate(-10px, calc(100vh + 80px)) rotate(330deg); }
}
.leaf {
position: absolute;
top: -40px;
line-height: 1;
animation-name: leafFall;
animation-timing-function: linear;
animation-iteration-count: infinite;
pointer-events: none;
}
.ambience {
display: block;
/* A canvas is a replaced element: without an explicit size here, its CSS layout box
@@ -146,13 +182,13 @@ button {
padding: 9px 15px;
border-radius: 999px;
white-space: nowrap;
background: oklch(97% 0.01 210 / 0.16);
background: oklch(97% 0.01 var(--hue) / 0.16);
color: var(--paper);
}
.pill[data-active="true"] {
background: var(--paper);
color: oklch(28% 0.04 210);
color: oklch(28% 0.04 var(--hue));
box-shadow: 0 4px 14px var(--shadow);
}
@@ -269,10 +305,10 @@ button {
flex: none;
border-radius: 999px;
font-size: 17px;
background: linear-gradient(160deg, #eef7f961, #eef7f91f) /* oklch(97% 0.01 210 / .38 -> .12) */;
background: linear-gradient(160deg, rgb(var(--paper-rgb) / .38), rgb(var(--paper-rgb) / .12)) /* oklch(97% 0.01 hue), kept sRGB like .stage */;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid oklch(97% 0.01 210 / 0.4);
border: 1px solid oklch(97% 0.01 var(--hue) / 0.4);
box-shadow:
0 3px 10px var(--shadow),
inset 0 1px 0 oklch(100% 0 0 / 0.35);
@@ -281,10 +317,10 @@ button {
/* A frosted panel wrapped around a shelf/list of rows so they read as a distinct
surface floating over the stage gradient, rather than flat text on a gradient. */
.glass-panel {
background: linear-gradient(160deg, #eef7f924, #eef7f90d) /* oklch(97% 0.01 210 / .14 -> .05) */;
background: linear-gradient(160deg, rgb(var(--paper-rgb) / .14), rgb(var(--paper-rgb) / .05)) /* oklch(97% 0.01 hue), kept sRGB like .stage */;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid oklch(97% 0.01 210 / 0.16);
border: 1px solid oklch(97% 0.01 var(--hue) / 0.16);
border-radius: 22px;
box-shadow:
0 8px 24px var(--shadow),
@@ -296,7 +332,7 @@ button {
.list-row {
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid oklch(97% 0.01 210 / 0.16);
border: 1px solid oklch(97% 0.01 var(--hue) / 0.16);
box-shadow:
0 4px 14px var(--shadow),
inset 0 1px 0 oklch(100% 0 0 / 0.12);
@@ -326,13 +362,13 @@ button {
.sheet {
background: var(--paper);
border-radius: 24px;
box-shadow: 0 20px 50px oklch(10% 0.04 210 / 0.5);
box-shadow: 0 20px 50px oklch(10% 0.04 var(--hue) / 0.5);
}
.overlay {
position: absolute;
inset: 0;
background: oklch(15% 0.03 210 / 0.55);
background: oklch(15% 0.03 var(--hue) / 0.55);
display: flex;
align-items: center;
justify-content: center;
@@ -398,7 +434,7 @@ input[type="range"] {
}
.searchbox-placeholder {
color: oklch(30% 0.04 210 / .45);
color: oklch(30% 0.04 var(--hue) / .45);
}
.searchbox-caret {
@@ -417,7 +453,7 @@ input[type="range"] {
}
.searchbox-note {
color: oklch(90% 0.02 210 / .85);
color: oklch(90% 0.02 var(--hue) / .85);
font-size: 15px;
font-weight: 700;
}