Files
musicmouse/web/src/components/Cover.tsx
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

121 lines
4.0 KiB
TypeScript

/** Album art, or a generated stand-in built from the album's own three colours.
*
* The gradient sits underneath the image rather than instead of it, so a cover that is
* still loading - or an album that has none - never shows a hole.
*
* The shape comes from the media type and from nowhere else: square for an album,
* taller than wide for an audiobook. Callers pick which axis is fixed, never the ratio.
*/
import type { CSSProperties } from "react";
import { THUMB_MAX_SIZE, coverUrl } from "../api/client";
import type { Album } from "../api/types";
import { aspectOf, coverBackground, coverUnderlay, isBook } from "../lib/covers";
interface Props {
album: Album;
/** Rendered size in px along the fixed axis; drives the stripe width so the generated
* pattern scales with the art. */
size: number;
/** Which axis the parent constrains. `height` keeps rows and grids even when a book
* and an album sit side by side. */
fit?: "width" | "height";
radius?: string;
className?: string;
style?: CSSProperties;
/** Print the title over generated art. Off for thumbnails, where it would not fit. */
label?: boolean;
/** A reward-gated album/track not yet earned - shows a placeholder cover instead of
* the real art or title, regardless of `has_cover`. */
locked?: boolean;
}
export function Cover({ album, size, fit = "width", radius, className, style, label, locked }: Props) {
const book = isBook(album);
const defaultRadius = book ? "6px 18px 18px 6px" : "16px";
const box: CSSProperties =
fit === "height"
? { height: "100%", width: "auto", aspectRatio: aspectOf(album) }
: { width: "100%", aspectRatio: aspectOf(album) };
return (
<div
className={className}
style={{
position: "relative",
flex: "none",
borderRadius: radius ?? defaultRadius,
overflow: "hidden",
// Real art is opaque: paint the cheap flat underlay, not the stripes/spine.
background:
album.has_cover && !locked
? coverUnderlay(album)
: coverBackground(album, Math.max(6, Math.round(size / 13))),
...box,
...style,
}}
>
{locked ? (
<img
src={book ? "/hidden_audiobook_or_podcast.png" : "/hiddenalbum.png"}
alt=""
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
clipPath: book ? "inset(0 5% 0 0)" : undefined,
}}
/>
) : (
<>
{!album.has_cover && label && (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: book ? "10% 14% 10% 20%" : "10%",
textAlign: "center",
fontSize: Math.max(11, Math.round(size / 11)),
fontWeight: 900,
lineHeight: 1.15,
color: "oklch(99% 0 0 / .92)",
textShadow: "0 2px 8px oklch(15% 0.05 var(--hue) / .6)",
overflow: "hidden",
}}
>
{album.title}
</div>
)}
{album.has_cover && (
<img
src={coverUrl(album.id, {
size: size <= THUMB_MAX_SIZE ? "thumb" : "full",
version: album.cover_v,
})}
alt=""
loading="lazy"
decoding="async"
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
// Books keep a sliver of spine showing on the right, so the shelf
// metaphor survives contact with real square artwork.
clipPath: book ? "inset(0 5% 0 0)" : undefined,
}}
/>
)}
</>
)}
</div>
);
}