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:
BIN
web/public/dinosaur-mascot.png
Normal file
BIN
web/public/dinosaur-mascot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 977 KiB |
BIN
web/public/dinosaur-remote.png
Normal file
BIN
web/public/dinosaur-remote.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 986 KiB |
BIN
web/public/dinosaur/apple-touch-icon.png
Normal file
BIN
web/public/dinosaur/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
web/public/dinosaur/icon-192.png
Normal file
BIN
web/public/dinosaur/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
BIN
web/public/dinosaur/icon-512.png
Normal file
BIN
web/public/dinosaur/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
BIN
web/public/dinosaur/icon-maskable-512.png
Normal file
BIN
web/public/dinosaur/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
22
web/public/dinosaur/manifest.webmanifest
Normal file
22
web/public/dinosaur/manifest.webmanifest
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "Musik Dino",
|
||||
"short_name": "Musik",
|
||||
"description": "Musik und Hörbücher für die MusicMouse",
|
||||
"start_url": "/?theme=dinosaur",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#1c311c",
|
||||
"theme_color": "#1c311c",
|
||||
"lang": "de",
|
||||
"icons": [
|
||||
{ "src": "/dinosaur/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/dinosaur/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{
|
||||
"src": "/dinosaur/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
// work offline - the audio, the library and the state all live on the mouse, and a
|
||||
// cached answer for any of those would be a lie.
|
||||
|
||||
const SHELL = "musikdelphin-shell-v1";
|
||||
const SHELL = "musikdelphin-shell-v2";
|
||||
|
||||
// There is no cover cache any more; `activate` below deletes every cache that is not
|
||||
// SHELL, which retires the two generations of cover cache this worker used to keep.
|
||||
@@ -16,6 +16,7 @@ self.addEventListener("install", (event) => {
|
||||
"/",
|
||||
"/manifest.webmanifest",
|
||||
"/dolphin-mascot.png",
|
||||
"/dinosaur-mascot.png",
|
||||
"/fonts/nunito.css",
|
||||
"/fonts/nunito-latin.woff2",
|
||||
"/fonts/nunito-latin-ext.woff2",
|
||||
|
||||
@@ -40,6 +40,7 @@ import { playPop } from "./lib/pop";
|
||||
import { targetForAlbum } from "./lib/remote";
|
||||
import type { Group, Mode, Results, SongHit } from "./lib/search";
|
||||
import { buildSearchIndex, categoryMatches, groupOf, results as computeResults } from "./lib/search";
|
||||
import { SKIN } from "./lib/skins";
|
||||
import { SHOW_AMBIENCE, SHOW_CARD_BLUR, SHOW_GLASS_BLUR } from "./lib/theme";
|
||||
|
||||
// The room page and the typing game are each a tab away from the player and together a
|
||||
@@ -557,7 +558,7 @@ export function App() {
|
||||
fontWeight: 800,
|
||||
padding: "10px 20px",
|
||||
borderRadius: 999,
|
||||
boxShadow: "0 8px 24px oklch(15% 0.05 210 / .4)",
|
||||
boxShadow: "0 8px 24px oklch(15% 0.05 var(--hue) / .4)",
|
||||
}}
|
||||
>
|
||||
{assignStatus}
|
||||
@@ -654,7 +655,7 @@ export function App() {
|
||||
border: "none",
|
||||
borderRadius: 999,
|
||||
cursor: "pointer",
|
||||
background: "oklch(15% 0.03 210 / .35)",
|
||||
background: "oklch(15% 0.03 var(--hue) / .35)",
|
||||
color: "#fff",
|
||||
opacity: 0.5,
|
||||
}}
|
||||
@@ -680,7 +681,7 @@ export function App() {
|
||||
border: "none",
|
||||
borderRadius: 999,
|
||||
cursor: "pointer",
|
||||
background: "oklch(15% 0.03 210 / .35)",
|
||||
background: "oklch(15% 0.03 var(--hue) / .35)",
|
||||
color: "#fff",
|
||||
opacity: 0.5,
|
||||
}}
|
||||
@@ -734,10 +735,10 @@ function CornerButton({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "oklch(97% 0.01 210 / .95)",
|
||||
background: "oklch(97% 0.01 var(--hue) / .95)",
|
||||
border: "none",
|
||||
borderRadius: 999,
|
||||
boxShadow: "0 6px 18px oklch(15% 0.05 210 / .4)",
|
||||
boxShadow: "0 6px 18px oklch(15% 0.05 var(--hue) / .4)",
|
||||
fontSize: 20,
|
||||
fontWeight: 900,
|
||||
color: "var(--ink)",
|
||||
@@ -762,7 +763,7 @@ function ConnectionDot({ online, state }: { online: boolean; state: { active_fig
|
||||
fontSize: 14,
|
||||
fontWeight: 800,
|
||||
color: online ? "var(--paper)" : "oklch(75% 0.16 30)",
|
||||
background: "oklch(97% 0.01 210 / .16)",
|
||||
background: "oklch(97% 0.01 var(--hue) / .16)",
|
||||
padding: "6px 14px",
|
||||
borderRadius: 999,
|
||||
}}
|
||||
@@ -776,15 +777,15 @@ function Splash({ error }: { error: string | null }) {
|
||||
return (
|
||||
<div
|
||||
className="overlay"
|
||||
style={{ zIndex: 10, background: "oklch(20% 0.045 210 / .92)", flexDirection: "column", gap: 18 }}
|
||||
style={{ zIndex: 10, background: "oklch(20% 0.045 var(--hue) / .92)", flexDirection: "column", gap: 18 }}
|
||||
>
|
||||
<img
|
||||
src="/dolphin-mascot.png"
|
||||
src={SKIN.mascot}
|
||||
alt=""
|
||||
style={{ width: 140, height: 140, objectFit: "contain", animation: "dolphinBob 1.6s ease-in-out infinite" }}
|
||||
style={{ width: 140, height: 140, objectFit: "contain", animation: "mascotBob 1.6s ease-in-out infinite" }}
|
||||
/>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: "var(--paper)" }}>
|
||||
{error ? "Der Delphin ist nicht erreichbar …" : "Einen Moment …"}
|
||||
{error ? `${SKIN.mascotName} ist nicht erreichbar …` : "Einen Moment …"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -53,7 +53,7 @@ export function AlbumModal({
|
||||
className={ANIMATE_VIEW_TRANSITIONS ? "overlay backdrop-enter" : "overlay"}
|
||||
style={{
|
||||
zIndex: 4,
|
||||
background: "oklch(15% 0.03 210 / .6)",
|
||||
background: "oklch(15% 0.03 var(--hue) / .6)",
|
||||
...(phone && { alignItems: "flex-end", padding: "calc(40px + env(safe-area-inset-top)) 0 0" }),
|
||||
}}
|
||||
onClick={onClose}
|
||||
@@ -63,14 +63,14 @@ export function AlbumModal({
|
||||
className={ANIMATE_VIEW_TRANSITIONS ? "sheet-enter" : undefined}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
style={{
|
||||
background: "oklch(96% 0.012 210)",
|
||||
background: "oklch(96% 0.012 var(--hue))",
|
||||
borderRadius: phone ? "26px 26px 0 0" : 26,
|
||||
padding: phone ? "20px 16px calc(20px + env(safe-area-inset-bottom))" : 26,
|
||||
width: phone ? "100%" : 640,
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
overflow: "auto",
|
||||
boxShadow: "0 24px 60px oklch(10% 0.04 210 / .55)",
|
||||
boxShadow: "0 24px 60px oklch(10% 0.04 var(--hue) / .55)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", gap: phone ? 14 : 20, alignItems: "flex-start" }}>
|
||||
@@ -88,7 +88,7 @@ export function AlbumModal({
|
||||
style={{
|
||||
fontSize: phone ? 20 : 26,
|
||||
fontWeight: 900,
|
||||
color: "oklch(22% 0.03 210)",
|
||||
color: "oklch(22% 0.03 var(--hue))",
|
||||
lineHeight: 1.15,
|
||||
}}
|
||||
>
|
||||
@@ -98,7 +98,7 @@ export function AlbumModal({
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: 700,
|
||||
color: "oklch(45% 0.03 210 / .85)",
|
||||
color: "oklch(45% 0.03 var(--hue) / .85)",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
@@ -108,7 +108,7 @@ export function AlbumModal({
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "oklch(50% 0.03 210 / .7)",
|
||||
color: "oklch(50% 0.03 var(--hue) / .7)",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
@@ -123,8 +123,8 @@ export function AlbumModal({
|
||||
marginTop: 14,
|
||||
border: "none",
|
||||
cursor: album.locked ? "default" : "pointer",
|
||||
background: album.locked ? "oklch(80% 0.02 210)" : "var(--accent)",
|
||||
color: album.locked ? "oklch(40% 0.03 210)" : "#fff",
|
||||
background: album.locked ? "oklch(80% 0.02 var(--hue))" : "var(--accent)",
|
||||
color: album.locked ? "oklch(40% 0.03 var(--hue))" : "#fff",
|
||||
fontSize: phone ? 15 : 16,
|
||||
fontWeight: 800,
|
||||
padding: phone ? "10px 18px" : "12px 22px",
|
||||
@@ -132,7 +132,7 @@ export function AlbumModal({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
boxShadow: album.locked ? "none" : "0 4px 14px oklch(70% 0.16 340 / .45)",
|
||||
boxShadow: album.locked ? "none" : "0 4px 14px oklch(70% 0.16 var(--accent-hue) / .45)",
|
||||
}}
|
||||
>
|
||||
{album.locked ? (
|
||||
@@ -158,7 +158,7 @@ export function AlbumModal({
|
||||
style={{
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: "oklch(88% 0.02 210)",
|
||||
background: "oklch(88% 0.02 var(--hue))",
|
||||
color: "var(--ink)",
|
||||
fontSize: 18,
|
||||
fontWeight: 900,
|
||||
@@ -193,8 +193,8 @@ export function AlbumModal({
|
||||
textAlign: "left",
|
||||
opacity: track.locked ? 0.55 : 1,
|
||||
background: current
|
||||
? "oklch(70% 0.16 340 / .16)"
|
||||
: "oklch(30% 0.03 210 / .05)",
|
||||
? "oklch(70% 0.16 var(--accent-hue) / .16)"
|
||||
: "oklch(30% 0.03 var(--hue) / .05)",
|
||||
outline: !phone && index === selectedIndex ? "3px solid var(--accent)" : "none",
|
||||
outlineOffset: 2,
|
||||
}}
|
||||
@@ -209,8 +209,8 @@ export function AlbumModal({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
font: "800 13px ui-monospace, Menlo, monospace",
|
||||
background: current ? "var(--accent)" : "oklch(88% 0.02 210)",
|
||||
color: current ? "#fff" : "oklch(35% 0.03 210)",
|
||||
background: current ? "var(--accent)" : "oklch(88% 0.02 var(--hue))",
|
||||
color: current ? "#fff" : "oklch(35% 0.03 var(--hue))",
|
||||
}}
|
||||
>
|
||||
{track.locked ? "❓" : index + 1}
|
||||
@@ -220,7 +220,7 @@ export function AlbumModal({
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 800,
|
||||
color: current ? "oklch(45% 0.16 340)" : "oklch(24% 0.03 210)",
|
||||
color: current ? "oklch(45% 0.16 var(--accent-hue))" : "oklch(24% 0.03 var(--hue))",
|
||||
}}
|
||||
>
|
||||
{track.locked ? "❓ Noch gesperrt" : track.title}
|
||||
@@ -230,7 +230,7 @@ export function AlbumModal({
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: "oklch(45% 0.03 210 / .8)",
|
||||
color: "oklch(45% 0.03 var(--hue) / .8)",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
@@ -243,7 +243,7 @@ export function AlbumModal({
|
||||
<div
|
||||
style={{
|
||||
font: "700 13px ui-monospace, Menlo, monospace",
|
||||
color: "oklch(45% 0.03 210 / .7)",
|
||||
color: "oklch(45% 0.03 var(--hue) / .7)",
|
||||
}}
|
||||
>
|
||||
{clock(track.duration)}
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
* re-seeded from the server, read against `performance.now()`) but reads it from
|
||||
* inside the loop instead of `useState`, which is what keeps 60fps from touching
|
||||
* React at all.
|
||||
*
|
||||
* Under a skin whose `drift` is "leaves" the same particles fall instead of rising and
|
||||
* are painted as tumbling leaves: same spawn rate, same current (a wind now, rather than
|
||||
* water), same beat bursts - only the direction and the shape change.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
@@ -43,6 +47,7 @@ import {
|
||||
import type { AmbienceTunables, ManualControl } from "../lib/ambienceTunables";
|
||||
import { AMBIENCE_QUALITY } from "../lib/lowPower";
|
||||
import { groupOf, type Group } from "../lib/search";
|
||||
import { SKIN } from "../lib/skins";
|
||||
|
||||
/** Everything `?debugDynamicUI=1` wants to see: the raw analysis, what it maps to,
|
||||
* and where the eased, on-screen look currently sits relative to that target. */
|
||||
@@ -92,17 +97,20 @@ const DEBUG_EMIT_INTERVAL_MS = 200;
|
||||
//: drive the kick), not *fetch* (the curve is wanted regardless of beat quality).
|
||||
const PULSE_THRESHOLD = 0.3;
|
||||
|
||||
/** A bubble or, under the leaves skin, a leaf - see the header. */
|
||||
interface Bubble {
|
||||
/** Horizontal position in CSS pixels - stateful, unlike the old sine-wander's fixed
|
||||
* anchor: a persistent one-directional current has to be integrated over time. */
|
||||
x: number;
|
||||
/** Pixels risen since spawn. */
|
||||
/** Pixels travelled since spawn - risen for a bubble, fallen for a leaf. */
|
||||
risen: number;
|
||||
/** Size at spawn. */
|
||||
baseSize: number;
|
||||
/** Current painted size - grows from `baseSize` as the bubble rises. */
|
||||
size: number;
|
||||
baseAlpha: number;
|
||||
/** A leaf's starting angle and sway, radians - unused by a bubble. */
|
||||
phase: number;
|
||||
}
|
||||
|
||||
//: How quickly the painted look eases toward the per-frame target - an exponential
|
||||
@@ -120,6 +128,11 @@ const BEAT_BURST_RATE = 6;
|
||||
const BASE_RISE_FRACTION_PER_SECOND = 0.12;
|
||||
const BUBBLE_MIN_SIZE = 8;
|
||||
const BUBBLE_MAX_SIZE = 24;
|
||||
//: Leaves fall a little slower than bubbles rise, and are drawn larger - a leaf the
|
||||
//: size of a small bubble is a speck.
|
||||
const LEAF_SPEED = 0.7;
|
||||
const LEAF_SCALE = 1.6;
|
||||
const LEAVES = SKIN.drift === "leaves";
|
||||
//: The canvas frame clamps to this, so a tab returning from the background after
|
||||
//: minutes away doesn't dump a giant `dt` into the physics in one jump.
|
||||
const MAX_FRAME_SECONDS = 0.05;
|
||||
@@ -171,9 +184,29 @@ function spawnBubble(width: number): Bubble {
|
||||
baseSize,
|
||||
size: baseSize,
|
||||
baseAlpha: 0.25 + Math.random() * 0.2,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
};
|
||||
}
|
||||
|
||||
/** An almond-shaped leaf with a midrib, centred on (x, y). */
|
||||
function paintLeaf(ctx: CanvasRenderingContext2D, x: number, y: number, size: number, angle: number): void {
|
||||
const half = size / 2;
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.rotate(angle);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-half, 0);
|
||||
ctx.quadraticCurveTo(0, -half * 0.75, half, 0);
|
||||
ctx.quadraticCurveTo(0, half * 0.75, -half, 0);
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 0.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-half * 0.8, 0);
|
||||
ctx.lineTo(half * 0.8, 0);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/** Turns already-smoothed energy/valence/drive into a full `Ambience` - shared
|
||||
* between the mount-time seed (unsmoothed, snapped straight to the first sample) and
|
||||
* every subsequent frame (smoothed with `tunables.curveSmoothingTau`), so the two
|
||||
@@ -411,14 +444,20 @@ export function Ambience({ album, state, tunables, manual, onDebugFrame }: Props
|
||||
// Not playing (and not in manual mode): no new spawns, but bubbles already in
|
||||
// flight keep rising and draining away rather than freezing mid-air.
|
||||
|
||||
const riseSpeed = current.rise * size.height * BASE_RISE_FRACTION_PER_SECOND;
|
||||
const riseSpeed =
|
||||
current.rise * size.height * BASE_RISE_FRACTION_PER_SECOND * (LEAVES ? LEAF_SPEED : 1);
|
||||
bubbles = bubbles.filter((b) => {
|
||||
const heightFraction = clamp01(b.risen / size.height);
|
||||
// Height above the bottom, which is what the current's bands are measured in.
|
||||
const travelled = clamp01(b.risen / size.height);
|
||||
const heightFraction = LEAVES ? 1 - travelled : travelled;
|
||||
const velocity = currentVelocityAt(heightFraction, kickedMagnitude);
|
||||
b.x = (((b.x + velocity * dt) % size.width) + size.width) % size.width;
|
||||
b.risen += riseSpeed * dt;
|
||||
b.size = b.baseSize * (1 + tunables.bubbleGrowthRate * heightFraction);
|
||||
return b.risen < size.height + BUBBLE_MAX_SIZE * 2;
|
||||
// Bubbles swell as the pressure drops on the way up; a leaf stays a leaf.
|
||||
b.size = LEAVES
|
||||
? b.baseSize * LEAF_SCALE
|
||||
: b.baseSize * (1 + tunables.bubbleGrowthRate * heightFraction);
|
||||
return b.risen < size.height + BUBBLE_MAX_SIZE * 2 * (LEAVES ? LEAF_SCALE : 1);
|
||||
});
|
||||
|
||||
// No `clearRect`: the gradient below is fully opaque (`oklchString` defaults to
|
||||
@@ -433,10 +472,21 @@ export function Ambience({ album, state, tunables, manual, onDebugFrame }: Props
|
||||
ctx.fillRect(0, 0, size.width, size.height);
|
||||
|
||||
if (!reducedMotion) {
|
||||
const third = size.height / 3;
|
||||
for (const b of bubbles) {
|
||||
if (LEAVES) {
|
||||
// Falling from above the top edge, fading out over the bottom third.
|
||||
const y = b.risen - BUBBLE_MAX_SIZE * LEAF_SCALE;
|
||||
const toBottom = size.height - y;
|
||||
const fade = toBottom < third ? Math.max(0, toBottom / third) : 1;
|
||||
const sway = Math.sin(b.risen / 60 + b.phase);
|
||||
ctx.fillStyle = oklchString(current.tint, b.baseAlpha * fade);
|
||||
ctx.strokeStyle = ctx.fillStyle;
|
||||
paintLeaf(ctx, b.x + sway * 10, y, b.size, b.phase + b.risen / 140 + sway * 0.4);
|
||||
continue;
|
||||
}
|
||||
const y = size.height + BUBBLE_MAX_SIZE - b.risen;
|
||||
const topThird = size.height / 3;
|
||||
const fade = y < topThird ? Math.max(0, y / topThird) : 1;
|
||||
const fade = y < third ? Math.max(0, y / third) : 1;
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = oklchString(current.tint, b.baseAlpha * fade);
|
||||
ctx.arc(b.x, y, b.size / 2, 0, Math.PI * 2);
|
||||
|
||||
@@ -194,7 +194,7 @@ function ManualControlRow({
|
||||
}}
|
||||
>
|
||||
<span style={{ opacity: 0.6, width: 58, display: "inline-block" }}>
|
||||
manual {active && <span style={{ color: "oklch(80% 0.14 340)" }}>●</span>}
|
||||
manual {active && <span style={{ color: "oklch(80% 0.14 var(--accent-hue))" }}>●</span>}
|
||||
</span>
|
||||
<label style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
||||
<input
|
||||
@@ -266,8 +266,8 @@ export function AmbienceDebugOverlay({
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 1000,
|
||||
background: "oklch(15% 0.02 210 / .92)",
|
||||
color: "oklch(95% 0.01 210)",
|
||||
background: "oklch(15% 0.02 var(--hue) / .92)",
|
||||
color: "oklch(95% 0.01 var(--hue))",
|
||||
font: "12px/1.5 ui-monospace, Menlo, monospace",
|
||||
padding: "8px 14px",
|
||||
display: "flex",
|
||||
@@ -278,7 +278,7 @@ export function AmbienceDebugOverlay({
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", gap: 14, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<b style={{ color: "oklch(80% 0.14 340)" }}>debugDynamicUI</b>
|
||||
<b style={{ color: "oklch(80% 0.14 var(--accent-hue))" }}>debugDynamicUI</b>
|
||||
<Field label="group" value={snapshot.group} />
|
||||
<Field label="playing" value={snapshot.playing ? "yes" : "no"} />
|
||||
<Field label="position" value={`${fmt(snapshot.position, 1)}s`} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { SKIN } from "../lib/skins";
|
||||
import { SHOW_APP_TITLE } from "../lib/theme";
|
||||
|
||||
interface Props {
|
||||
@@ -12,8 +13,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export function AppHeader({
|
||||
title = "Musik Delfin",
|
||||
mascot = "/dolphin-mascot.png",
|
||||
title = SKIN.appName,
|
||||
mascot = SKIN.mascot,
|
||||
status,
|
||||
compact = false,
|
||||
}: Props) {
|
||||
@@ -58,7 +59,7 @@ export function AppHeader({
|
||||
fontSize: compact ? 28 : 34,
|
||||
fontWeight: 900,
|
||||
color: "var(--paper)",
|
||||
textShadow: "0 3px 14px oklch(15% 0.05 210 / .5)",
|
||||
textShadow: "0 3px 14px oklch(15% 0.05 var(--hue) / .5)",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { clock } from "../lib/format";
|
||||
import type { Category, Group, Results, SearchIndex, SongHit } from "../lib/search";
|
||||
import { categoryMatches, effectiveSearch, groupOf } from "../lib/search";
|
||||
import { SKIN } from "../lib/skins";
|
||||
import {
|
||||
ANIMATE_VIEW_TRANSITIONS,
|
||||
glassTint,
|
||||
@@ -260,7 +261,7 @@ function BrowseViewImpl({
|
||||
textAlign: "center",
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
color: "oklch(88% 0.02 210 / .6)",
|
||||
color: "oklch(88% 0.02 var(--hue) / .6)",
|
||||
}}
|
||||
>
|
||||
Noch nichts hier
|
||||
@@ -400,7 +401,7 @@ function BrowseViewImpl({
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: "oklch(88% 0.02 210 / .65)",
|
||||
color: "oklch(88% 0.02 var(--hue) / .65)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
@@ -412,7 +413,7 @@ function BrowseViewImpl({
|
||||
<div
|
||||
style={{
|
||||
font: "700 13px ui-monospace, Menlo, monospace",
|
||||
color: "oklch(88% 0.02 210 / .6)",
|
||||
color: "oklch(88% 0.02 var(--hue) / .6)",
|
||||
}}
|
||||
>
|
||||
{clock(hit.duration)}
|
||||
@@ -540,7 +541,7 @@ function BrowseViewImpl({
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 800,
|
||||
color: "oklch(22% 0.03 210)",
|
||||
color: "oklch(22% 0.03 var(--hue))",
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
@@ -551,7 +552,7 @@ function BrowseViewImpl({
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: "oklch(40% 0.03 210)",
|
||||
color: "oklch(40% 0.03 var(--hue))",
|
||||
marginTop: 4,
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
@@ -566,7 +567,7 @@ function BrowseViewImpl({
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 800,
|
||||
color: "oklch(22% 0.03 210)",
|
||||
color: "oklch(22% 0.03 var(--hue))",
|
||||
lineHeight: 1.2,
|
||||
height: `${titleLines * 1.2}em`,
|
||||
display: "-webkit-box",
|
||||
@@ -582,7 +583,7 @@ function BrowseViewImpl({
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "oklch(30% 0.03 210)",
|
||||
color: "oklch(30% 0.03 var(--hue))",
|
||||
marginTop: 2,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
@@ -596,7 +597,7 @@ function BrowseViewImpl({
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 800,
|
||||
color: "oklch(40% 0.17 340)",
|
||||
color: "oklch(40% 0.17 var(--accent-hue))",
|
||||
marginTop: 6,
|
||||
}}
|
||||
>
|
||||
@@ -623,11 +624,11 @@ function BrowseViewImpl({
|
||||
style={{
|
||||
textAlign: "center",
|
||||
marginTop: 60,
|
||||
color: "oklch(92% 0.02 210 / .8)",
|
||||
color: "oklch(92% 0.02 var(--hue) / .8)",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/dolphin-mascot.png"
|
||||
src={SKIN.mascot}
|
||||
alt=""
|
||||
style={{
|
||||
width: 120,
|
||||
@@ -813,7 +814,7 @@ export function CategoryTile({
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 800,
|
||||
color: "oklch(22% 0.03 210)",
|
||||
color: "oklch(22% 0.03 var(--hue))",
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
@@ -823,7 +824,7 @@ export function CategoryTile({
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 800,
|
||||
color: "oklch(40% 0.17 340)",
|
||||
color: "oklch(40% 0.17 var(--accent-hue))",
|
||||
marginTop: 5,
|
||||
}}
|
||||
>
|
||||
@@ -871,7 +872,7 @@ function Muted({ children }: { children: ReactNode }) {
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "oklch(88% 0.02 210 / .7)",
|
||||
color: "oklch(88% 0.02 var(--hue) / .7)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
/** The ambient bubble field. Positions are fixed so they do not reshuffle on render. */
|
||||
|
||||
const BUBBLES: Array<[left: number, size: number, alpha: number, seconds: number, delay: number]> =
|
||||
[
|
||||
[5, 14, 0.5, 9, 0], [15, 22, 0.4, 12, 2], [28, 10, 0.5, 7, 1], [42, 18, 0.45, 10, 4],
|
||||
[58, 12, 0.5, 8, 3], [70, 26, 0.35, 13, 5], [82, 16, 0.5, 9.5, 1.5], [92, 10, 0.5, 6.5, 2.5],
|
||||
[2, 8, 0.45, 7.5, 3.5], [9, 18, 0.3, 14, 6], [21, 12, 0.5, 8.5, 5.5], [34, 24, 0.32, 12.5, 1.2],
|
||||
[38, 9, 0.5, 6.8, 4.6], [48, 14, 0.42, 10.5, 0.6], [53, 20, 0.3, 13.5, 7], [63, 8, 0.5, 7.2, 2.2],
|
||||
[66, 16, 0.38, 11.5, 5.2], [76, 11, 0.48, 9.2, 6.4], [87, 22, 0.28, 15, 3.2],
|
||||
[96, 14, 0.42, 10.8, 8], [45, 7, 0.5, 6.2, 7.6],
|
||||
];
|
||||
|
||||
export function Bubbles() {
|
||||
return (
|
||||
<>
|
||||
{BUBBLES.map(([left, size, alpha, seconds, delay], index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bubble"
|
||||
style={{
|
||||
left: `${left}%`,
|
||||
width: size,
|
||||
height: size,
|
||||
background: `oklch(90% 0.02 210 / ${alpha})`,
|
||||
animationDuration: `${seconds}s`,
|
||||
animationDelay: `${delay}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -85,7 +85,7 @@ export function Cover({ album, size, fit = "width", radius, className, style, la
|
||||
fontWeight: 900,
|
||||
lineHeight: 1.15,
|
||||
color: "oklch(99% 0 0 / .92)",
|
||||
textShadow: "0 2px 8px oklch(15% 0.05 210 / .6)",
|
||||
textShadow: "0 2px 8px oklch(15% 0.05 var(--hue) / .6)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
|
||||
64
web/src/components/Drift.tsx
Normal file
64
web/src/components/Drift.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
/** A decorative field drifting behind a page: rising bubbles for the dolphin, falling
|
||||
* leaves for the dinosaur (`drift` in the skin). The room page and the typing game each
|
||||
* pass their own fixed table, so positions do not reshuffle on render; the play view's
|
||||
* moving field is a canvas of its own (`Ambience.tsx`). */
|
||||
|
||||
import { SKIN } from "../lib/skins";
|
||||
|
||||
/** [left %, size px, opacity, seconds, delay seconds] */
|
||||
export type DriftField = readonly (readonly [number, number, number, number, number])[];
|
||||
|
||||
interface Props {
|
||||
field: DriftField;
|
||||
/** A bubble's fill at the given opacity - each page tints its own. */
|
||||
bubbleColor: (alpha: number) => string;
|
||||
}
|
||||
|
||||
const LEAVES = ["🍃", "🌿", "🍂"];
|
||||
|
||||
export function Drift({ field, bubbleColor }: Props) {
|
||||
const leaves = SKIN.drift === "leaves";
|
||||
// The wrapper is load-bearing, not tidiness. Each bubble starts at `bottom: -40px`,
|
||||
// and an absolutely positioned child still counts toward its container's scrollHeight
|
||||
// - so without this, twelve decorative bubbles made `.stage` 40px taller than the
|
||||
// viewport. `.stage` hides that overflow, but a hidden overflow is still scrollable:
|
||||
// focusing the result sheet's button scrolled the whole app up and pushed the header
|
||||
// off the top of the screen. Clipping them here keeps the stage exactly one screen.
|
||||
return (
|
||||
<div style={{ position: "absolute", inset: 0, overflow: "hidden", pointerEvents: "none" }}>
|
||||
{field.map(([left, size, alpha, seconds, delay], i) =>
|
||||
leaves ? (
|
||||
// A leaf is an opaque glyph rather than a translucent disc, so it gets a larger
|
||||
// size and a stronger opacity to read as present-but-quiet like a bubble does,
|
||||
// and falls a little slower than a bubble rises.
|
||||
<div
|
||||
key={i}
|
||||
className="leaf"
|
||||
style={{
|
||||
left: `${left}%`,
|
||||
fontSize: 12 + size,
|
||||
opacity: Math.min(0.55, alpha * 3),
|
||||
animationDuration: `${seconds * 1.4}s`,
|
||||
animationDelay: `${delay}s`,
|
||||
}}
|
||||
>
|
||||
{LEAVES[i % LEAVES.length]}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
key={i}
|
||||
className="bubble"
|
||||
style={{
|
||||
left: `${left}%`,
|
||||
width: size,
|
||||
height: size,
|
||||
background: bubbleColor(alpha),
|
||||
animationDuration: `${seconds}s`,
|
||||
animationDelay: `${delay}s`,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -78,7 +78,7 @@ export function HelpOverlay({ onClose }: { onClose: () => void }) {
|
||||
fontWeight: 800,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
color: "oklch(50% 0.03 210 / .8)",
|
||||
color: "oklch(50% 0.03 var(--hue) / .8)",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
@@ -92,7 +92,7 @@ export function HelpOverlay({ onClose }: { onClose: () => void }) {
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: "oklch(35% 0.03 210)" }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: "oklch(35% 0.03 var(--hue))" }}>
|
||||
{label}
|
||||
</div>
|
||||
</Fragment>
|
||||
|
||||
@@ -6,15 +6,10 @@ import { useMemo } from "react";
|
||||
|
||||
import type { HaDevice, HaEntityState } from "../api/types";
|
||||
import { oklchToRgb } from "../lib/oklch";
|
||||
import { SKIN } from "../lib/skins";
|
||||
|
||||
const SWATCHES: { name: string; oklch: string }[] = [
|
||||
{ name: "Warmweiß", oklch: "oklch(90% 0.06 85)" },
|
||||
{ name: "Sonnengelb", oklch: "oklch(85% 0.16 95)" },
|
||||
{ name: "Korallenrot", oklch: "oklch(65% 0.20 25)" },
|
||||
{ name: "Delfinblau", oklch: "oklch(70% 0.15 235)" },
|
||||
{ name: "Riffgrün", oklch: "oklch(75% 0.16 155)" },
|
||||
{ name: "Quallenlila", oklch: "oklch(65% 0.18 310)" },
|
||||
];
|
||||
/** The colour presets - named for the skin's world (`lightSwatches`). */
|
||||
const SWATCHES = SKIN.lightSwatches;
|
||||
|
||||
/** `["brightness"]`/`["onoff"]` lights get the brightness row only (a `brightness_pct`
|
||||
* sent to a plain on/off bulb is harmlessly ignored by Home Assistant). */
|
||||
|
||||
@@ -53,7 +53,7 @@ export function ParentPanel({ onClose }: { onClose: () => void }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overlay" style={{ zIndex: 6, background: "oklch(15% 0.03 210 / .75)" }}>
|
||||
<div className="overlay" style={{ zIndex: 6, background: "oklch(15% 0.03 var(--hue) / .75)" }}>
|
||||
<div className="sheet" style={{ padding: 28, width: 460, maxHeight: "100%", overflow: "auto" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div style={{ fontSize: 20, fontWeight: 900, color: "var(--ink)", flex: 1 }}>
|
||||
@@ -65,7 +65,7 @@ export function ParentPanel({ onClose }: { onClose: () => void }) {
|
||||
style={{
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: "oklch(88% 0.02 210)",
|
||||
background: "oklch(88% 0.02 var(--hue))",
|
||||
color: "var(--ink)",
|
||||
fontSize: 18,
|
||||
fontWeight: 900,
|
||||
@@ -79,13 +79,13 @@ export function ParentPanel({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
|
||||
{settings === null ? (
|
||||
<div style={{ marginTop: 18, color: "oklch(45% 0.03 210)" }}>Wird geladen …</div>
|
||||
<div style={{ marginTop: 18, color: "oklch(45% 0.03 var(--hue))" }}>Wird geladen …</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 14, marginTop: 18 }}>
|
||||
{FIELDS.map(([key, label, hint, min, max, step]) => (
|
||||
<label key={key} style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 800, color: "var(--ink)" }}>{label}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: "oklch(50% 0.03 210)" }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: "oklch(50% 0.03 var(--hue))" }}>
|
||||
{hint}
|
||||
</span>
|
||||
<input
|
||||
@@ -100,7 +100,7 @@ export function ParentPanel({ onClose }: { onClose: () => void }) {
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
borderRadius: 10,
|
||||
border: "2px solid oklch(85% 0.02 210)",
|
||||
border: "2px solid oklch(85% 0.02 var(--hue))",
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
@@ -130,7 +130,7 @@ export function ParentPanel({ onClose }: { onClose: () => void }) {
|
||||
style={{
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: "oklch(88% 0.02 210)",
|
||||
background: "oklch(88% 0.02 var(--hue))",
|
||||
color: "var(--ink)",
|
||||
fontSize: 15,
|
||||
fontWeight: 800,
|
||||
@@ -146,7 +146,7 @@ export function ParentPanel({ onClose }: { onClose: () => void }) {
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "oklch(50% 0.03 210)",
|
||||
color: "oklch(50% 0.03 var(--hue))",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -12,19 +12,25 @@ import {
|
||||
savePerfSettings,
|
||||
type PerfSettings,
|
||||
} from "../lib/perfSettings";
|
||||
import { SKIN } from "../lib/skins";
|
||||
|
||||
type BoolKey = { [K in keyof PerfSettings]: PerfSettings[K] extends boolean ? K : never }[keyof PerfSettings];
|
||||
type NumKey = { [K in keyof PerfSettings]: PerfSettings[K] extends number ? K : never }[keyof PerfSettings];
|
||||
|
||||
/** What drifts and how the mascot moves, in the skin's own words. */
|
||||
const DRIFT = SKIN.drift === "leaves" ? "Blätter" : "Blasen";
|
||||
const DRIFT_MOVING = SKIN.drift === "leaves" ? "Fallende Blätter" : "Aufsteigende Blasen";
|
||||
const MASCOT_MOVING = SKIN.mascotMotion === "walk" ? "wanderndes" : "schwimmendes";
|
||||
|
||||
const TOGGLES: [BoolKey, string, string][] = [
|
||||
["ambience", "Animierter Hintergrund", "Canvas im Abspielbildschirm (größter CPU-Verbraucher)"],
|
||||
["cardBlur", "Weichzeichner auf Karten", "Backdrop-Blur je Album-Karte und Titelzeile"],
|
||||
["viewTransitions", "Übergänge", "Ein-/Ausblenden beim Öffnen von Gruppen und Alben"],
|
||||
["decorativeAnimations", "Deko-Animationen", "Blasen im Zimmer, schwimmender Delfin"],
|
||||
["decorativeAnimations", "Deko-Animationen", `${DRIFT} im Zimmer, ${MASCOT_MOVING} Maskottchen`],
|
||||
["cardShadows", "Schatten auf Karten", "Schlagschatten unter den Album-Karten"],
|
||||
["tileShadows", "Schatten auf Kacheln", "Schlagschatten unter den Kacheln im Raster"],
|
||||
["petAnimations", "Tippen: Tiere bewegen sich", "Aus: Tiere stehen still"],
|
||||
["tippenBubbles", "Tippen: Blasen", "Aufsteigende Blasen im Hintergrund"],
|
||||
["tippenBubbles", `Tippen: ${DRIFT}`, `${DRIFT_MOVING} im Hintergrund`],
|
||||
["tippenViewTransitions", "Tippen: Übergänge", "Ein-/Ausblenden der Bildschirme"],
|
||||
["serviceWorker", "Service Worker", "Aus: vorhandener Worker wird entfernt"],
|
||||
["coverWarmup", "Cover vorladen", "Cover im Hintergrund dekodieren"],
|
||||
@@ -35,7 +41,7 @@ const NUMBERS: [NumKey, string, string, number, number, number][] = [
|
||||
["ambienceScale", "Hintergrund: Auflösung", "Faktor der Canvas-Größe", 0.1, 1, 0.05],
|
||||
["ambienceMaxPx", "Hintergrund: max. Pixel", "Obergrenze je Achse", 256, 4096, 64],
|
||||
["ambienceMaxFps", "Hintergrund: max. FPS", "0 = unbegrenzt", 0, 60, 1],
|
||||
["ambienceMaxBubbles", "Hintergrund: max. Blasen", "0 = unbegrenzt", 0, 500, 5],
|
||||
["ambienceMaxBubbles", `Hintergrund: max. ${DRIFT}`, "0 = unbegrenzt", 0, 500, 5],
|
||||
];
|
||||
|
||||
const pill = (background: string, color: string) =>
|
||||
@@ -63,7 +69,7 @@ export function PerfPanel({ onClose }: { onClose: () => void }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overlay" style={{ zIndex: 7, background: "oklch(15% 0.03 210 / .75)" }}>
|
||||
<div className="overlay" style={{ zIndex: 7, background: "oklch(15% 0.03 var(--hue) / .75)" }}>
|
||||
<div className="sheet" style={{ padding: 28, width: 480, maxHeight: "100%", overflow: "auto" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div style={{ fontSize: 20, fontWeight: 900, color: "var(--ink)", flex: 1 }}>
|
||||
@@ -73,7 +79,7 @@ export function PerfPanel({ onClose }: { onClose: () => void }) {
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
style={{
|
||||
...pill("oklch(88% 0.02 210)", "var(--ink)"),
|
||||
...pill("oklch(88% 0.02 var(--hue))", "var(--ink)"),
|
||||
fontSize: 18,
|
||||
fontWeight: 900,
|
||||
width: 36,
|
||||
@@ -86,10 +92,10 @@ export function PerfPanel({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 10, marginTop: 16 }}>
|
||||
<button onClick={() => setDraft({ ...PI })} style={pill("oklch(88% 0.02 210)", "var(--ink)")}>
|
||||
<button onClick={() => setDraft({ ...PI })} style={pill("oklch(88% 0.02 var(--hue))", "var(--ink)")}>
|
||||
Pi-Vorgabe
|
||||
</button>
|
||||
<button onClick={() => setDraft({ ...FULL })} style={pill("oklch(88% 0.02 210)", "var(--ink)")}>
|
||||
<button onClick={() => setDraft({ ...FULL })} style={pill("oklch(88% 0.02 var(--hue))", "var(--ink)")}>
|
||||
Alles an
|
||||
</button>
|
||||
</div>
|
||||
@@ -105,7 +111,7 @@ export function PerfPanel({ onClose }: { onClose: () => void }) {
|
||||
/>
|
||||
<span style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 800, color: "var(--ink)" }}>{label}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: "oklch(50% 0.03 210)" }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: "oklch(50% 0.03 var(--hue))" }}>
|
||||
{hint}
|
||||
</span>
|
||||
</span>
|
||||
@@ -115,7 +121,7 @@ export function PerfPanel({ onClose }: { onClose: () => void }) {
|
||||
{NUMBERS.map(([key, label, hint, min, max, step]) => (
|
||||
<label key={key} style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 800, color: "var(--ink)" }}>{label}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: "oklch(50% 0.03 210)" }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: "oklch(50% 0.03 var(--hue))" }}>
|
||||
{hint}
|
||||
</span>
|
||||
<input
|
||||
@@ -131,7 +137,7 @@ export function PerfPanel({ onClose }: { onClose: () => void }) {
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
borderRadius: 10,
|
||||
border: "2px solid oklch(85% 0.02 210)",
|
||||
border: "2px solid oklch(85% 0.02 var(--hue))",
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
@@ -143,12 +149,12 @@ export function PerfPanel({ onClose }: { onClose: () => void }) {
|
||||
<button onClick={save} style={pill("var(--accent)", "#fff")}>
|
||||
Speichern & neu laden
|
||||
</button>
|
||||
<button onClick={reset} style={pill("oklch(88% 0.02 210)", "var(--ink)")}>
|
||||
<button onClick={reset} style={pill("oklch(88% 0.02 var(--hue))", "var(--ink)")}>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: "oklch(50% 0.03 210)", lineHeight: 1.5 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: "oklch(50% 0.03 var(--hue))", lineHeight: 1.5 }}>
|
||||
Wird nur in diesem Browser gespeichert und gilt nur mit <code>?pi=1</code>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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)",
|
||||
}}
|
||||
>
|
||||
🎛️
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { Album, PlayerState } from "../api/types";
|
||||
import { usePlaybackClock } from "../hooks/usePlaybackClock";
|
||||
import { nowPlayingText } from "../lib/covers";
|
||||
import { SKIN } from "../lib/skins";
|
||||
import { SHOW_DECORATIVE_ANIMATIONS, SHOW_GLASS_BLUR } from "../lib/theme";
|
||||
import { Cover } from "./Cover";
|
||||
import { ProgressBar } from "./ProgressBar";
|
||||
@@ -42,7 +43,7 @@ export function PlayerBar({
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 2,
|
||||
background: "oklch(18% 0.05 210 / .96)",
|
||||
background: "oklch(18% 0.05 var(--hue) / .96)",
|
||||
backdropFilter: SHOW_GLASS_BLUR ? "blur(6px)" : undefined,
|
||||
padding: "16px clamp(12px, 3vw, 32px)",
|
||||
// Clear of the iPhone home indicator when installed to the home screen.
|
||||
@@ -51,11 +52,11 @@ export function PlayerBar({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "clamp(10px, 1.6vw, 22px)",
|
||||
borderTop: "3px solid oklch(70% 0.16 340 / .5)",
|
||||
borderTop: "3px solid oklch(70% 0.16 var(--accent-hue) / .5)",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/dolphin-mascot.png"
|
||||
src={SKIN.mascot}
|
||||
alt=""
|
||||
style={{
|
||||
width: "clamp(56px, 7vw, 84px)",
|
||||
@@ -65,9 +66,9 @@ export function PlayerBar({
|
||||
minWidth: 0,
|
||||
animation:
|
||||
SHOW_DECORATIVE_ANIMATIONS && state.playing
|
||||
? "dolphinBob 1.6s ease-in-out infinite"
|
||||
? "mascotBob 1.6s ease-in-out infinite"
|
||||
: "none",
|
||||
filter: "drop-shadow(0 4px 12px oklch(10% 0.05 210 / .5))",
|
||||
filter: "drop-shadow(0 4px 12px oklch(10% 0.05 var(--hue) / .5))",
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -84,7 +85,7 @@ export function PlayerBar({
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
cursor: album ? "pointer" : "default",
|
||||
background: album ? "none" : "oklch(35% 0.03 210)",
|
||||
background: album ? "none" : "oklch(35% 0.03 var(--hue))",
|
||||
}}
|
||||
>
|
||||
{album && <Cover album={album} size={56} fit="height" radius="10px" />}
|
||||
@@ -107,7 +108,7 @@ export function PlayerBar({
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "oklch(85% 0.02 210 / .75)",
|
||||
color: "oklch(85% 0.02 var(--hue) / .75)",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
|
||||
@@ -68,7 +68,7 @@ export function ProgressBar({
|
||||
style={{
|
||||
height,
|
||||
borderRadius: 999,
|
||||
background: "oklch(30% 0.03 210 / .6)",
|
||||
background: "oklch(30% 0.03 var(--hue) / .6)",
|
||||
overflow: "hidden",
|
||||
cursor: interactive && duration ? "pointer" : "default",
|
||||
touchAction: "none",
|
||||
|
||||
@@ -35,7 +35,7 @@ export function RemoteAssignPopup({ album, mapping, albums, onAssign, onClear, o
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "oklch(45% 0.03 210)",
|
||||
color: "oklch(45% 0.03 var(--hue))",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
@@ -78,7 +78,7 @@ export function RemoteAssignPopup({ album, mapping, albums, onAssign, onClear, o
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 4,
|
||||
background: view.state === "ok" ? "oklch(90% 0.04 210)" : "oklch(94% 0.01 210)",
|
||||
background: view.state === "ok" ? "oklch(90% 0.04 var(--hue))" : "oklch(94% 0.01 var(--hue))",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
@@ -90,7 +90,7 @@ export function RemoteAssignPopup({ album, mapping, albums, onAssign, onClear, o
|
||||
fontSize: 20,
|
||||
fontWeight: 900,
|
||||
color:
|
||||
view.state === "missing" ? "oklch(55% 0.15 30)" : "oklch(60% 0.02 210)",
|
||||
view.state === "missing" ? "oklch(55% 0.15 30)" : "oklch(60% 0.02 var(--hue))",
|
||||
}}
|
||||
>
|
||||
{view.state === "missing" ? "⚠️" : "+"}
|
||||
@@ -136,7 +136,7 @@ export function RemoteAssignPopup({ album, mapping, albums, onAssign, onClear, o
|
||||
marginTop: 16,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "oklch(50% 0.03 210 / .8)",
|
||||
color: "oklch(50% 0.03 var(--hue) / .8)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -7,15 +7,27 @@ import { useState } from "react";
|
||||
|
||||
import type { HaConfig } from "../api/types";
|
||||
import { useHomeAssistant } from "../hooks/useHomeAssistant";
|
||||
import { SKIN } from "../lib/skins";
|
||||
import { SHOW_DECORATIVE_ANIMATIONS, TAB_RAIL_CLEARANCE } from "../lib/theme";
|
||||
import { AppHeader } from "./AppHeader";
|
||||
import { Bubbles } from "./Bubbles";
|
||||
import { Drift } from "./Drift";
|
||||
import type { DriftField } from "./Drift";
|
||||
import { LightCard } from "./LightCard";
|
||||
import { SceneRow } from "./SceneRow";
|
||||
import { ShutterCard } from "./ShutterCard";
|
||||
|
||||
/** `phone`: no tab rail on the right to keep clear of, but the floating dock along the
|
||||
* bottom (see `MobileDock`) instead. */
|
||||
/** The room page's ambient field - see Drift. */
|
||||
const FIELD: DriftField = [
|
||||
[5, 14, 0.5, 9, 0], [15, 22, 0.4, 12, 2], [28, 10, 0.5, 7, 1], [42, 18, 0.45, 10, 4],
|
||||
[58, 12, 0.5, 8, 3], [70, 26, 0.35, 13, 5], [82, 16, 0.5, 9.5, 1.5], [92, 10, 0.5, 6.5, 2.5],
|
||||
[2, 8, 0.45, 7.5, 3.5], [9, 18, 0.3, 14, 6], [21, 12, 0.5, 8.5, 5.5], [34, 24, 0.32, 12.5, 1.2],
|
||||
[38, 9, 0.5, 6.8, 4.6], [48, 14, 0.42, 10.5, 0.6], [53, 20, 0.3, 13.5, 7], [63, 8, 0.5, 7.2, 2.2],
|
||||
[66, 16, 0.38, 11.5, 5.2], [76, 11, 0.48, 9.2, 6.4], [87, 22, 0.28, 15, 3.2],
|
||||
[96, 14, 0.42, 10.8, 8], [45, 7, 0.5, 6.2, 7.6],
|
||||
];
|
||||
|
||||
export function RoomView({ config, phone = false }: { config: HaConfig; phone?: boolean }) {
|
||||
const ha = useHomeAssistant(config);
|
||||
// Purely local: Home Assistant scenes have no "currently active" state of their own.
|
||||
@@ -31,7 +43,7 @@ export function RoomView({ config, phone = false }: { config: HaConfig; phone?:
|
||||
|
||||
return (
|
||||
<div className="stage room-stage" style={{ display: "flex", flexDirection: "column" }}>
|
||||
{SHOW_DECORATIVE_ANIMATIONS && <Bubbles />}
|
||||
{SHOW_DECORATIVE_ANIMATIONS && <Drift field={FIELD} bubbleColor={(alpha) => `oklch(90% 0.02 var(--hue) / ${alpha})`} />}
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
@@ -41,7 +53,7 @@ export function RoomView({ config, phone = false }: { config: HaConfig; phone?:
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<AppHeader title="Mein Zimmer" mascot="/dolphin-remote.png" compact={phone} />
|
||||
<AppHeader title="Mein Zimmer" mascot={SKIN.mascotRemote} compact={phone} />
|
||||
<div
|
||||
// Right padding reserves room for the tab rail's own glass bar (see
|
||||
// TAB_RAIL_CLEARANCE) so the device grid never renders under it.
|
||||
|
||||
@@ -35,11 +35,11 @@ export function TabRail({ page, onSelect, showRoom }: Props) {
|
||||
gap: 8,
|
||||
padding: 8,
|
||||
borderRadius: 999,
|
||||
background: "oklch(97% 0.01 210 / .22)",
|
||||
background: "oklch(97% 0.01 var(--hue) / .22)",
|
||||
backdropFilter: SHOW_GLASS_BLUR ? "blur(14px)" : undefined,
|
||||
WebkitBackdropFilter: SHOW_GLASS_BLUR ? "blur(14px)" : undefined,
|
||||
border: "1px solid oklch(97% 0.01 210 / .3)",
|
||||
boxShadow: "0 8px 24px oklch(15% 0.05 210 / .35)",
|
||||
border: "1px solid oklch(97% 0.01 var(--hue) / .3)",
|
||||
boxShadow: "0 8px 24px oklch(15% 0.05 var(--hue) / .35)",
|
||||
}}
|
||||
>
|
||||
{pages.map((candidate) => {
|
||||
|
||||
@@ -23,14 +23,16 @@ import { AppHeader } from "./tippen/AppHeader";
|
||||
import { HelpOverlay } from "./tippen/HelpOverlay";
|
||||
import { LessonMap } from "./tippen/LessonMap";
|
||||
import { ResultSheet } from "./tippen/ResultSheet";
|
||||
import { PetPicture } from "./tippen/PetPicture";
|
||||
import { RewardUnlockOverlay } from "./tippen/RewardUnlockOverlay";
|
||||
import { Stage } from "./tippen/Stage";
|
||||
import { BubblesRun } from "./tippen/modes/BubblesRun";
|
||||
import { DiveRun } from "./tippen/modes/DiveRun";
|
||||
import { FeedRun } from "./tippen/modes/FeedRun";
|
||||
import { RaceRun } from "./tippen/modes/RaceRun";
|
||||
import type { CreatureId } from "../lib/tippen/aquarium";
|
||||
import { creatureById } from "../lib/tippen/aquarium";
|
||||
import { SKIN } from "../lib/skins";
|
||||
import type { CreatureId } from "../lib/tippen/pets";
|
||||
import { creatureById } from "../lib/tippen/pets";
|
||||
import type { Lesson, ModeId } from "../lib/tippen/curriculum";
|
||||
import { lessonById, nextLesson } from "../lib/tippen/curriculum";
|
||||
import { letterStream, lineFor, lineText, mulberry32 } from "../lib/tippen/generator";
|
||||
@@ -308,7 +310,7 @@ export function TippenApp({ onExit }: Props) {
|
||||
return (
|
||||
<Stage creatures={progress.aquarium} dimmed={screen === "run"}>
|
||||
<AppHeader
|
||||
title={screen === "run" && lesson ? lesson.title : "Delfin Tippen"}
|
||||
title={screen === "run" && lesson ? lesson.title : SKIN.tippenName}
|
||||
compact={screen === "run"}
|
||||
status={
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "center", color: "var(--paper)", fontWeight: 800 }}>
|
||||
@@ -319,15 +321,12 @@ export function TippenApp({ onExit }: Props) {
|
||||
const creature = creatureById(world.reward);
|
||||
const owned = progress.aquarium.includes(creature.id);
|
||||
return (
|
||||
<img
|
||||
<PetPicture
|
||||
key={creature.id}
|
||||
src={creature.image}
|
||||
alt=""
|
||||
creature={creature}
|
||||
size={22}
|
||||
title={owned ? creature.name : `Welt ${world.number}`}
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
objectFit: "contain",
|
||||
filter: owned ? "none" : "brightness(0) invert(1)",
|
||||
opacity: owned ? 1 : 0.3,
|
||||
}}
|
||||
|
||||
@@ -24,7 +24,7 @@ export function Transport({ playing, onToggle, onNext, onPrevious, size, gap }:
|
||||
className="round"
|
||||
onClick={onPrevious}
|
||||
aria-label="Vorheriger Titel"
|
||||
style={{ width: size, height: size, background: "oklch(30% 0.04 210)" }}
|
||||
style={{ width: size, height: size, background: "oklch(30% 0.04 var(--hue))" }}
|
||||
>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
<span style={{ width: bar, height: glyph, borderRadius: 1, background: "#fff" }} />
|
||||
@@ -47,7 +47,7 @@ export function Transport({ playing, onToggle, onNext, onPrevious, size, gap }:
|
||||
width: playSize,
|
||||
height: playSize,
|
||||
background: "var(--accent)",
|
||||
boxShadow: "0 6px 20px oklch(70% 0.16 340 / .5)",
|
||||
boxShadow: "0 6px 20px oklch(70% 0.16 var(--accent-hue) / .5)",
|
||||
}}
|
||||
>
|
||||
{playing ? (
|
||||
@@ -75,7 +75,7 @@ export function Transport({ playing, onToggle, onNext, onPrevious, size, gap }:
|
||||
className="round"
|
||||
onClick={onNext}
|
||||
aria-label="Nächster Titel"
|
||||
style={{ width: size, height: size, background: "oklch(30% 0.04 210)" }}
|
||||
style={{ width: size, height: size, background: "oklch(30% 0.04 var(--hue))" }}
|
||||
>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
<span
|
||||
|
||||
@@ -52,7 +52,7 @@ export function VolumeBars({
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
background:
|
||||
volume >= level ? "var(--accent)" : "oklch(88% 0.02 210 / .4)",
|
||||
volume >= level ? "var(--accent)" : "oklch(88% 0.02 var(--hue) / .4)",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { albumLine, isBook, unitLabel } from "../../lib/covers";
|
||||
import { clock } from "../../lib/format";
|
||||
import type { Group, Mode, SearchIndex, SongHit } from "../../lib/search";
|
||||
import { effectiveSearch, groupOf, results as computeResults } from "../../lib/search";
|
||||
import { SKIN } from "../../lib/skins";
|
||||
import { GROUP_ICON, GROUP_LABEL } from "../../lib/theme";
|
||||
import { CategoryTile } from "../BrowseView";
|
||||
import { Cover } from "../Cover";
|
||||
@@ -135,8 +136,8 @@ function MobileBrowseImpl({
|
||||
return (
|
||||
<div className="m-scroll" key={group}>
|
||||
<div className="m-title-row">
|
||||
<img src="/dolphin-mascot.png" alt="" className="m-title-mascot" />
|
||||
<div className="m-title">Musik Delfin</div>
|
||||
<img src={SKIN.mascot} alt="" className="m-title-mascot" />
|
||||
<div className="m-title">{SKIN.appName}</div>
|
||||
{statusLabel && (
|
||||
<div className="m-status" data-offline={offline}>
|
||||
{statusLabel}
|
||||
@@ -253,7 +254,7 @@ function Segment({
|
||||
function Hint({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="m-hint">
|
||||
<img src="/dolphin-mascot.png" alt="" />
|
||||
<img src={SKIN.mascot} alt="" />
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/** The header, matching ../../web/src/components/AppHeader.tsx so the two apps open the
|
||||
* same way. The mascot bobs; `SHOW_BUBBLES` doubles as the decorative-motion switch. */
|
||||
* same way. The mascot bobs; `SHOW_DRIFT` doubles as the decorative-motion switch. */
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { SKIN } from "../../lib/skins";
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
@@ -12,7 +13,7 @@ interface Props {
|
||||
onMascotClick?: () => void;
|
||||
}
|
||||
|
||||
export function AppHeader({ title = "Delfin Tippen", status, compact = false, onMascotClick }: Props) {
|
||||
export function AppHeader({ title = SKIN.tippenName, status, compact = false, onMascotClick }: Props) {
|
||||
const size = compact ? 44 : 64;
|
||||
return (
|
||||
<div
|
||||
@@ -27,14 +28,14 @@ export function AppHeader({ title = "Delfin Tippen", status, compact = false, on
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/dolphin-mascot.png"
|
||||
src={SKIN.mascot}
|
||||
alt=""
|
||||
onClick={onMascotClick}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
objectFit: "contain",
|
||||
animation: "dolphinBob 4s ease-in-out infinite",
|
||||
animation: "mascotBob 4s ease-in-out infinite",
|
||||
cursor: onMascotClick ? "pointer" : "default",
|
||||
}}
|
||||
/>
|
||||
@@ -43,7 +44,7 @@ export function AppHeader({ title = "Delfin Tippen", status, compact = false, on
|
||||
fontSize: compact ? 24 : 34,
|
||||
fontWeight: 900,
|
||||
color: "var(--paper)",
|
||||
textShadow: "0 3px 14px oklch(15% 0.05 175 / .5)",
|
||||
textShadow: "0 3px 14px oklch(15% 0.05 var(--tp-hue) / .5)",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
/** The decorative bubble field, copied from ../../web/src/components/Bubbles.tsx.
|
||||
* Positions are a fixed table rather than random so they do not reshuffle on render. */
|
||||
|
||||
interface Props {
|
||||
count?: number;
|
||||
}
|
||||
|
||||
/** [left %, size px, opacity, seconds, delay seconds] */
|
||||
const BUBBLES: readonly [number, number, number, number, number][] = [
|
||||
[6, 14, 0.18, 13, 0], [17, 9, 0.14, 16, 2.5], [28, 20, 0.12, 11, 5],
|
||||
[39, 7, 0.2, 18, 1.2], [51, 16, 0.13, 14, 7], [62, 11, 0.17, 12, 3.4],
|
||||
[73, 22, 0.1, 17, 6.1], [84, 8, 0.19, 15, 0.8], [93, 13, 0.14, 13, 4.2],
|
||||
[11, 18, 0.11, 19, 8.5], [45, 10, 0.16, 15, 9.3], [68, 6, 0.21, 20, 2],
|
||||
];
|
||||
|
||||
export function Bubbles({ count = BUBBLES.length }: Props) {
|
||||
// The wrapper is load-bearing, not tidiness. Each bubble starts at `bottom: -40px`,
|
||||
// and an absolutely positioned child still counts toward its container's scrollHeight
|
||||
// - so without this, twelve decorative bubbles made `.stage` 40px taller than the
|
||||
// viewport. `.stage` hides that overflow, but a hidden overflow is still scrollable:
|
||||
// focusing the result sheet's button scrolled the whole app up and pushed the header
|
||||
// off the top of the screen. Clipping them here keeps the stage exactly one screen.
|
||||
return (
|
||||
<div style={{ position: "absolute", inset: 0, overflow: "hidden", pointerEvents: "none" }}>
|
||||
{BUBBLES.slice(0, count).map(([left, size, alpha, seconds, delay], i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bubble"
|
||||
style={{
|
||||
left: `${left}%`,
|
||||
width: size,
|
||||
height: size,
|
||||
background: `oklch(97% 0.01 175 / ${alpha})`,
|
||||
animationDuration: `${seconds}s`,
|
||||
animationDelay: `${delay}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
web/src/components/tippen/DinoEgg.tsx
Normal file
117
web/src/components/tippen/DinoEgg.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
/** A speckled dinosaur egg that hatches - the dinosaur skin's treasure chest.
|
||||
*
|
||||
* Built to be a drop-in for TreasureChest (same props, same 64x62 view box, same
|
||||
* `.tp-reward-lid` hinge), so the lesson map and the unlock overlay need not know which
|
||||
* one they are drawing. The top of the shell, above a zigzag crack, is the lid: it hinges
|
||||
* at the crack's left end and tips back the way the chest's lid swings. Inside is the
|
||||
* same dark opening with light coming out of it.
|
||||
*
|
||||
* The crack sits a little above the egg's middle on purpose. The overlay and the map
|
||||
* stand a cover up inside the box with its foot at about half height, and that foot has
|
||||
* to be hidden behind the bottom shell, just as it is behind the chest's front wall.
|
||||
*
|
||||
* The two halves are one egg outline clipped twice, not two hand-drawn shapes: the
|
||||
* pieces then meet along the crack exactly, at every size. */
|
||||
|
||||
import type { RewardBoxProps } from "./RewardBox";
|
||||
|
||||
/** Instance-unique ids for the gradient and the two clip paths - see TreasureChest. */
|
||||
let nextId = 0;
|
||||
|
||||
const EGG = "M32 3 C45 3 54 24 54 37 C54 50 44 59 32 59 C20 59 10 50 10 37 C10 24 19 3 32 3 Z";
|
||||
|
||||
/** The crack, left to right. Its ends sit on the egg's outline; the lid hinges at the
|
||||
* left one. */
|
||||
const CRACK: readonly (readonly [number, number])[] = [
|
||||
[11, 28.5], [16, 24.5], [21, 30], [26, 24], [32, 30], [38, 24], [43, 30], [48, 24.5], [53, 28.5],
|
||||
];
|
||||
const CRACK_POINTS = CRACK.map(([x, y]) => `${x},${y}`).join(" ");
|
||||
const ABOVE_CRACK = `0,0 64,0 64,28.5 ${[...CRACK].reverse().map(([x, y]) => `${x},${y}`).join(" ")} 0,28.5`;
|
||||
const BELOW_CRACK = `0,28.5 ${CRACK_POINTS} 64,28.5 64,62 0,62`;
|
||||
|
||||
const OUTLINE = "oklch(42% 0.05 70)";
|
||||
const SPOT = "oklch(64% 0.12 145)";
|
||||
|
||||
export function DinoEgg({ size, open, layer = "all", muted = false, className, style }: RewardBoxProps) {
|
||||
const id = `tp-egg-${(nextId += 1)}`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 64 62"
|
||||
className={className}
|
||||
aria-hidden
|
||||
style={{
|
||||
overflow: "visible",
|
||||
filter: muted ? "grayscale(0.35) brightness(0.9)" : undefined,
|
||||
opacity: muted ? 0.9 : 1,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={`${id}-shell`} x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stopColor="oklch(97% 0.03 95)" />
|
||||
<stop offset="100%" stopColor="oklch(83% 0.06 80)" />
|
||||
</linearGradient>
|
||||
<radialGradient id={`${id}-glow`}>
|
||||
<stop offset="0%" stopColor="oklch(97% 0.14 95 / 0.95)" />
|
||||
<stop offset="55%" stopColor="oklch(90% 0.16 92 / 0.35)" />
|
||||
<stop offset="100%" stopColor="oklch(90% 0.16 92 / 0)" />
|
||||
</radialGradient>
|
||||
<clipPath id={`${id}-top`}>
|
||||
<polygon points={ABOVE_CRACK} />
|
||||
</clipPath>
|
||||
<clipPath id={`${id}-bottom`}>
|
||||
<polygon points={BELOW_CRACK} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
{/* The opening and its light, before the bottom shell - as in the chest, so the
|
||||
shell's rim covers their lower half and it reads as a hole in the egg. */}
|
||||
{layer !== "front" && open && (
|
||||
<>
|
||||
<ellipse cx="32" cy="28" rx="20" ry="5" fill="oklch(24% 0.04 60)" />
|
||||
<circle cx="32" cy="25" r="24" fill={`url(#${id}-glow)`} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{layer === "back" && <TopShell id={id} open={open} />}
|
||||
|
||||
{layer !== "back" && (
|
||||
<>
|
||||
<g clipPath={`url(#${id}-bottom)`}>
|
||||
<path d={EGG} fill={`url(#${id}-shell)`} stroke={OUTLINE} strokeWidth="2" />
|
||||
<circle cx="18" cy="41" r="3" fill={SPOT} />
|
||||
<circle cx="30" cy="47" r="2.4" fill={SPOT} />
|
||||
<circle cx="44" cy="39" r="3.4" fill={SPOT} />
|
||||
<circle cx="39" cy="53" r="2" fill={SPOT} />
|
||||
<circle cx="24" cy="53" r="1.8" fill={SPOT} />
|
||||
</g>
|
||||
<polyline points={CRACK_POINTS} fill="none" stroke={OUTLINE} strokeWidth="1.6" strokeLinejoin="round" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* In one piece, the top goes last, over the crack's edge. */}
|
||||
{layer === "all" && <TopShell id={id} open={open} />}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** The cap of the shell, hinged at the crack's left end. */
|
||||
function TopShell({ id, open }: { id: string; open: boolean }) {
|
||||
const [hingeX, hingeY] = CRACK[0]!;
|
||||
return (
|
||||
<g className="tp-reward-lid" data-open={open || undefined} style={{ transformOrigin: `${hingeX}px ${hingeY}px` }}>
|
||||
<g clipPath={`url(#${id}-top)`}>
|
||||
<path d={EGG} fill={`url(#${id}-shell)`} stroke={OUTLINE} strokeWidth="2" />
|
||||
<circle cx="24" cy="15" r="3.2" fill={SPOT} />
|
||||
<circle cx="38" cy="10" r="2.2" fill={SPOT} />
|
||||
<circle cx="43" cy="20" r="2.6" fill={SPOT} />
|
||||
<circle cx="19" cy="24" r="1.6" fill={SPOT} />
|
||||
<ellipse cx="23" cy="11" rx="2.5" ry="5" transform="rotate(25 23 11)" fill="oklch(100% 0 0 / 0.5)" />
|
||||
</g>
|
||||
<polyline points={CRACK_POINTS} fill="none" stroke={OUTLINE} strokeWidth="1.6" strokeLinejoin="round" />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -18,12 +18,12 @@
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { coverUrl } from "../../api/client";
|
||||
import { SKIN } from "../../lib/skins";
|
||||
import type { Lesson, World } from "../../lib/tippen/curriculum";
|
||||
import { animalById } from "../../lib/tippen/grading";
|
||||
import { NODE_SPACING, pathD, pointFor } from "../../lib/tippen/lessonPath";
|
||||
import { MODE_INFO } from "../../lib/tippen/modeInfo";
|
||||
import type { Progress } from "../../lib/tippen/progress";
|
||||
import { TreasureChest } from "./TreasureChest";
|
||||
import { RewardBox } from "./RewardBox";
|
||||
|
||||
interface Props {
|
||||
worlds: readonly World[];
|
||||
@@ -96,7 +96,7 @@ export function LessonMap({ worlds, lessons, progress, selected, onPick, nextLes
|
||||
height={height}
|
||||
viewBox={`${-PATH_HALF_WIDTH} 0 ${PATH_HALF_WIDTH * 2} ${height}`}
|
||||
>
|
||||
<path d={pathD(points)} stroke="oklch(97% 0.01 175 / 0.35)" strokeWidth={8} strokeLinecap="round" fill="none" />
|
||||
<path d={pathD(points)} style={{ stroke: "oklch(97% 0.01 var(--tp-hue) / 0.35)" }} strokeWidth={8} strokeLinecap="round" fill="none" />
|
||||
</svg>
|
||||
|
||||
{worldLessons.map((lesson, i) => {
|
||||
@@ -105,7 +105,7 @@ export function LessonMap({ worlds, lessons, progress, selected, onPick, nextLes
|
||||
const animal = entry?.bestAnimal ? animalById(entry.bestAnimal) : null;
|
||||
const index = lessons.indexOf(lesson);
|
||||
const point = points[i]!;
|
||||
const modeInfo = MODE_INFO[lesson.primaryMode];
|
||||
const modeInfo = SKIN.game.modes[lesson.primaryMode];
|
||||
|
||||
return (
|
||||
// The node and its chest are two siblings, both positioned against
|
||||
@@ -257,7 +257,7 @@ function RewardChest({
|
||||
transform: "translate(-50%, -50%)",
|
||||
pointerEvents: "none",
|
||||
zIndex: 1,
|
||||
filter: "drop-shadow(0 3px 6px oklch(15% 0.05 175 / 0.55))",
|
||||
filter: "drop-shadow(0 3px 6px oklch(15% 0.05 var(--tp-hue) / 0.55))",
|
||||
}}
|
||||
title={earned ? "Freigespielt! Im Musik-Player zu hören" : "Hier gibt es neue Musik zu gewinnen!"}
|
||||
>
|
||||
@@ -267,7 +267,7 @@ function RewardChest({
|
||||
TreasureChest's `layer`. */}
|
||||
{earned && albumId ? (
|
||||
<div style={{ position: "relative", width: CHEST_SIZE, height: CHEST_SIZE }}>
|
||||
<TreasureChest size={CHEST_SIZE} open layer="back" style={{ position: "absolute", inset: 0 }} />
|
||||
<RewardBox size={CHEST_SIZE} open layer="back" style={{ position: "absolute", inset: 0 }} />
|
||||
<img
|
||||
src={coverUrl(albumId)}
|
||||
alt=""
|
||||
@@ -288,10 +288,10 @@ function RewardChest({
|
||||
border: "1.5px solid oklch(92% 0.13 92 / 0.9)",
|
||||
}}
|
||||
/>
|
||||
<TreasureChest size={CHEST_SIZE} open layer="front" style={{ position: "absolute", inset: 0 }} />
|
||||
<RewardBox size={CHEST_SIZE} open layer="front" style={{ position: "absolute", inset: 0 }} />
|
||||
</div>
|
||||
) : (
|
||||
<TreasureChest size={CHEST_SIZE} open={earned} muted={!earned} />
|
||||
<RewardBox size={CHEST_SIZE} open={earned} muted={!earned} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
37
web/src/components/tippen/PetPicture.tsx
Normal file
37
web/src/components/tippen/PetPicture.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
/** A pet as drawn - or, for a skin whose drawings do not exist yet, its stand-in emoji
|
||||
* in the same square, so every place that shows a pet works with either. */
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import type { Creature } from "../../lib/tippen/pets";
|
||||
|
||||
interface Props {
|
||||
creature: Creature;
|
||||
/** Edge of the square, px. */
|
||||
size: number;
|
||||
title?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export function PetPicture({ creature, size, title, style }: Props) {
|
||||
const box: CSSProperties = { width: size, height: size, flex: "none", ...style };
|
||||
if ("image" in creature.art) {
|
||||
return <img src={creature.art.image} alt="" title={title} draggable={false} style={{ objectFit: "contain", ...box }} />;
|
||||
}
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
title={title}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: size * 0.85,
|
||||
lineHeight: 1,
|
||||
...box,
|
||||
}}
|
||||
>
|
||||
{creature.art.emoji}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
/** The pets, swimming freely behind every screen.
|
||||
/** The pets, moving freely behind every screen.
|
||||
*
|
||||
* One requestAnimationFrame loop, started once and never restarted - the same approach as
|
||||
* ../../web/src/components/Ambience.tsx. Swimmers live in a ref and each frame is written
|
||||
* straight to its image's `transform`, so sixty frames a second never touch React. React
|
||||
* renders this component only when a pet moves in or the layer is dimmed.
|
||||
*
|
||||
* Where a pet goes is its skin's `motion`: swimmers use the whole tank, walkers the
|
||||
* ground and fliers the sky (`BANDS` in lib/tippen/pets.ts).
|
||||
*
|
||||
* The layer sits at z-index -1 inside the stage's own stacking context (`isolation` on
|
||||
* .stage): above the gradient, below every screen. That is also what makes the glass
|
||||
* panels frost a pet that drifts behind one, instead of it swimming over the buttons.
|
||||
@@ -15,8 +18,8 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { PERF } from "../../lib/perfSettings";
|
||||
import { creatureById, createSwimmer, pose, stepSwimmer } from "../../lib/tippen/aquarium";
|
||||
import type { CreatureId, Swimmer } from "../../lib/tippen/aquarium";
|
||||
import { BANDS, creatureById, createSwimmer, pose, stepSwimmer } from "../../lib/tippen/pets";
|
||||
import type { CreatureId, Swimmer } from "../../lib/tippen/pets";
|
||||
|
||||
interface Props {
|
||||
creatures: readonly CreatureId[];
|
||||
@@ -28,9 +31,9 @@ interface Props {
|
||||
* teleport every pet, so a frame never counts for more than this. */
|
||||
const MAX_STEP_S = 0.1;
|
||||
|
||||
export function AquariumCreatures({ creatures, opacity }: Props) {
|
||||
export function Pets({ creatures, opacity }: Props) {
|
||||
const tank = useRef<HTMLDivElement>(null);
|
||||
const images = useRef(new Map<CreatureId, HTMLImageElement>());
|
||||
const images = useRef(new Map<CreatureId, HTMLElement>());
|
||||
const swimmers = useRef(new Map<CreatureId, Swimmer>());
|
||||
const current = useRef(creatures);
|
||||
current.current = creatures;
|
||||
@@ -59,17 +62,20 @@ export function AquariumCreatures({ creatures, opacity }: Props) {
|
||||
const height = creature.size * size.height;
|
||||
const margin = height / 2;
|
||||
const speed = creature.speed * size.width;
|
||||
const band = BANDS[creature.motion];
|
||||
|
||||
const previous = swimmers.current.get(id);
|
||||
const s = previous
|
||||
? reducedMotion
|
||||
? previous
|
||||
: stepSwimmer(previous, dt, size, margin, speed, Math.random)
|
||||
: createSwimmer(size, margin, Math.random, !reducedMotion && !present.current.has(id));
|
||||
: stepSwimmer(previous, dt, size, margin, speed, Math.random, band)
|
||||
: createSwimmer(size, margin, Math.random, !reducedMotion && !present.current.has(id), band);
|
||||
swimmers.current.set(id, s);
|
||||
|
||||
const p = pose(s, creature, speed);
|
||||
image.style.height = `${height}px`;
|
||||
// Only an emoji stand-in reads this; a drawing is sized by its height alone.
|
||||
image.style.fontSize = `${height * 0.85}px`;
|
||||
image.style.transform =
|
||||
`translate(${p.x}px, ${p.y}px) translate(-50%, -50%) ` +
|
||||
`rotate(${p.rotation}deg) scaleX(${p.mirror})`;
|
||||
@@ -96,28 +102,30 @@ export function AquariumCreatures({ creatures, opacity }: Props) {
|
||||
transition: "opacity 700ms ease",
|
||||
}}
|
||||
>
|
||||
{creatures.map((id) => (
|
||||
<img
|
||||
key={id}
|
||||
ref={(image) => {
|
||||
if (image) images.current.set(id, image);
|
||||
else images.current.delete(id);
|
||||
}}
|
||||
src={creatureById(id).image}
|
||||
alt=""
|
||||
draggable={false}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
height: 0,
|
||||
// Off-stage until the loop has placed it, so nothing flashes in the corner.
|
||||
transform: "translate(-200vw, 0)",
|
||||
willChange: "transform",
|
||||
filter: "drop-shadow(0 10px 16px oklch(15% 0.05 175 / 0.35))",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{creatures.map((id) => {
|
||||
const ref = (element: HTMLElement | null) => {
|
||||
if (element) images.current.set(id, element);
|
||||
else images.current.delete(id);
|
||||
};
|
||||
const style = {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
height: 0,
|
||||
// Off-stage until the loop has placed it, so nothing flashes in the corner.
|
||||
transform: "translate(-200vw, 0)",
|
||||
willChange: "transform",
|
||||
filter: "drop-shadow(0 10px 16px oklch(15% 0.05 var(--tp-hue) / 0.35))",
|
||||
} as const;
|
||||
const { art } = creatureById(id);
|
||||
return "image" in art ? (
|
||||
<img key={id} ref={ref} src={art.image} alt="" draggable={false} style={style} />
|
||||
) : (
|
||||
<span key={id} ref={ref} style={{ ...style, lineHeight: 1 }}>
|
||||
{art.emoji}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,20 +8,21 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { coverUrl } from "../../api/client";
|
||||
import { creatureById } from "../../lib/tippen/aquarium";
|
||||
import type { CreatureId } from "../../lib/tippen/aquarium";
|
||||
import { SKIN } from "../../lib/skins";
|
||||
import { creatureById } from "../../lib/tippen/pets";
|
||||
import type { CreatureId } from "../../lib/tippen/pets";
|
||||
import type { ModeId } from "../../lib/tippen/curriculum";
|
||||
import type { RunResult } from "../../lib/tippen/grading";
|
||||
import { STAR_THRESHOLDS, visibleAnimals, animalById, animalIndex, animalProgress } from "../../lib/tippen/grading";
|
||||
import type { AnimalId } from "../../lib/tippen/grading";
|
||||
import { MODE_INFO } from "../../lib/tippen/modeInfo";
|
||||
import type { UnlockedReward } from "../../lib/tippen/progress";
|
||||
import { PetPicture } from "./PetPicture";
|
||||
|
||||
interface Props {
|
||||
result: RunResult;
|
||||
/** Set when this run opened the next lesson. */
|
||||
unlockedTitle: string | null;
|
||||
/** Set when this run released a creature into the aquarium. */
|
||||
/** Set when this run released a pet - into the aquarium, the dino valley, … */
|
||||
newCreature: CreatureId | null;
|
||||
/** Set when this run's lesson named an `unlocks:` target that just became reachable -
|
||||
* the literal track/episode it names, not "one of several". */
|
||||
@@ -101,7 +102,7 @@ export function ResultSheet({
|
||||
style={{
|
||||
height: 9,
|
||||
borderRadius: 999,
|
||||
background: "oklch(88% 0.02 175)",
|
||||
background: "oklch(88% 0.02 var(--tp-hue))",
|
||||
margin: "18px 0 6px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
@@ -140,12 +141,8 @@ export function ResultSheet({
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={creatureById(newCreature).image}
|
||||
alt=""
|
||||
style={{ width: 52, height: 52, objectFit: "contain", animation: "tierEnter 520ms ease-out" }}
|
||||
/>
|
||||
{creatureById(newCreature).article} {creatureById(newCreature).name} ist ins Aquarium gezogen!
|
||||
<PetPicture creature={creatureById(newCreature)} size={52} style={{ animation: "tierEnter 520ms ease-out" }} />
|
||||
{creatureById(newCreature).article} {creatureById(newCreature).name} ist {SKIN.game.habitat} gezogen!
|
||||
</div>
|
||||
)}
|
||||
{/* The recap, not the reveal. RewardUnlockOverlay has already given this its own
|
||||
@@ -205,7 +202,7 @@ export function ResultSheet({
|
||||
Nochmal ⏎
|
||||
</button>
|
||||
{continueLabel && (
|
||||
<button onClick={onContinue} style={{ ...buttonStyle, background: "oklch(90% 0.02 175)", color: "var(--ink)" }}>
|
||||
<button onClick={onContinue} style={{ ...buttonStyle, background: "oklch(90% 0.02 var(--tp-hue))", color: "var(--ink)" }}>
|
||||
{continueLabel}
|
||||
</button>
|
||||
)}
|
||||
@@ -228,11 +225,11 @@ export function ResultSheet({
|
||||
fontSize: 14,
|
||||
fontWeight: 800,
|
||||
cursor: "pointer",
|
||||
background: "oklch(90% 0.02 175)",
|
||||
background: "oklch(90% 0.02 var(--tp-hue))",
|
||||
color: "var(--ink)",
|
||||
}}
|
||||
>
|
||||
{MODE_INFO[mode].emoji} {MODE_INFO[mode].name}
|
||||
{SKIN.game.modes[mode].emoji} {SKIN.game.modes[mode].name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
27
web/src/components/tippen/RewardBox.tsx
Normal file
27
web/src/components/tippen/RewardBox.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
/** What a music reward comes out of: the dolphin's treasure chest or the dinosaur's egg
|
||||
* (`game.rewardBox` in the skin). Both draw the same way - a lid that hinges open, split
|
||||
* into a back and a front layer so a cover can rise out from between them - so every
|
||||
* caller works with either. */
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import { SKIN } from "../../lib/skins";
|
||||
import { DinoEgg } from "./DinoEgg";
|
||||
import { TreasureChest } from "./TreasureChest";
|
||||
|
||||
export interface RewardBoxProps {
|
||||
size: number;
|
||||
open: boolean;
|
||||
/** Which half to draw - see TreasureChest's header. "all" is the whole box, in one
|
||||
* element. */
|
||||
layer?: "all" | "back" | "front";
|
||||
/** Softened - a reward whose lesson is still ahead of her. Muted rather than greyed
|
||||
* out: a shut chest still has to look like treasure, or the badge promises nothing. */
|
||||
muted?: boolean;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export function RewardBox(props: RewardBoxProps) {
|
||||
return SKIN.game.rewardBox === "egg" ? <DinoEgg {...props} /> : <TreasureChest {...props} />;
|
||||
}
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
playRewardJingle,
|
||||
} from "../../lib/tippen/pop";
|
||||
import type { UnlockedReward } from "../../lib/tippen/progress";
|
||||
import { TreasureChest } from "./TreasureChest";
|
||||
import { RewardBox } from "./RewardBox";
|
||||
|
||||
type Phase = "drop" | "open" | "reveal";
|
||||
|
||||
@@ -69,7 +69,7 @@ const COPY: Record<
|
||||
|
||||
const CONFETTI_COLOURS = [
|
||||
"oklch(85% 0.17 88)",
|
||||
"oklch(78% 0.16 340)",
|
||||
"oklch(78% 0.16 var(--accent-hue))",
|
||||
"oklch(82% 0.15 150)",
|
||||
"oklch(80% 0.14 220)",
|
||||
"oklch(88% 0.13 60)",
|
||||
@@ -178,10 +178,10 @@ export function RewardUnlockOverlay({ reward, sound, onClose }: Props) {
|
||||
marginTop: COVER_SIZE * 0.78,
|
||||
animation: open
|
||||
? undefined
|
||||
: "chestDrop 520ms cubic-bezier(0.34, 1.4, 0.64, 1), chestRattle 420ms ease-in-out 540ms 2",
|
||||
: "rewardDrop 520ms cubic-bezier(0.34, 1.4, 0.64, 1), rewardRattle 420ms ease-in-out 540ms 2",
|
||||
}}
|
||||
>
|
||||
<TreasureChest size={CHEST_SIZE} open={open} layer="back" style={{ position: "absolute", inset: 0 }} />
|
||||
<RewardBox size={CHEST_SIZE} open={open} layer="back" style={{ position: "absolute", inset: 0 }} />
|
||||
|
||||
{open && !reducedMotion && <Rays />}
|
||||
|
||||
@@ -199,7 +199,7 @@ export function RewardUnlockOverlay({ reward, sound, onClose }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TreasureChest size={CHEST_SIZE} open={open} layer="front" style={{ position: "absolute", inset: 0 }} />
|
||||
<RewardBox size={CHEST_SIZE} open={open} layer="front" style={{ position: "absolute", inset: 0 }} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -305,7 +305,7 @@ function RewardArt({
|
||||
justifyContent: "center",
|
||||
fontSize: 92,
|
||||
background:
|
||||
"linear-gradient(160deg, oklch(70% 0.09 175), oklch(45% 0.08 175))",
|
||||
"linear-gradient(160deg, oklch(70% 0.09 var(--tp-hue)), oklch(45% 0.08 var(--tp-hue)))",
|
||||
}}
|
||||
>
|
||||
{glyph}
|
||||
@@ -321,7 +321,7 @@ function RewardArt({
|
||||
style={{
|
||||
...shared,
|
||||
objectFit: "cover",
|
||||
background: "oklch(45% 0.08 175)",
|
||||
background: "oklch(45% 0.08 var(--tp-hue))",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3,16 +3,26 @@
|
||||
* by tippen.css to stop the one CSS animation loop that is not a component's to switch
|
||||
* off - the pulse on the next key to press.
|
||||
*
|
||||
* The pets swim here rather than on the home screen, so they stay with her on the lesson
|
||||
* The pets live here rather than on the home screen, so they stay with her on the lesson
|
||||
* map and during a run too. */
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { CreatureId } from "../../lib/tippen/aquarium";
|
||||
import type { CreatureId } from "../../lib/tippen/pets";
|
||||
import { PERF } from "../../lib/perfSettings";
|
||||
import { SHOW_AQUARIUM_CREATURES, SHOW_BUBBLES, SHOW_GLASS_BLUR } from "../../lib/tippen/theme";
|
||||
import { AquariumCreatures } from "./AquariumCreatures";
|
||||
import { Bubbles } from "./Bubbles";
|
||||
import { SHOW_DRIFT, SHOW_GLASS_BLUR, SHOW_PETS } from "../../lib/tippen/theme";
|
||||
import { Drift } from "../Drift";
|
||||
import type { DriftField } from "../Drift";
|
||||
import { Pets } from "./Pets";
|
||||
|
||||
/** The typing game's background field - see Drift. Sparser and fainter than the room
|
||||
* page's, since it sits behind a typing drill. */
|
||||
const FIELD: DriftField = [
|
||||
[6, 14, 0.18, 13, 0], [17, 9, 0.14, 16, 2.5], [28, 20, 0.12, 11, 5],
|
||||
[39, 7, 0.2, 18, 1.2], [51, 16, 0.13, 14, 7], [62, 11, 0.17, 12, 3.4],
|
||||
[73, 22, 0.1, 17, 6.1], [84, 8, 0.19, 15, 0.8], [93, 13, 0.14, 13, 4.2],
|
||||
[11, 18, 0.11, 19, 8.5], [45, 10, 0.16, 15, 9.3], [68, 6, 0.21, 20, 2],
|
||||
];
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
@@ -29,8 +39,8 @@ export function Stage({ children, creatures, dimmed }: Props) {
|
||||
data-blur={SHOW_GLASS_BLUR ? "on" : "off"}
|
||||
data-anim={PERF.petAnimations ? "on" : "off"}
|
||||
>
|
||||
{SHOW_AQUARIUM_CREATURES && <AquariumCreatures creatures={creatures} opacity={dimmed ? 0.25 : 1} />}
|
||||
{SHOW_BUBBLES && <Bubbles />}
|
||||
{SHOW_PETS && <Pets creatures={creatures} opacity={dimmed ? 0.25 : 1} />}
|
||||
{SHOW_DRIFT && <Drift field={FIELD} bubbleColor={(alpha) => `oklch(97% 0.01 var(--tp-hue) / ${alpha})`} />}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* badge on the lesson map and the 168px chest in the unlock overlay.
|
||||
*
|
||||
* `open` is a plain prop, not an animation trigger: the lid transitions to its open
|
||||
* angle through CSS (`.tp-chest-lid`, tippen.css), so the map can render a permanently
|
||||
* angle through CSS (`.tp-reward-lid`, tippen.css), so the map can render a permanently
|
||||
* open chest for a claimed reward with no animation bookkeeping at all, and the overlay
|
||||
* gets its swing for free just by flipping the prop mid-sequence.
|
||||
*
|
||||
@@ -19,23 +19,13 @@
|
||||
* what coming out of a chest actually looks like. The map, which puts nothing inside, and
|
||||
* whose shut chests need the lid painted over the body's top edge, takes the default. */
|
||||
|
||||
import type { RewardBoxProps } from "./RewardBox";
|
||||
|
||||
/** Instance-unique gradient ids. Two chests on one page (the map has many) must not share
|
||||
* `<defs>` ids, or the second one silently paints with the first one's stops. */
|
||||
let nextId = 0;
|
||||
|
||||
interface Props {
|
||||
size: number;
|
||||
open: boolean;
|
||||
/** Which half to draw - see the header. "all" is the whole chest, in one element. */
|
||||
layer?: "all" | "back" | "front";
|
||||
/** Softened - a reward whose lesson is still ahead of her. Muted rather than greyed
|
||||
* out: a shut chest still has to look like treasure, or the badge promises nothing. */
|
||||
muted?: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function TreasureChest({ size, open, layer = "all", muted = false, className, style }: Props) {
|
||||
export function TreasureChest({ size, open, layer = "all", muted = false, className, style }: RewardBoxProps) {
|
||||
const id = `tp-chest-${(nextId += 1)}`;
|
||||
|
||||
return (
|
||||
@@ -130,7 +120,7 @@ export function TreasureChest({ size, open, layer = "all", muted = false, classN
|
||||
* that works in a flat, front-on view. */
|
||||
function Lid({ id, open }: { id: string; open: boolean }) {
|
||||
return (
|
||||
<g className="tp-chest-lid" data-open={open || undefined} style={{ transformOrigin: "8px 27px" }}>
|
||||
<g className="tp-reward-lid" data-open={open || undefined} style={{ transformOrigin: "8px 27px" }}>
|
||||
<path d="M8 27 A 24 21 0 0 1 56 27 Z" fill={`url(#${id}-lid)`} stroke="oklch(28% 0.06 45)" strokeWidth="2" />
|
||||
<rect
|
||||
x="7"
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { SKIN } from "../../../lib/skins";
|
||||
import { currentChar } from "../../../lib/tippen/engine";
|
||||
import type { RunEvent } from "../../../lib/tippen/engine";
|
||||
import { fingerOf } from "../../../lib/tippen/fingers";
|
||||
@@ -35,6 +36,18 @@ const VISIBLE_COUNT = 5;
|
||||
/** Fixed horizontal lanes, so bubbles do not jitter sideways as they rise. */
|
||||
const LANES = [50, 28, 68, 38, 60, 46];
|
||||
|
||||
/** Soap bubbles under water, or (`blobs: "lava"`) glowing lava blobs welling up to the
|
||||
* lava's surface. Only the outline and the glow change - the fill stays the finger's
|
||||
* colour either way, because that colour is what teaches which finger to use. */
|
||||
const LOOK =
|
||||
SKIN.game.blobs === "lava"
|
||||
? {
|
||||
surface: "oklch(74% 0.17 50 / 0.55)",
|
||||
radius: "46% 54% 52% 48% / 56% 50% 50% 44%",
|
||||
glow: ", 0 0 24px oklch(72% 0.18 45 / 0.5)",
|
||||
}
|
||||
: { surface: "oklch(97% 0.01 var(--tp-hue) / 0.25)", radius: "50%", glow: "" };
|
||||
|
||||
export function BubblesRun({ letters, activeKeys, progress, paused, onFinished }: Props) {
|
||||
const text = letters.join("");
|
||||
const [popped, setPopped] = useState<number | null>(null);
|
||||
@@ -76,7 +89,7 @@ export function BubblesRun({ letters, activeKeys, progress, paused, onFinished }
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 2,
|
||||
background: "oklch(97% 0.01 175 / 0.25)",
|
||||
background: LOOK.surface,
|
||||
}}
|
||||
/>
|
||||
{[...text].map((letter, i) => {
|
||||
@@ -98,18 +111,18 @@ export function BubblesRun({ letters, activeKeys, progress, paused, onFinished }
|
||||
transform: "translate(-50%, 0)",
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: "50%",
|
||||
borderRadius: LOOK.radius,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: size * 0.42,
|
||||
fontWeight: 900,
|
||||
color: isCurrent ? "oklch(25% 0.05 175)" : "var(--paper)",
|
||||
color: isCurrent ? "oklch(25% 0.05 var(--tp-hue))" : "var(--paper)",
|
||||
background: isCurrent
|
||||
? "var(--paper)"
|
||||
: `linear-gradient(160deg, oklch(75% 0.12 ${finger?.hue ?? 175} / .45), oklch(50% 0.09 ${finger?.hue ?? 175} / .2))`,
|
||||
border: `2px solid oklch(90% 0.05 ${finger?.hue ?? 175} / ${isCurrent ? 0.9 : 0.35})`,
|
||||
boxShadow: isCurrent ? "0 10px 30px var(--shadow)" : "0 4px 14px var(--shadow)",
|
||||
: `linear-gradient(160deg, oklch(75% 0.12 ${finger?.hue ?? "var(--tp-hue)"} / .45), oklch(50% 0.09 ${finger?.hue ?? "var(--tp-hue)"} / .2))`,
|
||||
border: `2px solid oklch(90% 0.05 ${finger?.hue ?? "var(--tp-hue)"} / ${isCurrent ? 0.9 : 0.35})`,
|
||||
boxShadow: (isCurrent ? "0 10px 30px var(--shadow)" : "0 4px 14px var(--shadow)") + LOOK.glow,
|
||||
opacity: 1 - distance * 0.13,
|
||||
transition: "top 380ms cubic-bezier(.2,.7,.3,1), width 240ms ease, height 240ms ease, font-size 240ms ease",
|
||||
animation:
|
||||
|
||||
@@ -73,7 +73,7 @@ function ProgressBar({ done, total }: { done: number; total: number }) {
|
||||
width: "min(520px, 80%)",
|
||||
height: 8,
|
||||
borderRadius: 999,
|
||||
background: "oklch(97% 0.01 175 / 0.16)",
|
||||
background: "oklch(97% 0.01 var(--tp-hue) / 0.16)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { SKIN } from "../../../lib/skins";
|
||||
import { currentChar } from "../../../lib/tippen/engine";
|
||||
import type { RunEvent } from "../../../lib/tippen/engine";
|
||||
import { chunkOffsets } from "../../../lib/tippen/generator";
|
||||
@@ -29,8 +30,6 @@ interface Props {
|
||||
onFinished: (result: RunResult) => void;
|
||||
}
|
||||
|
||||
const FISH = ["🐟", "🐠", "🐡", "🦐", "🦀"];
|
||||
|
||||
export function FeedRun({
|
||||
chunks,
|
||||
text,
|
||||
@@ -79,18 +78,18 @@ export function FeedRun({
|
||||
}}
|
||||
>
|
||||
<div style={{ position: "relative", flex: 1, width: "100%", minHeight: 0, overflow: "hidden" }}>
|
||||
{/* The dolphin waits on the left; the fish come to it. */}
|
||||
{/* The feeder waits on the left; the food comes to it. */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 8,
|
||||
top: "42%",
|
||||
fontSize: 66,
|
||||
animation: "dolphinBob 3.2s ease-in-out infinite",
|
||||
animation: "mascotBob 3.2s ease-in-out infinite",
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
🐬
|
||||
{SKIN.game.feeder}
|
||||
</div>
|
||||
|
||||
{chunks.map((word, i) => {
|
||||
@@ -115,8 +114,8 @@ export function FeedRun({
|
||||
gap: 10,
|
||||
padding: isTarget ? "10px 18px" : "7px 13px",
|
||||
borderRadius: 999,
|
||||
background: isTarget ? "var(--paper)" : "oklch(97% 0.01 175 / .13)",
|
||||
border: `2px solid oklch(97% 0.01 175 / ${isTarget ? 0.85 : 0.2})`,
|
||||
background: isTarget ? "var(--paper)" : "oklch(97% 0.01 var(--tp-hue) / .13)",
|
||||
border: `2px solid oklch(97% 0.01 var(--tp-hue) / ${isTarget ? 0.85 : 0.2})`,
|
||||
boxShadow: isTarget ? "0 10px 28px var(--shadow)" : "0 3px 10px var(--shadow)",
|
||||
maxWidth: "34%",
|
||||
opacity: 1 - distance * 0.22,
|
||||
@@ -125,13 +124,13 @@ export function FeedRun({
|
||||
zIndex: isTarget ? 3 : 1,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: isTarget ? 34 : 24 }}>{FISH[i % FISH.length]}</span>
|
||||
<span style={{ fontSize: isTarget ? 34 : 24 }}>{SKIN.game.feedItems[i % SKIN.game.feedItems.length]}</span>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
fontSize: isTarget ? 34 : 22,
|
||||
fontWeight: 800,
|
||||
color: isTarget ? "oklch(25% 0.05 175)" : "var(--paper)",
|
||||
color: isTarget ? "oklch(25% 0.05 var(--tp-hue))" : "var(--paper)",
|
||||
}}
|
||||
>
|
||||
{isTarget
|
||||
@@ -147,7 +146,7 @@ export function FeedRun({
|
||||
? "oklch(58% 0.15 150)"
|
||||
: charState === "current"
|
||||
? "var(--accent)"
|
||||
: "oklch(45% 0.03 175)",
|
||||
: "oklch(45% 0.03 var(--tp-hue))",
|
||||
textDecoration: charState === "current" ? "underline" : undefined,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { SKIN } from "../../../lib/skins";
|
||||
import { currentChar } from "../../../lib/tippen/engine";
|
||||
import type { RunResult } from "../../../lib/tippen/grading";
|
||||
import type { Progress } from "../../../lib/tippen/progress";
|
||||
@@ -127,13 +128,13 @@ export function RaceRun({
|
||||
transition: "left 140ms ease-out",
|
||||
}}
|
||||
>
|
||||
🐬
|
||||
{SKIN.game.racer}
|
||||
</div>
|
||||
</Lane>
|
||||
|
||||
<Lane label={ghost ? "Dein Rekord" : "Die Krabbe"} color="oklch(97% 0.01 175 / .55)">
|
||||
<Lane label={ghost ? "Dein Rekord" : SKIN.game.rival.label} color="oklch(97% 0.01 var(--tp-hue) / .55)">
|
||||
<div ref={opponent} style={{ position: "absolute", left: "0%", transform: "translateX(-50%)", fontSize: 30 }}>
|
||||
{ghost ? "👻" : "🦀"}
|
||||
{ghost ? "👻" : SKIN.game.rival.emoji}
|
||||
</div>
|
||||
</Lane>
|
||||
</div>
|
||||
@@ -163,8 +164,8 @@ function Lane({ label, color, children }: { label: string; color: string; childr
|
||||
flex: 1,
|
||||
height: 44,
|
||||
borderRadius: 999,
|
||||
background: "oklch(97% 0.01 175 / .1)",
|
||||
border: "1px solid oklch(97% 0.01 175 / .16)",
|
||||
background: "oklch(97% 0.01 var(--tp-hue) / .1)",
|
||||
border: "1px solid oklch(97% 0.01 var(--tp-hue) / .16)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
|
||||
69
web/src/lib/__tests__/skin.test.ts
Normal file
69
web/src/lib/__tests__/skin.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const store = new Map<string, string>();
|
||||
|
||||
async function load(search: string) {
|
||||
vi.resetModules();
|
||||
vi.stubGlobal("location", { search });
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
});
|
||||
return import("../skins");
|
||||
}
|
||||
|
||||
describe("skins", () => {
|
||||
beforeEach(() => store.clear());
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("is the dolphin with nothing chosen", async () => {
|
||||
const m = await load("");
|
||||
expect(m.SKIN.id).toBe("dolphin");
|
||||
});
|
||||
|
||||
it("takes ?theme= and remembers it", async () => {
|
||||
const m = await load("?theme=dinosaur");
|
||||
expect(m.SKIN.id).toBe("dinosaur");
|
||||
expect(store.get(m.STORAGE_KEY)).toBe("dinosaur");
|
||||
});
|
||||
|
||||
it("uses the remembered skin when the URL has none", async () => {
|
||||
store.set("musicmouse.theme", "dinosaur");
|
||||
const m = await load("?pi=1");
|
||||
expect(m.SKIN.id).toBe("dinosaur");
|
||||
});
|
||||
|
||||
it("lets ?theme= switch back", async () => {
|
||||
store.set("musicmouse.theme", "dinosaur");
|
||||
const m = await load("?theme=dolphin");
|
||||
expect(m.SKIN.id).toBe("dolphin");
|
||||
expect(store.get(m.STORAGE_KEY)).toBe("dolphin");
|
||||
});
|
||||
|
||||
it("gives every skin a complete, readable game world", async () => {
|
||||
const { SKINS } = await load("");
|
||||
const { CREATURE_IDS } = await import("../tippen/pets");
|
||||
const { ANIMALS } = await import("../tippen/grading");
|
||||
for (const skin of Object.values(SKINS)) {
|
||||
const { game } = skin;
|
||||
for (const id of CREATURE_IDS) expect(game.pets[id].name, `${skin.id} ${id}`).not.toBe("");
|
||||
const ladder = ANIMALS.map((animal) => game.ladder[animal.id]);
|
||||
for (const rung of ladder) expect(rung.name && rung.emoji && rung.praise, skin.id).toBeTruthy();
|
||||
// One look per rung: two rungs sharing a name or an emoji would read as no progress.
|
||||
expect(new Set(ladder.map((rung) => rung.emoji)).size, skin.id).toBe(ladder.length);
|
||||
expect(new Set(ladder.map((rung) => rung.name)).size, skin.id).toBe(ladder.length);
|
||||
// ⭐ is the star rating, three lines below the animal on the result sheet.
|
||||
for (const rung of ladder) expect(rung.emoji, skin.id).not.toBe("⭐");
|
||||
for (const mode of Object.values(game.modes)) expect(mode.name && mode.emoji, skin.id).toBeTruthy();
|
||||
expect(game.feedItems.length, skin.id).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores unknown values, in the URL and in storage", async () => {
|
||||
store.set("musicmouse.theme", "toString");
|
||||
const m = await load("?theme=unicorn");
|
||||
expect(m.SKIN.id).toBe("dolphin");
|
||||
expect(store.get(m.STORAGE_KEY)).toBe("toString");
|
||||
});
|
||||
});
|
||||
73
web/src/lib/skins/dinosaur.ts
Normal file
73
web/src/lib/skins/dinosaur.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { Skin } from "./types";
|
||||
|
||||
/** Dino: the same design on a jungle green with a lava-orange accent. Lightness and
|
||||
* chroma follow the dolphin's sea exactly - only the hues move - so every contrast
|
||||
* ratio the original was tuned for still holds. */
|
||||
export const DINOSAUR: Skin = {
|
||||
id: "dinosaur",
|
||||
appName: "Musik Dino",
|
||||
tippenName: "Dino Tippen",
|
||||
mascotName: "Der Dino",
|
||||
mascot: "/dinosaur-mascot.png",
|
||||
mascotRemote: "/dinosaur-remote.png",
|
||||
mascotFacesLeft: true,
|
||||
mascotMotion: "walk",
|
||||
drift: "leaves",
|
||||
icons: {
|
||||
favicon: "/dinosaur/icon-192.png",
|
||||
appleTouch: "/dinosaur/apple-touch-icon.png",
|
||||
manifest: "/dinosaur/manifest.webmanifest",
|
||||
},
|
||||
// The dolphin's #0d2f3c - oklch(28.75% 0.045 226) - turned to the jungle's hue.
|
||||
themeColor: "#1c311c",
|
||||
// The dolphin's six colours, named for this world instead of the sea.
|
||||
lightSwatches: [
|
||||
{ name: "Warmweiß", oklch: "oklch(90% 0.06 85)" },
|
||||
{ name: "Sonnengelb", oklch: "oklch(85% 0.16 95)" },
|
||||
{ name: "Lavarot", oklch: "oklch(65% 0.20 25)" },
|
||||
{ name: "Himmelblau", oklch: "oklch(70% 0.15 235)" },
|
||||
{ name: "Farngrün", oklch: "oklch(75% 0.16 155)" },
|
||||
{ name: "Blütenlila", oklch: "oklch(65% 0.18 310)" },
|
||||
],
|
||||
hue: 145,
|
||||
accentHue: 45,
|
||||
tippenHue: 120,
|
||||
stageGradient: ["#577d58", "#2d4b2e", "#071b08"],
|
||||
paperRgb: "241 247 241",
|
||||
game: {
|
||||
habitat: "ins Dino-Tal",
|
||||
// Emoji stand in until the drawings exist. Each becomes `art: { image: "/dinopark/….webp" }`
|
||||
// (and `facing: "side"` for a side view drawn facing right) once it is made.
|
||||
pets: {
|
||||
clownfish: { name: "Triceratops", article: "Der", art: { emoji: "🦏" }, size: 0.15, speed: 0.03, facing: "front", motion: "walk" },
|
||||
octopus: { name: "Stegosaurus", article: "Der", art: { emoji: "🐊" }, size: 0.15, speed: 0.025, facing: "front", motion: "walk" },
|
||||
seahorse: { name: "Flugsaurier", article: "Der", art: { emoji: "🦅" }, size: 0.13, speed: 0.05, facing: "front", motion: "fly" },
|
||||
turtle: { name: "Langhals", article: "Der", art: { emoji: "🦕" }, size: 0.2, speed: 0.02, facing: "front", motion: "walk" },
|
||||
pearlmussel: { name: "Dino-Baby", article: "Das", art: { emoji: "🐣" }, size: 0.1, speed: 0.025, facing: "front", motion: "walk" },
|
||||
},
|
||||
ladder: {
|
||||
snail: { name: "Schnecke", emoji: "🐌", praise: "Die Schnecke ist losgekrochen!" },
|
||||
crab: { name: "Raupe", emoji: "🐛", praise: "Eine Raupe! Die krabbelt schon los." },
|
||||
turtle: { name: "Schildkröte", emoji: "🐢", praise: "Die Schildkröte ist stetig und sicher." },
|
||||
jellyfish: { name: "Eidechse", emoji: "🦎", praise: "Eine Eidechse! Die huscht schon flink." },
|
||||
fish: { name: "Krokodil", emoji: "🐊", praise: "Ein Krokodil! Das ist richtig flott." },
|
||||
penguin: { name: "Langhals", emoji: "🦕", praise: "Ein Langhals! Der stapft mit großen Schritten." },
|
||||
seal: { name: "Mammut", emoji: "🦣", praise: "Das Mammut stampft schnell und kräftig!" },
|
||||
dolphin: { name: "T-Rex", emoji: "🦖", praise: "Ein T-Rex! Das ist richtig, richtig schnell." },
|
||||
shark: { name: "Säbelzahntiger", emoji: "🐅", praise: "Ein Säbelzahntiger! Unglaublich schnell." },
|
||||
orca: { name: "Drache", emoji: "🐉", praise: "Ein Drache! Schneller wird es im Dino-Tal nicht." },
|
||||
},
|
||||
modes: {
|
||||
dive: { emoji: "🥾", name: "Expedition" },
|
||||
bubbles: { emoji: "🌋", name: "Lavablubbern" },
|
||||
feed: { emoji: "🌿", name: "Fütterungszeit" },
|
||||
race: { emoji: "🦖", name: "Dinorennen" },
|
||||
},
|
||||
feeder: "🦕",
|
||||
feedItems: ["🌿", "🍃", "🍓", "🍎", "🌰"],
|
||||
racer: "🦖",
|
||||
rival: { emoji: "🐢", label: "Die Schildkröte" },
|
||||
blobs: "lava",
|
||||
rewardBox: "egg",
|
||||
},
|
||||
};
|
||||
65
web/src/lib/skins/dolphin.ts
Normal file
65
web/src/lib/skins/dolphin.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { Skin } from "./types";
|
||||
|
||||
/** Dolphin Beats: the original design (claude-design/Dolphin Beats.dc.html). A teal
|
||||
* sea with a pink accent and a turquoise lagoon for the typing game. */
|
||||
export const DOLPHIN: Skin = {
|
||||
id: "dolphin",
|
||||
appName: "Musik Delfin",
|
||||
tippenName: "Delfin Tippen",
|
||||
mascotName: "Der Delphin",
|
||||
mascot: "/dolphin-mascot.png",
|
||||
mascotRemote: "/dolphin-remote.png",
|
||||
mascotFacesLeft: false,
|
||||
mascotMotion: "swim",
|
||||
drift: "bubbles",
|
||||
icons: { favicon: "/icon-192.png", appleTouch: "/apple-touch-icon.png", manifest: "/manifest.webmanifest" },
|
||||
themeColor: "#0d2f3c",
|
||||
lightSwatches: [
|
||||
{ name: "Warmweiß", oklch: "oklch(90% 0.06 85)" },
|
||||
{ name: "Sonnengelb", oklch: "oklch(85% 0.16 95)" },
|
||||
{ name: "Korallenrot", oklch: "oklch(65% 0.20 25)" },
|
||||
{ name: "Delfinblau", oklch: "oklch(70% 0.15 235)" },
|
||||
{ name: "Riffgrün", oklch: "oklch(75% 0.16 155)" },
|
||||
{ name: "Quallenlila", oklch: "oklch(65% 0.18 310)" },
|
||||
],
|
||||
hue: 210,
|
||||
accentHue: 340,
|
||||
tippenHue: 175,
|
||||
stageGradient: ["#397d88", "#0e4b54", "#001b21"],
|
||||
paperRgb: "238 247 249",
|
||||
game: {
|
||||
habitat: "ins Aquarium",
|
||||
// Drawings made from the originals by scripts/aquarium-bild.sh.
|
||||
pets: {
|
||||
clownfish: { name: "Clownfisch", article: "Der", art: { image: "/aquarium/clownfisch.webp" }, size: 0.13, speed: 0.045, facing: "side", motion: "swim" },
|
||||
octopus: { name: "Krake", article: "Die", art: { image: "/aquarium/krake.webp" }, size: 0.17, speed: 0.025, facing: "front", motion: "swim" },
|
||||
seahorse: { name: "Seepferdchen", article: "Das", art: { image: "/aquarium/seepferdchen.webp" }, size: 0.19, speed: 0.02, facing: "side", motion: "swim" },
|
||||
turtle: { name: "Schildkröte", article: "Die", art: { image: "/aquarium/schildkroete.webp" }, size: 0.17, speed: 0.032, facing: "side", motion: "swim" },
|
||||
pearlmussel: { name: "Perlmuschel", article: "Die", art: { image: "/aquarium/perlmuschel.webp" }, size: 0.12, speed: 0.016, facing: "front", motion: "swim" },
|
||||
},
|
||||
ladder: {
|
||||
snail: { name: "Meeresschnecke", emoji: "🐌", praise: "Die Schnecke ist losgekrochen!" },
|
||||
crab: { name: "Krabbe", emoji: "🦀", praise: "Eine Krabbe! Die krabbelt schon los." },
|
||||
turtle: { name: "Schildkröte", emoji: "🐢", praise: "Die Schildkröte ist stetig und sicher." },
|
||||
jellyfish: { name: "Qualle", emoji: "🪼", praise: "Die Qualle gleitet dahin!" },
|
||||
fish: { name: "Fisch", emoji: "🐟", praise: "Ein Fisch! Der schwimmt richtig flott." },
|
||||
penguin: { name: "Pinguin", emoji: "🐧", praise: "Ein Pinguin! Der flitzt durchs Wasser." },
|
||||
seal: { name: "Robbe", emoji: "🦭", praise: "Die Robbe ist schnell und geschickt!" },
|
||||
dolphin: { name: "Delfin", emoji: "🐬", praise: "Ein Delfin! Das ist richtig, richtig schnell." },
|
||||
shark: { name: "Hai", emoji: "🦈", praise: "Ein Hai! Unglaublich schnell." },
|
||||
orca: { name: "Schwertwal", emoji: "🐋", praise: "Ein Schwertwal! Schneller wird es im Meer nicht." },
|
||||
},
|
||||
modes: {
|
||||
dive: { emoji: "🤿", name: "Tauchgang" },
|
||||
bubbles: { emoji: "🫧", name: "Blasenplatzen" },
|
||||
feed: { emoji: "🐟", name: "Fütterungszeit" },
|
||||
race: { emoji: "🐬", name: "Delfinrennen" },
|
||||
},
|
||||
feeder: "🐬",
|
||||
feedItems: ["🐟", "🐠", "🐡", "🦐", "🦀"],
|
||||
racer: "🐬",
|
||||
rival: { emoji: "🦀", label: "Die Krabbe" },
|
||||
blobs: "bubbles",
|
||||
rewardBox: "chest",
|
||||
},
|
||||
};
|
||||
72
web/src/lib/skins/index.ts
Normal file
72
web/src/lib/skins/index.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/** Which skin this page runs in, and putting it on the page.
|
||||
*
|
||||
* Chosen with `?theme=dolphin` or `?theme=dinosaur`. The choice is remembered in this
|
||||
* browser, because the installed home-screen app always starts at plain "/" (the
|
||||
* manifest's `start_url`) and would otherwise forget it on every launch. Without either,
|
||||
* it is the dolphin. */
|
||||
|
||||
import { DINOSAUR } from "./dinosaur";
|
||||
import { DOLPHIN } from "./dolphin";
|
||||
import type { Skin, SkinId } from "./types";
|
||||
|
||||
export type { AnimalLook, GameWorld, PetLook, Skin, SkinId } from "./types";
|
||||
|
||||
export const SKINS: Record<SkinId, Skin> = {
|
||||
dolphin: DOLPHIN,
|
||||
dinosaur: DINOSAUR,
|
||||
};
|
||||
|
||||
export const STORAGE_KEY = "musicmouse.theme";
|
||||
|
||||
const isSkinId = (value: unknown): value is SkinId =>
|
||||
typeof value === "string" && Object.hasOwn(SKINS, value);
|
||||
|
||||
function readSkin(): Skin {
|
||||
// `location` is absent under vitest's default node environment.
|
||||
if (typeof location === "undefined") return DOLPHIN;
|
||||
|
||||
const requested = new URLSearchParams(location.search).get("theme");
|
||||
if (isSkinId(requested)) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, requested);
|
||||
} catch {
|
||||
// Storage blocked: this page still gets the skin, the next one will not remember it.
|
||||
}
|
||||
return SKINS[requested];
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (isSkinId(stored)) return SKINS[stored];
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return DOLPHIN;
|
||||
}
|
||||
|
||||
export const SKIN: Skin = readSkin();
|
||||
|
||||
/** Write the skin's CSS custom properties onto `<html>`. Called once, before the first
|
||||
* render, so nothing ever paints in the wrong colours. */
|
||||
export function applySkin(skin: Skin = SKIN): void {
|
||||
const root = document.documentElement;
|
||||
root.dataset.theme = skin.id;
|
||||
const vars: Record<string, string> = {
|
||||
"--hue": String(skin.hue),
|
||||
"--accent-hue": String(skin.accentHue),
|
||||
"--tp-hue": String(skin.tippenHue),
|
||||
"--stage-top": skin.stageGradient[0],
|
||||
"--stage-mid": skin.stageGradient[1],
|
||||
"--stage-deep": skin.stageGradient[2],
|
||||
"--paper-rgb": skin.paperRgb,
|
||||
};
|
||||
for (const [name, value] of Object.entries(vars)) root.style.setProperty(name, value);
|
||||
document.title = skin.appName;
|
||||
|
||||
// index.html ships the dolphin's; these are the same values for it. iOS reads the
|
||||
// touch icon when "Zum Home-Bildschirm" is tapped, so a swap made here is in time.
|
||||
document.querySelector<HTMLLinkElement>('link[rel="icon"]')?.setAttribute("href", skin.icons.favicon);
|
||||
document.querySelector<HTMLLinkElement>('link[rel="apple-touch-icon"]')?.setAttribute("href", skin.icons.appleTouch);
|
||||
document.querySelector<HTMLLinkElement>('link[rel="manifest"]')?.setAttribute("href", skin.icons.manifest);
|
||||
document.querySelector<HTMLMetaElement>('meta[name="theme-color"]')?.setAttribute("content", skin.themeColor);
|
||||
}
|
||||
112
web/src/lib/skins/types.ts
Normal file
112
web/src/lib/skins/types.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/** What one child's version of the app looks like.
|
||||
*
|
||||
* Everything that differs between the two kids' devices lives in one `Skin` object, so
|
||||
* a new look is a new file next to dolphin.ts rather than a hunt through components.
|
||||
* What does *not* live here: layout and feature toggles (`lib/theme.ts`,
|
||||
* `lib/tippen/theme.ts`), which are the same for every skin.
|
||||
*
|
||||
* Hues are oklch degrees. CSS reads them as custom properties (`applySkin` in
|
||||
* index.ts writes them onto `<html>`), canvas and computed style strings read the
|
||||
* numbers from here directly - the canvas cannot resolve `var()`. */
|
||||
|
||||
import type { ModeId } from "../tippen/curriculum";
|
||||
import type { AnimalId } from "../tippen/grading";
|
||||
import type { CreatureId } from "../tippen/pets";
|
||||
|
||||
export type SkinId = "dolphin" | "dinosaur";
|
||||
|
||||
export interface Skin {
|
||||
id: SkinId;
|
||||
/** Browser tab title, the music header and the phone layout's title bar. */
|
||||
appName: string;
|
||||
/** The typing game's header. */
|
||||
tippenName: string;
|
||||
/** Who the app is, in "… ist nicht erreichbar" when the backend cannot be reached. */
|
||||
mascotName: string;
|
||||
/** The mascot with headphones, used everywhere a mascot shows. */
|
||||
mascot: string;
|
||||
/** The mascot holding a remote, for "Mein Zimmer". */
|
||||
mascotRemote: string;
|
||||
/** The play view's swimming mascot is animated assuming it faces right; a drawing
|
||||
* that faces left is mirrored so it looks where it is going. */
|
||||
mascotFacesLeft: boolean;
|
||||
/** How the play view's mascot crosses the screen: swimming in waves, or walking. */
|
||||
mascotMotion: "swim" | "walk";
|
||||
/** What drifts through every background - the play view's canvas, the room page and
|
||||
* the typing game: rising bubbles or falling leaves. */
|
||||
drift: "bubbles" | "leaves";
|
||||
/** Browser chrome and the installed app. `index.html` carries the dolphin's; another
|
||||
* skin swaps them in at startup (`applySkin`). */
|
||||
icons: { favicon: string; appleTouch: string; manifest: string };
|
||||
/** The browser/status bar colour, as `<meta name="theme-color">`. */
|
||||
themeColor: string;
|
||||
/** The colour presets on a room light's card. */
|
||||
lightSwatches: readonly { name: string; oklch: string }[];
|
||||
/** The music player's base hue: stage, glass, text tints, the ambience canvas. */
|
||||
hue: number;
|
||||
/** Focus rings, the search box frame, the caret, the playing-card outline. */
|
||||
accentHue: number;
|
||||
/** The typing game's base hue - a sibling of `hue`, not the same room. */
|
||||
tippenHue: number;
|
||||
/** The stage gradient's three stops, top to deep, as sRGB hex. Not oklch on purpose:
|
||||
* see the note on `.stage` in app.css. Each is the sRGB rendering of
|
||||
* oklch(55% 0.07 hue) / (38% 0.06 hue) / (20% 0.045 hue). */
|
||||
stageGradient: readonly [string, string, string];
|
||||
/** oklch(97% 0.01 hue) as space-separated sRGB channels, for `rgb(var(--paper-rgb) / a)`
|
||||
* in the glass gradients that must stay sRGB for the same reason. */
|
||||
paperRgb: string;
|
||||
/** Everything the typing game shows that is not a colour. */
|
||||
game: GameWorld;
|
||||
}
|
||||
|
||||
/** The typing game's world: who lives in it, who races and who eats.
|
||||
*
|
||||
* The backend stores pets and speed animals by id (`CreatureId`, `AnimalId`: the
|
||||
* dolphin's names, because the dolphin came first). Those ids are slot keys and nothing
|
||||
* more - "clownfish" means "world 1's pet", "dolphin" means "the eighth rung". Thresholds,
|
||||
* world order and the surprise boundary stay with the game logic; a skin only says what
|
||||
* each slot looks like, so both skins share one backend and one save format. */
|
||||
export interface GameWorld {
|
||||
/** Where pets move in, finishing "Die Krake ist … gezogen!". */
|
||||
habitat: string;
|
||||
pets: Record<CreatureId, PetLook>;
|
||||
ladder: Record<AnimalId, AnimalLook>;
|
||||
modes: Record<ModeId, { emoji: string; name: string }>;
|
||||
/** Feed mode: who waits on the left, and what carries each word to it. */
|
||||
feeder: string;
|
||||
feedItems: readonly string[];
|
||||
/** Race mode: the player's racer, and the opponent before a ghost exists. */
|
||||
racer: string;
|
||||
rival: { emoji: string; label: string };
|
||||
/** The letters in the bubbles game: soap-like bubbles, or glowing lava blobs. */
|
||||
blobs: "bubbles" | "lava";
|
||||
/** What a music reward comes out of. */
|
||||
rewardBox: "chest" | "egg";
|
||||
}
|
||||
|
||||
export interface PetLook {
|
||||
name: string;
|
||||
/** For the sentence on arrival: "Die Krake ist ins Aquarium gezogen!" */
|
||||
article: "Der" | "Die" | "Das";
|
||||
/** A drawing under public/, or - until one exists - an emoji standing in for it. */
|
||||
art: { image: string } | { emoji: string };
|
||||
/** Height as a fraction of the stage height - so a pet is the same size relative to
|
||||
* the stage on a small laptop and on a big screen. */
|
||||
size: number;
|
||||
/** Cruising speed as a fraction of the stage width per second. Slow on purpose: these
|
||||
* are in the background of a typing drill, and anything darting reads as an event. */
|
||||
speed: number;
|
||||
/** Which way the drawing faces. Side-view drawings face right and are mirrored to look
|
||||
* where they go; front-view ones never are - an octopus flipping on every turn looks
|
||||
* broken. Emoji are front: which way they face depends on the platform's font. */
|
||||
facing: "side" | "front";
|
||||
/** Where on the stage it lives: anywhere, along the ground, or up in the sky. */
|
||||
motion: "swim" | "walk" | "fly";
|
||||
}
|
||||
|
||||
export interface AnimalLook {
|
||||
name: string;
|
||||
emoji: string;
|
||||
/** What the speech synthesis says when this animal is reached. */
|
||||
praise: string;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import type { CSSProperties } from "react";
|
||||
import { PERF } from "./perfSettings";
|
||||
import type { Group } from "./search";
|
||||
import type { UiState } from "./keyboard";
|
||||
import { SKIN } from "./skins";
|
||||
|
||||
// ---------------------------------------------------------------- toggles --
|
||||
|
||||
@@ -88,7 +89,7 @@ export const SHOW_DECORATIVE_ANIMATIONS = PERF.decorativeAnimations;
|
||||
* at the mood its albums play into - and so does the book/music card tint in
|
||||
* `lib/covers.ts`, which reads `GROUP_HUE.audiobooks`/`GROUP_HUE.music` too. */
|
||||
export const GROUP_HUE: Record<Group, number> = {
|
||||
music: 210, // today's sea - Dolphin Beats' baseline hue
|
||||
music: SKIN.hue, // the skin's own base: the dolphin's sea, the dinosaur's jungle
|
||||
audiobooks: 55, // warm amber - richer than the old shared hue (80)'s dull brown
|
||||
podcasts: 300, // the one otherwise-unused family
|
||||
};
|
||||
@@ -144,7 +145,7 @@ export function glassTint(hue: number): CSSProperties {
|
||||
* neutral highlight the row used before tinting existed. */
|
||||
export function rowTint(hue: number, highlighted: boolean): CSSProperties {
|
||||
if (!SHOW_ROW_TINT) {
|
||||
return { background: `oklch(97% 0.01 210 / ${highlighted ? ".22" : ".10"})` };
|
||||
return { background: `oklch(97% 0.01 var(--hue) / ${highlighted ? ".22" : ".10"})` };
|
||||
}
|
||||
return {
|
||||
background: `oklch(${highlighted ? "62% 0.12" : "42% 0.08"} ${hue} / ${highlighted ? ".34" : ".2"})`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CREATURES, creatureFromRaw, creatureById, createSwimmer, pose, stepSwimmer } from "../aquarium";
|
||||
import type { Tank, Swimmer } from "../aquarium";
|
||||
import { BANDS, CREATURES, creatureFromRaw, creatureById, createSwimmer, pose, stepSwimmer } from "../pets";
|
||||
import type { Tank, Swimmer } from "../pets";
|
||||
import { mulberry32 } from "../generator";
|
||||
|
||||
const TANK: Tank = { width: 1280, height: 800 };
|
||||
@@ -69,6 +69,27 @@ describe("stepSwimmer", () => {
|
||||
expect(inside.x).toBeLessThan(TANK.width);
|
||||
});
|
||||
|
||||
it("keeps a walker on the ground and a flier in the sky", () => {
|
||||
for (const [band, top, bottom] of [
|
||||
[BANDS.walk, 0.7 * TANK.height + MARGIN, TANK.height - MARGIN],
|
||||
[BANDS.fly, MARGIN, 0.35 * TANK.height - MARGIN],
|
||||
] as const) {
|
||||
const rng = mulberry32(9);
|
||||
let s = createSwimmer(TANK, MARGIN, rng, false, band);
|
||||
expect(s.y).toBeGreaterThanOrEqual(top);
|
||||
expect(s.y).toBeLessThanOrEqual(bottom);
|
||||
// Ten minutes, checked every frame: steering eases towards each target, so the
|
||||
// centre may never overshoot past the strip its targets are drawn from.
|
||||
for (let t = 0; t < 600; t += DT) {
|
||||
s = stepSwimmer(s, DT, TANK, MARGIN, SPEED, rng, band);
|
||||
expect(s.targetY).toBeGreaterThanOrEqual(top);
|
||||
expect(s.targetY).toBeLessThanOrEqual(bottom);
|
||||
expect(s.y).toBeGreaterThanOrEqual(top - 1);
|
||||
expect(s.y).toBeLessThanOrEqual(bottom + 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("finds a new target when the window shrinks under the old one", () => {
|
||||
const s: Swimmer = { x: 200, y: 200, vx: 0, vy: 0, targetX: 1200, targetY: 700, facing: 1, age: 0 };
|
||||
const small = { width: 500, height: 400 };
|
||||
@@ -85,6 +106,15 @@ describe("pose", () => {
|
||||
expect(pose(s, creatureById("clownfish"), SPEED).mirror).toBe(-1);
|
||||
});
|
||||
|
||||
it("hops a walker off the ground instead of bobbing it through", () => {
|
||||
const walker = { ...creatureById("clownfish"), motion: "walk" as const };
|
||||
for (let age = 0; age < 4; age += 0.1) {
|
||||
const p = pose({ ...s, vy: 20, age }, walker, SPEED);
|
||||
expect(p.y).toBeLessThanOrEqual(s.y);
|
||||
if (walker.facing === "side") expect(Math.abs(p.rotation)).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("never mirrors a front-view pet", () => {
|
||||
expect(pose(s, creatureById("octopus"), SPEED).mirror).toBe(1);
|
||||
expect(pose(s, creatureById("pearlmussel"), SPEED).mirror).toBe(1);
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
TippenLesson as ApiLesson,
|
||||
TippenReward as ApiReward,
|
||||
} from "../../api/types";
|
||||
import type { CreatureId } from "./aquarium";
|
||||
import type { CreatureId } from "./pets";
|
||||
|
||||
export type LessonKind = "letters" | "fragments" | "words" | "sentences";
|
||||
export type ModeId = "dive" | "bubbles" | "feed" | "race";
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
* Crucially, speed only ever buys the *animal*. The unlock gate below is accuracy-only
|
||||
* (see `isPassed`), so a slow, careful child still reaches the end of the curriculum. */
|
||||
|
||||
import { SKIN } from "../skins";
|
||||
import type { AnimalLook } from "../skins";
|
||||
import type { RunState, Stroke } from "./engine";
|
||||
|
||||
export interface RunResult {
|
||||
@@ -39,6 +41,9 @@ export interface RunResult {
|
||||
strokes: readonly Stroke[];
|
||||
}
|
||||
|
||||
/** The ladder's rungs, slowest first. The ids are the backend's and name the dolphin
|
||||
* skin's sea animals; to any other skin they are just rung 1 … rung 10 - see
|
||||
* `GameWorld` in ../skins/types.ts. */
|
||||
export type AnimalId =
|
||||
| "snail"
|
||||
| "crab"
|
||||
@@ -51,43 +56,42 @@ export type AnimalId =
|
||||
| "shark"
|
||||
| "orca";
|
||||
|
||||
export interface Animal {
|
||||
export interface Animal extends AnimalLook {
|
||||
id: AnimalId;
|
||||
name: string;
|
||||
emoji: string;
|
||||
/** Lower bound in points (characters per minute, accuracy-weighted). */
|
||||
from: number;
|
||||
/** What the speech synthesis says when this animal is reached. */
|
||||
praise: string;
|
||||
}
|
||||
|
||||
/** Nine sea animals, slowest first. Thresholds are calibrated against the "5 WPM pro
|
||||
/** Ten rungs, slowest first. Thresholds are calibrated against the "5 WPM pro
|
||||
* Klassenstufe" school rule - roughly 25 characters/min at the end of first grade -
|
||||
* with plenty of headroom above it.
|
||||
*
|
||||
* The dolphin sits deliberately high, at about 24 WPM, one below the top. It is a
|
||||
* realistic second- or third-grade target, which means it stays out of reach and
|
||||
* The eighth rung - the dolphin, the T-Rex - sits deliberately high, at about 24 WPM. It
|
||||
* is a realistic second- or third-grade target, which means it stays out of reach and
|
||||
* therefore worth chasing for a long time. Reaching it should feel like an event.
|
||||
*
|
||||
* Every emoji here is checked to render as the animal it names - the first draft used
|
||||
* What each rung is called, looks like and says comes from the skin (`game.ladder`).
|
||||
* Every emoji there is checked to render as the animal it names - the first draft used
|
||||
* 🗡️ for a sailfish (it is a dagger) and 🎐 for a jellyfish (it is a wind chime), and
|
||||
* ⭐ for a starfish, which collided with the star rating three lines below it. */
|
||||
export const ANIMALS: readonly Animal[] = [
|
||||
{ id: "snail", name: "Meeresschnecke", emoji: "🐌", from: 0, praise: "Die Schnecke ist losgekrochen!" },
|
||||
{ id: "crab", name: "Krabbe", emoji: "🦀", from: 15, praise: "Eine Krabbe! Die krabbelt schon los." },
|
||||
{ id: "turtle", name: "Schildkröte", emoji: "🐢", from: 25, praise: "Die Schildkröte ist stetig und sicher." },
|
||||
{ id: "jellyfish", name: "Qualle", emoji: "🪼", from: 40, praise: "Die Qualle gleitet dahin!" },
|
||||
{ id: "fish", name: "Fisch", emoji: "🐟", from: 55, praise: "Ein Fisch! Der schwimmt richtig flott." },
|
||||
{ id: "penguin", name: "Pinguin", emoji: "🐧", from: 75, praise: "Ein Pinguin! Der flitzt durchs Wasser." },
|
||||
{ id: "seal", name: "Robbe", emoji: "🦭", from: 95, praise: "Die Robbe ist schnell und geschickt!" },
|
||||
{ id: "dolphin", name: "Delfin", emoji: "🐬", from: 120, praise: "Ein Delfin! Das ist richtig, richtig schnell." },
|
||||
{ id: "shark", name: "Hai", emoji: "🦈", from: 150, praise: "Ein Hai! Unglaublich schnell." },
|
||||
{ id: "orca", name: "Schwertwal", emoji: "🐋", from: 190, praise: "Ein Schwertwal! Schneller wird es im Meer nicht." },
|
||||
const RUNGS: readonly { id: AnimalId; from: number }[] = [
|
||||
{ id: "snail", from: 0 },
|
||||
{ id: "crab", from: 15 },
|
||||
{ id: "turtle", from: 25 },
|
||||
{ id: "jellyfish", from: 40 },
|
||||
{ id: "fish", from: 55 },
|
||||
{ id: "penguin", from: 75 },
|
||||
{ id: "seal", from: 95 },
|
||||
{ id: "dolphin", from: 120 },
|
||||
{ id: "shark", from: 150 },
|
||||
{ id: "orca", from: 190 },
|
||||
];
|
||||
|
||||
export const ANIMALS: readonly Animal[] = RUNGS.map((rung) => ({ ...rung, ...SKIN.game.ladder[rung.id] }));
|
||||
|
||||
/** The last animal shown on the ladder before a run has been fast enough to earn it.
|
||||
* Everything above the dolphin stays hidden until it is actually reached - see
|
||||
* `visibleAnimals`. */
|
||||
* Everything above the dolphin (the rung, whatever the skin draws there) stays hidden
|
||||
* until it is actually reached - see `visibleAnimals`. */
|
||||
export const SURPRISE_FROM: AnimalId = "dolphin";
|
||||
|
||||
export function animalIndex(id: AnimalId): number {
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
/** Labels and emoji for each game, shared by the result sheet's bonus buttons and the
|
||||
* lesson map's mode badge. */
|
||||
|
||||
import type { ModeId } from "./curriculum";
|
||||
|
||||
export const MODE_INFO: Record<ModeId, { emoji: string; name: string }> = {
|
||||
dive: { emoji: "🤿", name: "Tauchgang" },
|
||||
bubbles: { emoji: "🫧", name: "Blasenplatzen" },
|
||||
feed: { emoji: "🐟", name: "Fütterungszeit" },
|
||||
race: { emoji: "🐬", name: "Delfinrennen" },
|
||||
};
|
||||
@@ -1,47 +1,38 @@
|
||||
/** The aquarium's pets, and how they swim.
|
||||
/** The pets, and how they get around.
|
||||
*
|
||||
* A pet moves in when a world is finished and then stays - not in a list on the home
|
||||
* screen, but swimming around behind every screen of the game. A reward that is always
|
||||
* screen, but moving around behind every screen of the game. A reward that is always
|
||||
* in view, drifting past while she types, is the strongest version of "the reward
|
||||
* persists", and it costs nothing to look at.
|
||||
*
|
||||
* Pets are illustrations, speed trophies are emoji. The ladder in grading.ts changes
|
||||
* with every run; a pet arrives once and never leaves. Keeping the two in different
|
||||
* visual languages is what lets a turtle be both a speed trophy (🐢) and a pet (the
|
||||
* drawing) without a six-year-old having to work out which is which.
|
||||
* drawing) without a six-year-old having to work out which is which. (A skin whose
|
||||
* drawings do not exist yet stands in with emoji until they do.)
|
||||
*
|
||||
* The swimming lives here rather than in the component because it is the part worth
|
||||
* testing: `stepSwimmer` is a pure step - a swimmer and a time slice in, a swimmer out -
|
||||
* so "never leaves the tank" and "looks where it is going" are checkable without a DOM
|
||||
* or a clock. The component only calls it once per frame and writes the transform. */
|
||||
* Who the pets are is the skin's business (`game.pets` in ../skins/); this module only
|
||||
* knows the slots and the movement. The movement lives here rather than in the component
|
||||
* because it is the part worth testing: `stepSwimmer` is a pure step - a swimmer and a
|
||||
* time slice in, a swimmer out - so "never leaves the tank" and "looks where it is going"
|
||||
* are checkable without a DOM or a clock. The component only calls it once per frame and
|
||||
* writes the transform. */
|
||||
|
||||
export type CreatureId = "clownfish" | "octopus" | "seahorse" | "turtle" | "pearlmussel";
|
||||
import { SKIN } from "../skins";
|
||||
import type { PetLook } from "../skins";
|
||||
|
||||
export interface Creature {
|
||||
/** One pet per world, in world order. The ids are the backend's (and the curriculum
|
||||
* file's `reward:` keys) and name the dolphin skin's pets; to every other skin they are
|
||||
* just "world 1's pet" … "world 5's pet". */
|
||||
export const CREATURE_IDS = ["clownfish", "octopus", "seahorse", "turtle", "pearlmussel"] as const;
|
||||
|
||||
export type CreatureId = (typeof CREATURE_IDS)[number];
|
||||
|
||||
export interface Creature extends PetLook {
|
||||
id: CreatureId;
|
||||
name: string;
|
||||
/** For the sentence read aloud on arrival: "Die Krake ist ins Aquarium gezogen!" */
|
||||
article: "Der" | "Die" | "Das";
|
||||
/** Under public/, made from the original drawing by scripts/aquarium-bild.sh. */
|
||||
image: string;
|
||||
/** Height as a fraction of the stage height - so a pet is the same size relative to
|
||||
* the sea on a small laptop and on a big screen. */
|
||||
size: number;
|
||||
/** Cruising speed as a fraction of the stage width per second. Slow on purpose: these
|
||||
* are in the background of a typing drill, and anything darting reads as an event. */
|
||||
speed: number;
|
||||
/** Which way the drawing faces. Side-view creatures are mirrored to look where they
|
||||
* swim; front-view ones never are - an octopus flipping on every turn looks broken. */
|
||||
facing: "side" | "front";
|
||||
}
|
||||
|
||||
export const CREATURES: readonly Creature[] = [
|
||||
{ id: "clownfish", name: "Clownfisch", article: "Der", image: "/aquarium/clownfisch.webp", size: 0.13, speed: 0.045, facing: "side" },
|
||||
{ id: "octopus", name: "Krake", article: "Die", image: "/aquarium/krake.webp", size: 0.17, speed: 0.025, facing: "front" },
|
||||
{ id: "seahorse", name: "Seepferdchen", article: "Das", image: "/aquarium/seepferdchen.webp", size: 0.19, speed: 0.02, facing: "side" },
|
||||
{ id: "turtle", name: "Schildkröte", article: "Die", image: "/aquarium/schildkroete.webp", size: 0.17, speed: 0.032, facing: "side" },
|
||||
{ id: "pearlmussel", name: "Perlmuschel", article: "Die", image: "/aquarium/perlmuschel.webp", size: 0.12, speed: 0.016, facing: "front" },
|
||||
];
|
||||
export const CREATURES: readonly Creature[] = CREATURE_IDS.map((id) => ({ id, ...SKIN.game.pets[id] }));
|
||||
|
||||
const CREATURE_BY_ID = new Map(CREATURES.map((creature) => [creature.id, creature]));
|
||||
|
||||
@@ -68,13 +59,34 @@ export function creatureFromRaw(raw: unknown): CreatureId | null {
|
||||
return LEGACY_EMOJI[raw] ?? null;
|
||||
}
|
||||
|
||||
// --- swimming ---------------------------------------------------------------
|
||||
// --- moving -----------------------------------------------------------------
|
||||
|
||||
export interface Tank {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** The horizontal strip of the tank a pet keeps to, as fractions of its height. */
|
||||
export interface Band {
|
||||
top: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
/** Swimmers use the whole tank; walkers keep to the ground along the bottom and fliers
|
||||
* to the sky along the top, so a dinosaur never floats past the lesson title. */
|
||||
export const BANDS: Readonly<Record<PetLook["motion"], Band>> = {
|
||||
swim: { top: 0, bottom: 1 },
|
||||
walk: { top: 0.7, bottom: 1 },
|
||||
fly: { top: 0, bottom: 0.35 },
|
||||
};
|
||||
|
||||
const WHOLE_TANK = BANDS.swim;
|
||||
|
||||
/** The range a pet's centre may take vertically: its band, less half its size. */
|
||||
function verticalRange(tank: Tank, margin: number, band: Band): { top: number; bottom: number } {
|
||||
return { top: band.top * tank.height + margin, bottom: band.bottom * tank.height - margin };
|
||||
}
|
||||
|
||||
export interface Swimmer {
|
||||
/** Centre, in px. */
|
||||
x: number;
|
||||
@@ -100,13 +112,18 @@ const TURN_TAU = 0.25;
|
||||
/** One gentle bob per this many seconds. */
|
||||
const BOB_PERIOD = 3.6;
|
||||
|
||||
function randomTarget(tank: Tank, margin: number, rng: () => number): { x: number; y: number } {
|
||||
function randomTarget(
|
||||
tank: Tank,
|
||||
margin: number,
|
||||
rng: () => number,
|
||||
band: Band,
|
||||
): { x: number; y: number } {
|
||||
// A tank smaller than the creature (a collapsed window) still needs a valid target:
|
||||
// the middle.
|
||||
const span = (length: number) => Math.max(0, length - 2 * margin);
|
||||
const range = verticalRange(tank, margin, band);
|
||||
return {
|
||||
x: margin + rng() * span(tank.width),
|
||||
y: margin + rng() * span(tank.height),
|
||||
x: margin + rng() * Math.max(0, tank.width - 2 * margin),
|
||||
y: range.top + rng() * Math.max(0, range.bottom - range.top),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -117,11 +134,12 @@ export function createSwimmer(
|
||||
margin: number,
|
||||
rng: () => number,
|
||||
fromOutside = false,
|
||||
band: Band = WHOLE_TANK,
|
||||
): Swimmer {
|
||||
const target = randomTarget(tank, margin, rng);
|
||||
const target = randomTarget(tank, margin, rng, band);
|
||||
const start = fromOutside
|
||||
? { x: rng() < 0.5 ? -margin : tank.width + margin, y: target.y }
|
||||
: randomTarget(tank, margin, rng);
|
||||
: randomTarget(tank, margin, rng, band);
|
||||
return {
|
||||
x: start.x,
|
||||
y: start.y,
|
||||
@@ -146,16 +164,18 @@ export function stepSwimmer(
|
||||
margin: number,
|
||||
speed: number,
|
||||
rng: () => number,
|
||||
band: Band = WHOLE_TANK,
|
||||
): Swimmer {
|
||||
let { targetX, targetY } = s;
|
||||
const range = verticalRange(tank, margin, band);
|
||||
const inTank = (x: number, y: number) =>
|
||||
x >= margin && x <= tank.width - margin && y >= margin && y <= tank.height - margin;
|
||||
x >= margin && x <= tank.width - margin && y >= range.top && y <= range.bottom;
|
||||
|
||||
// Arrived, or the window shrank and the target is now outside it: drift somewhere new.
|
||||
const dx = targetX - s.x;
|
||||
const dy = targetY - s.y;
|
||||
if (Math.hypot(dx, dy) < Math.max(margin, 24) || !inTank(targetX, targetY)) {
|
||||
const target = randomTarget(tank, margin, rng);
|
||||
const target = randomTarget(tank, margin, rng, band);
|
||||
targetX = target.x;
|
||||
targetY = target.y;
|
||||
}
|
||||
@@ -197,11 +217,14 @@ export function pose(
|
||||
): { x: number; y: number; mirror: number; rotation: number } {
|
||||
const bob = Math.sin((s.age / BOB_PERIOD) * 2 * Math.PI);
|
||||
const isSide = creature.facing === "side";
|
||||
// Something walking hops off the ground and back rather than bobbing through it, and
|
||||
// keeps its nose level - only swimmers and fliers point up a slope they are taking.
|
||||
const walking = creature.motion === "walk";
|
||||
// Nose up when rising, down when sinking - a few degrees, in the direction it faces.
|
||||
const tilt = speed > 0 ? Math.max(-1, Math.min(1, s.vy / speed)) : 0;
|
||||
const tilt = speed > 0 && !walking ? Math.max(-1, Math.min(1, s.vy / speed)) : 0;
|
||||
return {
|
||||
x: s.x,
|
||||
y: s.y + bob * 7,
|
||||
y: walking ? s.y - Math.abs(bob) * 7 : s.y + bob * 7,
|
||||
mirror: isSide ? s.facing : 1,
|
||||
rotation: isSide ? tilt * 10 * Math.sign(s.facing || 1) : bob * 3,
|
||||
};
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
TippenRunResult as ApiRunResult,
|
||||
TippenStroke,
|
||||
} from "../../api/types";
|
||||
import type { CreatureId } from "./aquarium";
|
||||
import type { CreatureId } from "./pets";
|
||||
import type { AnimalId, RunResult } from "./grading";
|
||||
|
||||
export interface LessonProgress {
|
||||
|
||||
@@ -1,32 +1,29 @@
|
||||
/** Feature toggles and the one hue this app is built on.
|
||||
/** Feature toggles for the typing game.
|
||||
*
|
||||
* Same role as ../../web/src/lib/theme.ts: flip a switch here rather than hunting
|
||||
* through components. The hue lives in styles/app.css because CSS is where it is used;
|
||||
* it is repeated here only for the canvas, which cannot read a custom property. */
|
||||
* Same role as ../theme.ts: flip a switch here rather than hunting through components.
|
||||
* Its hue is not here - it belongs to the skin (`tippenHue` in ../skins/), which writes
|
||||
* it to CSS as `--tp-hue`. */
|
||||
|
||||
import { PERF } from "../perfSettings";
|
||||
|
||||
/** The turquoise lagoon. Music is 210, Hörbücher 55, "Mein Zimmer" 300. */
|
||||
export const HUE = 175;
|
||||
|
||||
/** Frost the glass panels with a real backdrop blur. Looks like the first thing to cut
|
||||
* on a weak GPU; on the Pi it measured five times worse with it off, because the blur is
|
||||
* what gives each panel its own compositing layer. Same finding as the music app's
|
||||
* matching toggle - see the table in ../lowPower.ts. */
|
||||
export const SHOW_GLASS_BLUR = true;
|
||||
|
||||
/** The decorative rising bubbles behind everything. A dozen elements on a CSS transform
|
||||
* loop - off under `?pi=1`, like every other loop in the app. */
|
||||
export const SHOW_BUBBLES = PERF.tippenBubbles;
|
||||
/** The decorative drift behind everything - rising bubbles or falling leaves, by skin. A
|
||||
* dozen elements on a CSS transform loop - off under `?pi=1`, like every other loop in the
|
||||
* app. */
|
||||
export const SHOW_DRIFT = PERF.tippenBubbles;
|
||||
|
||||
/** The earned pets swimming behind every screen. The reward that is always in view - off
|
||||
/** The earned pets moving behind every screen. The reward that is always in view - off
|
||||
* only to rule it out when chasing a performance problem.
|
||||
*
|
||||
* Stays on under `?pi=1`, but the pets hold still there: `AquariumCreatures` already has
|
||||
* that exact mode for `prefers-reduced-motion`, where every pet is placed and shown and
|
||||
* simply does not swim. Taking the animation away is the point; taking away what she
|
||||
* earned is not. */
|
||||
export const SHOW_AQUARIUM_CREATURES = true;
|
||||
* Stays on under `?pi=1`, but the pets hold still there: `Pets` already has that exact
|
||||
* mode for `prefers-reduced-motion`, where every pet is placed and shown and simply does
|
||||
* not move. Taking the animation away is the point; taking away what she earned is not. */
|
||||
export const SHOW_PETS = true;
|
||||
|
||||
/** Fade screens in on entry. */
|
||||
export const ANIMATE_VIEW_TRANSITIONS = PERF.tippenViewTransitions;
|
||||
|
||||
@@ -3,11 +3,15 @@ import { createRoot } from "react-dom/client";
|
||||
|
||||
import { App } from "./App";
|
||||
import { PERF } from "./lib/perfSettings";
|
||||
import { applySkin } from "./lib/skins";
|
||||
import "./styles/app.css";
|
||||
import "./styles/mobile.css";
|
||||
import "./styles/room.css";
|
||||
import "./styles/tippen.css";
|
||||
|
||||
// Before the first render, so nothing ever paints in the wrong skin's colours.
|
||||
applySkin();
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
/* 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-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 210 / 0.45);
|
||||
--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));
|
||||
@@ -73,7 +73,7 @@ body {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 3px 8px oklch(15% 0.05 210 / 0.5));
|
||||
filter: drop-shadow(0 3px 8px oklch(15% 0.05 var(--hue) / 0.5));
|
||||
}
|
||||
|
||||
.m-title {
|
||||
@@ -82,14 +82,14 @@ body {
|
||||
font-weight: 900;
|
||||
color: var(--paper);
|
||||
letter-spacing: -0.01em;
|
||||
text-shadow: 0 2px 10px oklch(15% 0.05 210 / 0.45);
|
||||
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 210 / 0.16);
|
||||
background: oklch(97% 0.01 var(--hue) / 0.16);
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
@@ -157,7 +157,7 @@ body {
|
||||
margin: -6px var(--m-gutter) 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: oklch(90% 0.02 210 / 0.75);
|
||||
color: oklch(90% 0.02 var(--hue) / 0.75);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- segmented -- */
|
||||
@@ -167,11 +167,11 @@ body {
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
border-radius: 999px;
|
||||
background: oklch(20% 0.04 210 / 0.38);
|
||||
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 210 / 0.35);
|
||||
box-shadow: inset 0 1px 2px oklch(10% 0.04 var(--hue) / 0.35);
|
||||
}
|
||||
|
||||
.m-segment {
|
||||
@@ -179,7 +179,7 @@ body {
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: oklch(95% 0.01 210 / 0.85);
|
||||
color: oklch(95% 0.01 var(--hue) / 0.85);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
@@ -195,7 +195,7 @@ body {
|
||||
.m-segment[data-active="true"] {
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
box-shadow: 0 3px 10px oklch(10% 0.04 210 / 0.35);
|
||||
box-shadow: 0 3px 10px oklch(10% 0.04 var(--hue) / 0.35);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ grids -- */
|
||||
@@ -243,7 +243,7 @@ body {
|
||||
}
|
||||
|
||||
.m-album-cover {
|
||||
box-shadow: 0 6px 16px oklch(10% 0.04 210 / 0.45);
|
||||
box-shadow: 0 6px 16px oklch(10% 0.04 var(--hue) / 0.45);
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
|
||||
@@ -294,7 +294,7 @@ body {
|
||||
|
||||
.m-song:active,
|
||||
.m-song[data-current="true"] {
|
||||
background: oklch(97% 0.01 210 / 0.14);
|
||||
background: oklch(97% 0.01 var(--hue) / 0.14);
|
||||
}
|
||||
|
||||
.m-song-cover {
|
||||
@@ -323,12 +323,12 @@ body {
|
||||
.m-song-sub {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: oklch(88% 0.02 210 / 0.65);
|
||||
color: oklch(88% 0.02 var(--hue) / 0.65);
|
||||
}
|
||||
|
||||
.m-song-time {
|
||||
font: 700 12px ui-monospace, Menlo, monospace;
|
||||
color: oklch(88% 0.02 210 / 0.6);
|
||||
color: oklch(88% 0.02 var(--hue) / 0.6);
|
||||
}
|
||||
|
||||
.m-hint {
|
||||
@@ -337,7 +337,7 @@ body {
|
||||
padding: 0 var(--m-gutter);
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: oklch(92% 0.02 210 / 0.8);
|
||||
color: oklch(92% 0.02 var(--hue) / 0.8);
|
||||
}
|
||||
|
||||
.m-hint img {
|
||||
@@ -422,14 +422,14 @@ body {
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: oklch(95% 0.01 210 / 0.8);
|
||||
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 210 / 0.2);
|
||||
background: oklch(97% 0.01 var(--hue) / 0.2);
|
||||
color: var(--paper);
|
||||
box-shadow: inset 0 1px 0 oklch(100% 0 0 / 0.2);
|
||||
}
|
||||
@@ -483,7 +483,7 @@ body {
|
||||
}
|
||||
|
||||
.m-search input::placeholder {
|
||||
color: oklch(95% 0.01 210 / 0.55);
|
||||
color: oklch(95% 0.01 var(--hue) / 0.55);
|
||||
}
|
||||
|
||||
.m-search input::-webkit-search-cancel-button {
|
||||
@@ -494,7 +494,7 @@ body {
|
||||
cursor: pointer;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: oklch(95% 0.01 210 / 0.7);
|
||||
color: oklch(95% 0.01 var(--hue) / 0.7);
|
||||
}
|
||||
|
||||
.m-search-clear {
|
||||
@@ -506,7 +506,7 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: oklch(97% 0.01 210 / 0.3);
|
||||
background: oklch(97% 0.01 var(--hue) / 0.3);
|
||||
color: var(--sea-deep);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
@@ -545,7 +545,7 @@ body {
|
||||
height: 44px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
box-shadow: 0 3px 10px oklch(10% 0.04 210 / 0.4);
|
||||
box-shadow: 0 3px 10px oklch(10% 0.04 var(--hue) / 0.4);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -568,7 +568,7 @@ body {
|
||||
.m-mini-sub {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: oklch(90% 0.02 210 / 0.7);
|
||||
color: oklch(90% 0.02 var(--hue) / 0.7);
|
||||
}
|
||||
|
||||
.m-mini-button {
|
||||
@@ -587,7 +587,7 @@ body {
|
||||
}
|
||||
|
||||
.m-mini-button:active {
|
||||
background: oklch(97% 0.01 210 / 0.15);
|
||||
background: oklch(97% 0.01 var(--hue) / 0.15);
|
||||
}
|
||||
|
||||
.m-mini-progress {
|
||||
|
||||
@@ -3,24 +3,25 @@
|
||||
the radius scale and the entrance animations are deliberately identical [to
|
||||
app.css], so the two apps read as siblings. What changes is the hue: the music
|
||||
player's sea is 210, this is 175 - a turquoise lagoon, clearly the same ocean and
|
||||
clearly not the same room.").
|
||||
clearly not the same room."). Both hues now come from the skin (lib/skins/) as
|
||||
`--hue` and `--tp-hue`; 210/175 are the dolphin's.
|
||||
|
||||
Classes here are prefixed `tp-` and colours scoped under `.tp-stage` rather than
|
||||
redefined at `:root`, because app.css already claims the *same* custom property
|
||||
names (--ink, --paper, --accent, --shadow, ...) for the music player's own blue hue -
|
||||
redefining them globally would have silently reskinned one app or the other. Anything
|
||||
below with no hue baked into it - .bubble, .card, .key-cap, .view-enter,
|
||||
.backdrop-enter, and the dolphinBob/bubbleRise/viewEnter/backdropEnter keyframes - is
|
||||
.backdrop-enter, and the mascotBob/bubbleRise/viewEnter/backdropEnter keyframes - is
|
||||
identical between the two apps and reused directly from app.css instead of being
|
||||
duplicated here. */
|
||||
|
||||
.tp-stage {
|
||||
--ink: oklch(30% 0.04 175);
|
||||
--paper: oklch(97% 0.01 175);
|
||||
--accent: oklch(70% 0.16 340);
|
||||
--accent-dim: oklch(78% 0.14 340);
|
||||
--sea-deep: oklch(20% 0.045 175);
|
||||
--shadow: oklch(15% 0.05 175 / 0.35);
|
||||
--ink: oklch(30% 0.04 var(--tp-hue));
|
||||
--paper: oklch(97% 0.01 var(--tp-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(--tp-hue));
|
||||
--shadow: oklch(15% 0.05 var(--tp-hue) / 0.35);
|
||||
/* This app's own additions: the feedback colours. Note there is no red - a wrong key
|
||||
is amber and gentle, never an alarm. */
|
||||
--correct: oklch(80% 0.17 150);
|
||||
@@ -32,8 +33,8 @@
|
||||
overflow: hidden;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
oklch(55% 0.07 175) 0%,
|
||||
oklch(38% 0.06 175) 45%,
|
||||
oklch(55% 0.07 var(--tp-hue)) 0%,
|
||||
oklch(38% 0.06 var(--tp-hue)) 45%,
|
||||
var(--sea-deep) 100%
|
||||
);
|
||||
display: flex;
|
||||
@@ -51,8 +52,8 @@
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* None of these five collide with app.css's own keyframes (bubbleRise, dolphinBob,
|
||||
dolphinSwim, viewEnter, sheetEnter, backdropEnter), so they keep their plain names -
|
||||
/* None of these five collide with app.css's own keyframes (bubbleRise, mascotBob,
|
||||
mascotSwim, viewEnter, sheetEnter, backdropEnter), so they keep their plain names -
|
||||
only classes that would otherwise collide are `tp-`-prefixed. */
|
||||
@keyframes correctPop {
|
||||
0% { transform: scale(1); }
|
||||
@@ -69,8 +70,8 @@
|
||||
}
|
||||
|
||||
@keyframes keyPulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 oklch(97% 0.01 175 / 0.6); }
|
||||
50% { box-shadow: 0 0 0 9px oklch(97% 0.01 175 / 0); }
|
||||
0%, 100% { box-shadow: 0 0 0 0 oklch(97% 0.01 var(--tp-hue) / 0.6); }
|
||||
50% { box-shadow: 0 0 0 9px oklch(97% 0.01 var(--tp-hue) / 0); }
|
||||
}
|
||||
|
||||
@keyframes tierEnter {
|
||||
@@ -93,6 +94,7 @@
|
||||
/* Keep the colour feedback, drop the movement - covers the shared .bubble/.view-enter/
|
||||
.backdrop-enter classes too, which app.css does not otherwise handle. */
|
||||
.bubble,
|
||||
.leaf,
|
||||
.view-enter,
|
||||
.tp-sheet-enter,
|
||||
.backdrop-enter {
|
||||
@@ -101,10 +103,10 @@
|
||||
}
|
||||
|
||||
.tp-glass-panel {
|
||||
background: linear-gradient(160deg, oklch(97% 0.01 175 / 0.14), oklch(97% 0.01 175 / 0.05));
|
||||
background: linear-gradient(160deg, oklch(97% 0.01 var(--tp-hue) / 0.14), oklch(97% 0.01 var(--tp-hue) / 0.05));
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid oklch(97% 0.01 175 / 0.16);
|
||||
border: 1px solid oklch(97% 0.01 var(--tp-hue) / 0.16);
|
||||
border-radius: 22px;
|
||||
box-shadow:
|
||||
0 8px 24px var(--shadow),
|
||||
@@ -126,26 +128,26 @@
|
||||
padding: 9px 15px;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
background: oklch(97% 0.01 175 / 0.16);
|
||||
background: oklch(97% 0.01 var(--tp-hue) / 0.16);
|
||||
color: var(--paper);
|
||||
}
|
||||
|
||||
.tp-pill[data-active="true"] {
|
||||
background: var(--paper);
|
||||
color: oklch(28% 0.04 175);
|
||||
color: oklch(28% 0.04 var(--tp-hue));
|
||||
box-shadow: 0 4px 14px var(--shadow);
|
||||
}
|
||||
|
||||
.tp-sheet {
|
||||
background: var(--paper);
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 20px 50px oklch(10% 0.04 175 / 0.5);
|
||||
box-shadow: 0 20px 50px oklch(10% 0.04 var(--tp-hue) / 0.5);
|
||||
}
|
||||
|
||||
.tp-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: oklch(15% 0.03 175 / 0.55);
|
||||
background: oklch(15% 0.03 var(--tp-hue) / 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -179,8 +181,8 @@
|
||||
font-size: calc(var(--kb-size, 42px) * 0.42);
|
||||
font-weight: 800;
|
||||
color: var(--paper);
|
||||
background: oklch(97% 0.01 175 / 0.1);
|
||||
border: 1px solid oklch(97% 0.01 175 / 0.14);
|
||||
background: oklch(97% 0.01 var(--tp-hue) / 0.1);
|
||||
border: 1px solid oklch(97% 0.01 var(--tp-hue) / 0.14);
|
||||
backdrop-filter: blur(6px);
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
@@ -209,7 +211,7 @@
|
||||
|
||||
.tp-kb-key[data-next="true"] {
|
||||
background: var(--paper);
|
||||
color: oklch(25% 0.05 175);
|
||||
color: oklch(25% 0.05 var(--tp-hue));
|
||||
transform: translateY(-3px) scale(1.08);
|
||||
animation: keyPulse 1.3s ease-out infinite;
|
||||
}
|
||||
@@ -240,7 +242,7 @@
|
||||
they read as the modifier they are, and dim until a capital actually needs one. */
|
||||
.tp-kb-shift {
|
||||
width: calc(var(--kb-size, 42px) * 1.8);
|
||||
background: oklch(97% 0.01 175 / 0.12);
|
||||
background: oklch(97% 0.01 var(--tp-hue) / 0.12);
|
||||
font-size: calc(var(--kb-size, 42px) * 0.4);
|
||||
}
|
||||
|
||||
@@ -273,7 +275,7 @@
|
||||
wider, the whole line would reflow around the cursor as she types, and chunks
|
||||
would visibly jump between lines mid-word. */
|
||||
padding: 0 6px;
|
||||
color: oklch(97% 0.01 175 / 0.45);
|
||||
color: oklch(97% 0.01 var(--tp-hue) / 0.45);
|
||||
transition: color 0.12s ease;
|
||||
}
|
||||
|
||||
@@ -282,7 +284,7 @@
|
||||
}
|
||||
|
||||
.tp-target-char[data-state="current"] {
|
||||
color: oklch(25% 0.05 175);
|
||||
color: oklch(25% 0.05 var(--tp-hue));
|
||||
background: var(--paper);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 6px 18px var(--shadow);
|
||||
@@ -342,19 +344,20 @@
|
||||
overflow: hidden;
|
||||
background: radial-gradient(
|
||||
circle at 50% 42%,
|
||||
oklch(45% 0.07 175 / 0.82) 0%,
|
||||
oklch(16% 0.04 175 / 0.94) 70%
|
||||
oklch(45% 0.07 var(--tp-hue) / 0.82) 0%,
|
||||
oklch(16% 0.04 var(--tp-hue) / 0.94) 70%
|
||||
);
|
||||
animation: backdropEnter 260ms ease-out;
|
||||
/* Thrown right back, out of the column the cover rises through - see the note on
|
||||
.tp-chest-lid. Alone this would look like a lid coming off; with the cover standing
|
||||
.tp-reward-lid. Alone this would look like a lid coming off; with the cover standing
|
||||
in the middle it reads as the lid flung open to make room for it. */
|
||||
--tp-chest-open: -108deg;
|
||||
--tp-reward-open: -108deg;
|
||||
}
|
||||
|
||||
/* The lid's two states. Transitioned rather than keyframed so the map can draw a
|
||||
/* The lid's two states - the chest's lid, or the egg's top shell (DinoEgg.tsx), which
|
||||
hinges the same way. Transitioned rather than keyframed so the map can draw a
|
||||
permanently open chest with no animation at all - see TreasureChest.tsx. */
|
||||
.tp-chest-lid {
|
||||
.tp-reward-lid {
|
||||
transform-box: view-box;
|
||||
transform: rotate(0deg);
|
||||
transition: transform 620ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
@@ -365,12 +368,12 @@
|
||||
attached to its hinge - a lid much past -90deg, alone, reads as a flap flying off.
|
||||
The overlay overrides it above, because there the lid has a cover rising through the
|
||||
middle of the chest and has to clear out of its way. */
|
||||
.tp-chest-lid[data-open] {
|
||||
transform: rotate(var(--tp-chest-open, -62deg));
|
||||
.tp-reward-lid[data-open] {
|
||||
transform: rotate(var(--tp-reward-open, -62deg));
|
||||
}
|
||||
|
||||
/* The chest arriving: dropped in, with a squash on landing. */
|
||||
@keyframes chestDrop {
|
||||
@keyframes rewardDrop {
|
||||
0% { opacity: 0; transform: translateY(-120px) scale(0.7); }
|
||||
55% { opacity: 1; transform: translateY(0) scale(1.06, 0.9); }
|
||||
75% { transform: translateY(-6px) scale(0.98, 1.04); }
|
||||
@@ -380,7 +383,7 @@
|
||||
/* The wait before the lid goes: the chest rattling, because something inside wants out.
|
||||
This is the beat that earns the opening - a lid that opens the instant the chest lands
|
||||
is just a transition, not a reveal. */
|
||||
@keyframes chestRattle {
|
||||
@keyframes rewardRattle {
|
||||
0%, 100% { transform: translateX(0) rotate(0deg); }
|
||||
15% { transform: translateX(-3px) rotate(-2.5deg); }
|
||||
30% { transform: translateX(3px) rotate(2.5deg); }
|
||||
@@ -447,7 +450,7 @@
|
||||
.tp-reward-overlay * {
|
||||
animation: none !important;
|
||||
}
|
||||
.tp-chest-lid {
|
||||
.tp-reward-lid {
|
||||
transition: none;
|
||||
}
|
||||
.tp-confetti-piece {
|
||||
|
||||
Reference in New Issue
Block a user