Downscale cached cover art, and stop every animation loop under ?pi=1

Covers. Art out of an ID3 APIC frame is sized for a record sleeve: this library
averaged 3000x3000 and 580 kB per cover, 140 MB across 284 albums. The browser was
decoding nine megapixels - around 36 MB of bitmap - for every cover it painted, to show
it in a 185 px card, on a Pi with 2 GB of RAM. The largest any screen in this app asks
for is 340 px (the play view), so cache.store_cover now downscales to a 640 px long
edge, which leaves room for a tablet at devicePixelRatio 2 and cuts the decode about
twentyfold. Pillow was already a hard dependency, for colour extraction.

scan_library reuses an album whose fingerprint is unchanged without re-reading its
tags, so covers already on disk would never be rewritten - hence shrink_stored_covers(),
a pass at the top of a scan. Reading a JPEG's dimensions only parses its header, so
after the first run it costs one small read per album. Art already small enough is
returned byte-identical rather than re-encoded, so repeated scans cannot slowly grind
it down, and anything Pillow cannot read is passed through untouched: a cover that is
too big is a performance problem, a cover that is missing is a visible one.

Animation. Halving the ambient canvas to a quarter of the pixels at 30fps took it from
53.5% of a core to 25%, and 25% was still not good enough to use. A requestAnimationFrame
loop repainting the viewport is a floor you cannot get under while it runs at all, so
?pi=1 now switches it off outright rather than thinning it, along with the decorative
CSS loops, the view transitions, the typing game's bubbles and its next-key pulse. The
pets stay on screen but hold still, through the same path prefers-reduced-motion already
took - taking the animation away is the point, taking away what she earned is not.
.stage keeps its own static gradient, so there is still a sea behind everything.

Both blurs on the panels stay on. Dropping those measured five times worse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 15:44:02 +02:00
parent 3cdad1e714
commit 0ef5a04cb9
10 changed files with 245 additions and 49 deletions

View File

@@ -5,7 +5,8 @@ different amounts to produce::
<cache_dir>/ <cache_dir>/
├── index.json cheap: tags and structure. Thrown away freely. ├── index.json cheap: tags and structure. Thrown away freely.
├── covers/<album_id>.jpg medium: art pulled out of an ID3 APIC frame ├── covers/<album_id>.jpg medium: art pulled out of an ID3 APIC frame,
│ downscaled to MAX_COVER_PX on the way in
└── analysis/<track_key>.json expensive: minutes of DSP per track └── analysis/<track_key>.json expensive: minutes of DSP per track
analysis/<track_key>.beats.json analysis/<track_key>.beats.json
analysis/<track_key>.curve.json analysis/<track_key>.curve.json
@@ -18,12 +19,15 @@ folder or re-sorting a section then costs nothing.
from __future__ import annotations from __future__ import annotations
import io
import json import json
import logging import logging
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, cast from typing import Any, Final, cast
from PIL import Image, UnidentifiedImageError
from musicmouse.library.analysis import BeatGrid, TrackAnalysis, TrackCurves from musicmouse.library.analysis import BeatGrid, TrackAnalysis, TrackCurves
from musicmouse.library.models import Album, LibraryTrack from musicmouse.library.models import Album, LibraryTrack
@@ -86,9 +90,37 @@ class LibraryCache:
def store_cover(self, album_id: str, data: bytes) -> Path: def store_cover(self, album_id: str, data: bytes) -> Path:
path = self.cover_path(album_id) path = self.cover_path(album_id)
path.write_bytes(data) path.write_bytes(shrink_cover(data))
return path return path
def shrink_stored_covers(self) -> int:
"""Rewrite any already-stored cover that predates :data:`MAX_COVER_PX`.
Needed because :func:`~musicmouse.library.scanner.scan_library` reuses an album
whose fingerprint is unchanged *without* re-reading its tags, so a cover written
by an older version would otherwise never be touched again. Reading a JPEG's
dimensions only parses its header, so once every file is within the limit this
costs one small read per album and nothing else.
Returns the number of files actually rewritten.
"""
rewritten = 0
for path in sorted(self.covers.glob("*.jpg")):
try:
with Image.open(path) as image:
oversized = max(image.size) > MAX_COVER_PX
except (OSError, UnidentifiedImageError):
continue
if not oversized:
continue
try:
shrunk = shrink_cover(path.read_bytes())
path.write_bytes(shrunk)
except OSError: # pragma: no cover - a cache we cannot write is not fatal
continue
rewritten += 1
return rewritten
# ------------------------------------------------------------------ analysis # ------------------------------------------------------------------ analysis
def load_analysis(self, key: str) -> TrackAnalysis | None: def load_analysis(self, key: str) -> TrackAnalysis | None:
@@ -213,3 +245,44 @@ def _album_from_json(data: dict[str, Any]) -> Album:
for track in data["tracks"] for track in data["tracks"]
), ),
) )
# ---------------------------------------------------------------------------- covers
#: Longest edge kept for cached album art, in pixels.
#:
#: The art that comes out of an ID3 APIC frame is sized for a record sleeve, not for a
#: screen: a real library here averaged 3000x3000 and 580 kB per cover, 140 MB for 284
#: albums. The browser was decoding nine megapixels - about 36 MB of bitmap - for every
#: cover it painted, to show it in a 185 px card on a Pi with 2 GB of RAM.
#:
#: 340 is the largest any of this app's screens asks for (the play view; the browse grid
#: asks for 180 and the player bar for 56), so 640 still leaves room for a tablet at
#: devicePixelRatio 2 and cuts the decode by about twenty times.
MAX_COVER_PX: Final = 640
#: Re-encode quality. At these dimensions the difference from 95 is invisible and the
#: file is a third of the size.
_COVER_JPEG_QUALITY: Final = 85
def shrink_cover(data: bytes) -> bytes:
"""Downscale cover art to :data:`MAX_COVER_PX` on its longest edge.
Art that is already small enough is returned untouched rather than re-encoded, so
repeated scans never degrade it. Anything Pillow cannot read is passed through
unchanged: a cover that is too big is a performance problem, a cover that is missing
is a visible one.
"""
try:
with Image.open(io.BytesIO(data)) as image:
if max(image.size) <= MAX_COVER_PX:
return data
# `thumbnail` keeps the aspect ratio and never scales up.
image = image.convert("RGB")
image.thumbnail((MAX_COVER_PX, MAX_COVER_PX), Image.Resampling.LANCZOS)
buffer = io.BytesIO()
image.save(buffer, format="JPEG", quality=_COVER_JPEG_QUALITY, optimize=True)
return buffer.getvalue()
except (OSError, UnidentifiedImageError, ValueError):
return data

View File

@@ -295,6 +295,13 @@ def scan_library(
figure name to what it holds, which is the one thing the folders cannot say. figure name to what it holds, which is the one thing the folders cannot say.
""" """
cache.prepare() cache.prepare()
# Covers written before MAX_COVER_PX existed are still whatever size the tag held,
# and the reuse path below means an unchanged album never rewrites its own. One
# pass here catches them; after the first run every file is already small and this
# is 300-odd header reads.
shrunk = cache.shrink_stored_covers()
if shrunk:
_log.info("Downscaled %d oversized cover(s) in the cache", shrunk)
known = known or {} known = known or {}
out: dict[str, tuple[Album, Fingerprint]] = {} out: dict[str, tuple[Album, Fingerprint]] = {}
last_report = time.monotonic() last_report = time.monotonic()

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
import contextlib import contextlib
import io
import json import json
import os import os
import threading import threading
@@ -726,3 +727,76 @@ async def test_a_parallel_pass_is_cancellable_mid_flight(config_dir: Path) -> No
def test_the_default_worker_count_leaves_a_core_for_everything_else() -> None: def test_the_default_worker_count_leaves_a_core_for_everything_else() -> None:
count = default_worker_count() count = default_worker_count()
assert 1 <= count <= max(1, (os.cpu_count() or 1) - 1) assert 1 <= count <= max(1, (os.cpu_count() or 1) - 1)
class TestCoverDownscaling:
"""Album art arrives sized for a record sleeve; screens want a fraction of that."""
@staticmethod
def _jpeg(size: tuple[int, int]) -> bytes:
from PIL import Image
buffer = io.BytesIO()
Image.new("RGB", size, (120, 40, 200)).save(buffer, format="JPEG")
return buffer.getvalue()
def test_oversized_art_is_shrunk_to_the_long_edge(self) -> None:
from PIL import Image
from musicmouse.library.cache import MAX_COVER_PX, shrink_cover
original = self._jpeg((3000, 2000))
shrunk = shrink_cover(original)
with Image.open(io.BytesIO(shrunk)) as image:
width, height = image.size
assert max(width, height) == MAX_COVER_PX
# Aspect ratio survives, to within the rounding of a whole pixel.
assert abs(width / height - 3000 / 2000) < 0.01
assert len(shrunk) < len(original)
def test_art_already_small_enough_is_returned_untouched(self) -> None:
"""Byte-identical, not merely similar: repeated scans must not re-encode art
over and over, each pass losing a little more to JPEG."""
from musicmouse.library.cache import shrink_cover
original = self._jpeg((300, 300))
assert shrink_cover(original) is original
def test_unreadable_art_is_passed_through_rather_than_dropped(self) -> None:
"""A cover too big is a performance problem; a cover missing is a visible one."""
from musicmouse.library.cache import shrink_cover
assert shrink_cover(b"not an image at all") == b"not an image at all"
def test_store_cover_shrinks_on_the_way_in(self, tmp_path: Path) -> None:
from PIL import Image
from musicmouse.library.cache import MAX_COVER_PX, LibraryCache
cache = LibraryCache(tmp_path)
cache.prepare()
path = cache.store_cover("abc123", self._jpeg((2400, 2400)))
with Image.open(path) as image:
assert max(image.size) == MAX_COVER_PX
def test_covers_written_by_an_older_version_are_migrated(self, tmp_path: Path) -> None:
"""The scanner reuses an unchanged album without re-reading its tags, so a
cover stored before the limit existed would otherwise never be rewritten."""
from PIL import Image
from musicmouse.library.cache import MAX_COVER_PX, LibraryCache
cache = LibraryCache(tmp_path)
cache.prepare()
stale = cache.cover_path("old")
stale.write_bytes(self._jpeg((3000, 3000)))
fresh = cache.cover_path("new")
fresh.write_bytes(self._jpeg((320, 320)))
fresh_before = fresh.read_bytes()
assert cache.shrink_stored_covers() == 1
with Image.open(stale) as image:
assert max(image.size) == MAX_COVER_PX
assert fresh.read_bytes() == fresh_before
# Second pass has nothing left to do - the cheap steady state.
assert cache.shrink_stored_covers() == 0

View File

@@ -95,34 +95,35 @@ seeking is the one thing a real player can do that the mockup could not.
## The Raspberry Pi profile ## The Raspberry Pi profile
`?pi=1` loads the same app with the ambient canvas painting a quarter of the pixels. It `?pi=1` loads the same app with nothing on screen animating itself. It exists for
exists for musicdolphin - a Pi 4 driving a 1920x1080 kiosk screen - where Firefox musicdolphin - a Pi 4 driving a 1920x1080 kiosk screen.
rasterizes 2D canvas in the content process, and the canvas repaints the whole screen
every frame whether or not anything is playing.
Everything in `src/lib/lowPower.ts` was measured on the device. Sum of the Firefox | | Full | `?pi=1` |
process tree, idle on the browse screen, 15 s average: |---|---|---|
| Ambient canvas (gradient + bubbles) | on | **not rendered** |
| Decorative CSS loops, view transitions | on | off |
| Typing game bubbles, next-key pulse | on | off |
| Typing game pets | swimming | placed, held still |
| `backdrop-filter` on cards and list rows | on | off |
| `backdrop-filter` on panels | on | **on** |
| Progress-bar re-renders | every animation frame | 10 per second |
| | idle CPU | Two of those rows are worth knowing the reasons for, because both are the opposite of
|---|---| what they look like.
| full app | 53.5% |
| + ambient canvas at half resolution | **25.0%** |
| + 30 fps cap on top of that | 25.0% |
| + decorative CSS animation loops off | 24.6% |
| + `backdrop-filter` glass blur off | **118.1%** |
Two things to take from that table. The canvas resolution is the whole win, and **The panel blur stays on.** Dropping it measured *five times worse* on the device -
**turning off the backdrop blur makes it five times worse** - the blur is what promotes 118% of a core against 25%. `backdrop-filter` is what promotes each glass panel to its
each glass panel to its own compositing layer, so a canvas frame underneath repaints own compositing layer; without it the panels and everything under them collapse into
only the canvas. Without it the canvas and the album grid above it share one layer and one layer that repaints wholesale. The card blur goes because that is a different
every frame repaints all of it. The most expensive-looking CSS in the app is what keeps problem: three hundred of them in one grid, not one per panel.
the rest of it cheap; `SHOW_GLASS_BLUR` stays on everywhere, and so do the decorative
loops, which measured as noise.
So the profile is one real change plus three caps that only bite while something is **The canvas goes away rather than getting cheaper.** Half resolution and 30 fps took
playing, and are therefore not in the table: 30 fps on the canvas, 60 bubbles alive at it from 53.5% of a core to 25.0%, and 25% was still not good enough to use. A
once, and ten progress-bar re-renders a second instead of sixty (`usePlaybackClock` `requestAnimationFrame` loop repainting the viewport is a floor you cannot get under
pushes a React `setState` per animation frame, into both `PlayView` and `PlayerBar`). while it runs at all. `.stage` keeps its own static CSS gradient, so there is still a
sea behind everything - it just no longer moves or follows the track.
`AMBIENCE_QUALITY` in `src/lib/lowPower.ts` is kept accurate for whoever turns the
canvas back on.
The flag is read once at module load from the URL and nowhere else - no persistence, no The flag is read once at module load from the URL and nowhere else - no persistence, no
auto-detection. That is what makes "is this the profile or the hardware?" answerable by auto-detection. That is what makes "is this the profile or the hardware?" answerable by

View File

@@ -14,6 +14,7 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { LOW_POWER } from "../../lib/lowPower";
import { creatureById, createSwimmer, pose, stepSwimmer } from "../../lib/tippen/aquarium"; import { creatureById, createSwimmer, pose, stepSwimmer } from "../../lib/tippen/aquarium";
import type { CreatureId, Swimmer } from "../../lib/tippen/aquarium"; import type { CreatureId, Swimmer } from "../../lib/tippen/aquarium";
@@ -37,8 +38,11 @@ export function AquariumCreatures({ creatures, opacity }: Props) {
const present = useRef<ReadonlySet<CreatureId>>(new Set(creatures)); const present = useRef<ReadonlySet<CreatureId>>(new Set(creatures));
useEffect(() => { useEffect(() => {
// Reduced motion: every pet is still placed and shown, it just holds still. // Reduced motion: every pet is still placed and shown, it just holds still. `?pi=1`
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; // takes the same door: on that device the point is that nothing runs a frame loop,
// and the pets are the reward - they should be *there*, they just need not swim.
const reducedMotion =
LOW_POWER || window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let frame = 0; let frame = 0;
let lastTime = performance.now(); let lastTime = performance.now();

View File

@@ -1,5 +1,7 @@
/** The gradient stage every screen sits on - the app's one full-height container. /** The gradient stage every screen sits on - the app's one full-height container.
* `data-blur` is read by app.css to drop the backdrop filters wholesale. * `data-blur` is read by app.css to drop the backdrop filters wholesale, and `data-anim`
* 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 swim here rather than on the home screen, so they stay with her on the lesson
* map and during a run too. */ * map and during a run too. */
@@ -7,6 +9,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { CreatureId } from "../../lib/tippen/aquarium"; import type { CreatureId } from "../../lib/tippen/aquarium";
import { LOW_POWER } from "../../lib/lowPower";
import { SHOW_AQUARIUM_CREATURES, SHOW_BUBBLES, SHOW_GLASS_BLUR } from "../../lib/tippen/theme"; import { SHOW_AQUARIUM_CREATURES, SHOW_BUBBLES, SHOW_GLASS_BLUR } from "../../lib/tippen/theme";
import { AquariumCreatures } from "./AquariumCreatures"; import { AquariumCreatures } from "./AquariumCreatures";
import { Bubbles } from "./Bubbles"; import { Bubbles } from "./Bubbles";
@@ -21,7 +24,11 @@ interface Props {
export function Stage({ children, creatures, dimmed }: Props) { export function Stage({ children, creatures, dimmed }: Props) {
return ( return (
<div className="tp-stage" data-blur={SHOW_GLASS_BLUR ? "on" : "off"}> <div
className="tp-stage"
data-blur={SHOW_GLASS_BLUR ? "on" : "off"}
data-anim={LOW_POWER ? "off" : "on"}
>
{SHOW_AQUARIUM_CREATURES && <AquariumCreatures creatures={creatures} opacity={dimmed ? 0.25 : 1} />} {SHOW_AQUARIUM_CREATURES && <AquariumCreatures creatures={creatures} opacity={dimmed ? 0.25 : 1} />}
{SHOW_BUBBLES && <Bubbles />} {SHOW_BUBBLES && <Bubbles />}
{children} {children}

View File

@@ -24,9 +24,18 @@
* grid included. The most expensive-looking CSS in the app is the thing keeping the * grid included. The most expensive-looking CSS in the app is the thing keeping the
* rest of it cheap. Do not "optimize" it away without re-running the numbers above. * rest of it cheap. Do not "optimize" it away without re-running the numbers above.
* *
* So the profile is one real change - paint a quarter of the pixels - plus three caps * That got it from 53.5% to 25%, and 25% was still not enough to use. The profile now
* that only bite while something is playing (bubbles spawn, curves are sampled per * goes further and stops the animation instead of thinning it: under `?pi=1` the
* frame, the progress bar re-renders) and that are therefore not in the table above. * ambient canvas is not rendered at all (`SHOW_AMBIENCE`), no bubbles rise anywhere,
* the decorative CSS loops are off, the view transitions are off, and the typing game's
* pets are placed but held still. A requestAnimationFrame loop that repaints the whole
* viewport is a floor you cannot get under while it runs at all, and on a Pi 4 that
* floor is too high. With it gone the browser has nothing to do between one keypress
* and the next. `.stage` keeps its own static CSS gradient, so the screen still has a
* sea behind it - it just no longer moves or follows the track.
*
* What stays: both blurs on the panels (see the table - dropping those was five times
* worse), and the card blur off, which is a count problem rather than a blur problem.
* *
* Read once at module load, from the URL and nowhere else: no persistence, no * Read once at module load, from the URL and nowhere else: no persistence, no
* auto-detection. The kiosk points Firefox at `http://localhost:8080/?pi=1` (see the * auto-detection. The kiosk points Firefox at `http://localhost:8080/?pi=1` (see the
@@ -67,6 +76,10 @@ export interface AmbienceQuality {
maxBubbles: number; maxBubbles: number;
} }
/** Unreachable under `?pi=1` as things stand, because `SHOW_AMBIENCE` switches the
* canvas off there entirely. Kept, and kept accurate, because it is the setting that
* matters the moment anyone turns the canvas back on for a weak device - the half-scale
* backing store is what took it from 53.5% to 25%. */
export const AMBIENCE_QUALITY: AmbienceQuality = LOW_POWER export const AMBIENCE_QUALITY: AmbienceQuality = LOW_POWER
? { resolutionScale: 0.5, maxBackingStorePx: 1280, maxFps: 30, maxBubbles: 60 } ? { resolutionScale: 0.5, maxBackingStorePx: 1280, maxFps: 30, maxBubbles: 60 }
: { resolutionScale: 1, maxBackingStorePx: 4096, maxFps: 0, maxBubbles: 0 }; : { resolutionScale: 1, maxBackingStorePx: 4096, maxFps: 0, maxBubbles: 0 };

View File

@@ -33,7 +33,7 @@ export const ROW_SPACING = 25;
/** Fade the album/category grid in (with a slight upward slide) when a group or /** Fade the album/category grid in (with a slight upward slide) when a group or
* category is entered, and the album modal in when it opens. One animation per * category is entered, and the album modal in when it opens. One animation per
* container, not per card, so cost stays flat regardless of grid size. */ * container, not per card, so cost stays flat regardless of grid size. */
export const ANIMATE_VIEW_TRANSITIONS = true; export const ANIMATE_VIEW_TRANSITIONS = !LOW_POWER;
/** Frost the glass panels/cards/rows with a real backdrop blur. /** Frost the glass panels/cards/rows with a real backdrop blur.
* *
@@ -45,13 +45,16 @@ export const ANIMATE_VIEW_TRANSITIONS = true;
export const SHOW_GLASS_BLUR = true; export const SHOW_GLASS_BLUR = true;
/** Render the play view's animated canvas background (gradient + bubbles, both /** Render the play view's animated canvas background (gradient + bubbles, both
* redrawn every frame at 60fps). Off falls back to `.stage`'s own static CSS * redrawn every frame). Off falls back to `.stage`'s own static CSS gradient, which is
* gradient, which is still underneath the canvas either way. * still underneath the canvas either way - so the screen keeps a background, it just
* stops being a per-track one and stops moving.
* *
* Deliberately still on under `?pi=1`: it is the app's whole look, and * Off under `?pi=1`. Halving its resolution and its frame rate first (see
* `AMBIENCE_QUALITY` in lib/lowPower.ts makes it affordable (quarter of the pixels, * `AMBIENCE_QUALITY`) was not enough on the device this exists for: a requestAnimationFrame
* half the frames) rather than making it go away. */ * loop that repaints the full viewport is a floor you cannot get under while it runs at
export const SHOW_AMBIENCE = true; * all, and on a Pi 4 that floor is too high. Nothing else in the app needs a frame loop,
* so with this off the browser has nothing to do between one keypress and the next. */
export const SHOW_AMBIENCE = !LOW_POWER;
/** Frost the *repeated* glass surfaces - every album card in the grid, every row in a /** Frost the *repeated* glass surfaces - every album card in the grid, every row in a
* track list - as opposed to the handful of panels wrapped around them. * track list - as opposed to the handful of panels wrapped around them.
@@ -65,10 +68,11 @@ export const SHOW_AMBIENCE = true;
export const SHOW_CARD_BLUR = !LOW_POWER; export const SHOW_CARD_BLUR = !LOW_POWER;
/** Run the purely decorative CSS animation loops: the room page's bubble field and /** Run the purely decorative CSS animation loops: the room page's bubble field and
* the dolphin mascot's bob/swim. Left on even under `?pi=1`: switching them off on the * the dolphin mascot's bob/swim. Off under `?pi=1`. On their own they measured as
* Pi moved the idle figure by 0.4 percentage points, which is noise, and they are the * noise, but "nothing on this screen moves by itself" is a property worth having
* kind of detail this app is for. */ * outright rather than a sum of small wins - a compositor with no animation to service
export const SHOW_DECORATIVE_ANIMATIONS = true; * has nothing to wake up for. */
export const SHOW_DECORATIVE_ANIMATIONS = !LOW_POWER;
// ------------------------------------------------------------------ colors -- // ------------------------------------------------------------------ colors --

View File

@@ -4,6 +4,8 @@
* through components. The hue lives in styles/app.css because CSS is where it is used; * 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. */ * it is repeated here only for the canvas, which cannot read a custom property. */
import { LOW_POWER } from "../lowPower";
/** The turquoise lagoon. Music is 210, Hörbücher 55, "Mein Zimmer" 300. */ /** The turquoise lagoon. Music is 210, Hörbücher 55, "Mein Zimmer" 300. */
export const HUE = 175; export const HUE = 175;
@@ -14,15 +16,20 @@ export const HUE = 175;
export const SHOW_GLASS_BLUR = true; export const SHOW_GLASS_BLUR = true;
/** The decorative rising bubbles behind everything. A dozen elements on a CSS transform /** The decorative rising bubbles behind everything. A dozen elements on a CSS transform
* loop; measured as noise on the Pi, so they stay. */ * loop - off under `?pi=1`, like every other loop in the app. */
export const SHOW_BUBBLES = true; export const SHOW_BUBBLES = !LOW_POWER;
/** The earned pets swimming behind every screen. The reward that is always in view - off /** The earned pets swimming behind every screen. The reward that is always in view - off
* only to rule it out when chasing a performance problem. */ * 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; export const SHOW_AQUARIUM_CREATURES = true;
/** Fade screens in on entry. */ /** Fade screens in on entry. */
export const ANIMATE_VIEW_TRANSITIONS = true; export const ANIMATE_VIEW_TRANSITIONS = !LOW_POWER;
/** Show the on-screen keyboard with the finger colours. "auto" fades it out key by key /** Show the on-screen keyboard with the finger colours. "auto" fades it out key by key
* as each one is mastered - the scaffold that removes itself, which is the whole point * as each one is mastered - the scaffold that removes itself, which is the whole point

View File

@@ -214,6 +214,12 @@
animation: keyPulse 1.3s ease-out infinite; animation: keyPulse 1.3s ease-out infinite;
} }
/* `?pi=1` (`data-anim="off"`, set in Stage.tsx): the key still lifts and lightens, it
just stops breathing. The last animation loop left on that device. */
.tp-stage[data-anim="off"] .tp-kb-key[data-next="true"] {
animation: none;
}
.tp-kb-key[data-home="true"]::after { .tp-kb-key[data-home="true"]::after {
/* The tactile bump on F and J, drawn so it can be pointed at on screen too. */ /* The tactile bump on F and J, drawn so it can be pointed at on screen too. */
content: ""; content: "";