Compare commits
2 Commits
6981890be0
...
2a629faf50
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a629faf50 | |||
| 21d65cad9e |
1
python-backend/.gitignore
vendored
1
python-backend/.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
config.yml
|
||||
tippen-curriculum.yml
|
||||
tippen-curriculum-dino.yml
|
||||
tippen-progress.json
|
||||
/.musicmouse-cache
|
||||
|
||||
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
|
||||
@@ -446,6 +447,7 @@ export function App() {
|
||||
return (
|
||||
<div
|
||||
className="stage"
|
||||
data-phone={phone || undefined}
|
||||
data-blur={SHOW_GLASS_BLUR ? undefined : "off"}
|
||||
data-cardblur={SHOW_CARD_BLUR ? undefined : "off"}
|
||||
>
|
||||
@@ -556,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}
|
||||
@@ -653,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,
|
||||
}}
|
||||
@@ -679,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,
|
||||
}}
|
||||
@@ -733,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)",
|
||||
@@ -761,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,
|
||||
}}
|
||||
@@ -775,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,14 +5,30 @@
|
||||
/* 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));
|
||||
}
|
||||
|
||||
/* iOS can leave the layout viewport shorter than the screen - after the keyboard has
|
||||
been up, `100dvh` in a home-screen app comes back one status bar short - and the
|
||||
page behind the stage showed as a white strip along the bottom. Pinning the stage to
|
||||
the viewport's edges does not depend on that height at all, and the sea colour
|
||||
underneath covers whatever might still peek through. */
|
||||
html,
|
||||
body {
|
||||
background: var(--stage-deep, #001b21);
|
||||
}
|
||||
|
||||
.stage[data-phone] {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.m-glass {
|
||||
background: var(--m-glass-fill);
|
||||
backdrop-filter: blur(14px) saturate(1.7);
|
||||
@@ -39,7 +55,8 @@
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: env(safe-area-inset-top) 0 calc(var(--m-dock-clearance) + 84px);
|
||||
/* No top inset here: the sticky bar reaches up under the status bar itself. */
|
||||
padding: 0 0 calc(var(--m-dock-clearance) + 84px);
|
||||
animation: viewEnter 180ms ease-out;
|
||||
}
|
||||
|
||||
@@ -49,14 +66,14 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px var(--m-gutter) 4px;
|
||||
padding: calc(14px + env(safe-area-inset-top)) var(--m-gutter) 4px;
|
||||
}
|
||||
|
||||
.m-title-mascot {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 3px 8px oklch(15% 0.05 210 / 0.5));
|
||||
filter: drop-shadow(0 3px 8px oklch(15% 0.05 var(--hue) / 0.5));
|
||||
}
|
||||
|
||||
.m-title {
|
||||
@@ -65,14 +82,14 @@
|
||||
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;
|
||||
@@ -82,21 +99,45 @@
|
||||
color: oklch(80% 0.16 30);
|
||||
}
|
||||
|
||||
/* The bar that stays put while the page scrolls under it. Fades from the top of the
|
||||
sea into transparency rather than ending in a hard edge. */
|
||||
/* The bar that stays put while the page scrolls under it. Sticks to the very top of
|
||||
the screen, under the status bar, so content never shows behind the clock.
|
||||
|
||||
Frosted rather than painted: the background behind it is the ambience canvas,
|
||||
which takes its colour from whatever is playing, so any fixed fill was the wrong
|
||||
colour as soon as an album with different art started. A blur with a neutral
|
||||
darkening suits every one of them, and the mask fades it out instead of ending in
|
||||
a hard edge. */
|
||||
.m-sticky {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
padding: 10px var(--m-gutter) 14px;
|
||||
background: linear-gradient(180deg, #2f6f7aee 0%, #2f6f7a99 65%, #2f6f7a00 100%);
|
||||
padding: calc(10px + env(safe-area-inset-top)) var(--m-gutter) 14px;
|
||||
}
|
||||
|
||||
/* Under the title row, overlap the row by the status bar's height: the bar keeps its
|
||||
place in the layout but already covers the status bar once it sticks. */
|
||||
.m-title-row + .m-sticky {
|
||||
margin-top: calc(-1 * env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.m-sticky::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background: oklch(0% 0 0 / 0.16);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
-webkit-mask-image: linear-gradient(180deg, #000 70%, transparent);
|
||||
mask-image: linear-gradient(180deg, #000 70%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.m-navbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-top: 12px;
|
||||
padding-top: calc(12px + env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.m-navbar-title {
|
||||
@@ -116,7 +157,7 @@
|
||||
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 -- */
|
||||
@@ -126,11 +167,11 @@
|
||||
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 {
|
||||
@@ -138,7 +179,7 @@
|
||||
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;
|
||||
@@ -154,7 +195,7 @@
|
||||
.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 -- */
|
||||
@@ -202,7 +243,7 @@
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -253,7 +294,7 @@
|
||||
|
||||
.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 {
|
||||
@@ -282,12 +323,12 @@
|
||||
.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 {
|
||||
@@ -296,7 +337,7 @@
|
||||
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 {
|
||||
@@ -381,14 +422,14 @@
|
||||
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);
|
||||
}
|
||||
@@ -442,7 +483,7 @@
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -453,7 +494,7 @@
|
||||
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 {
|
||||
@@ -465,7 +506,7 @@
|
||||
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;
|
||||
@@ -504,7 +545,7 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -527,7 +568,7 @@
|
||||
.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 {
|
||||
@@ -546,7 +587,7 @@
|
||||
}
|
||||
|
||||
.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