Web frontend: perf panel, cover warmup, incremental browse rendering

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 11:42:33 +02:00
parent 9fd106b757
commit 2db4368fc9
24 changed files with 1178 additions and 255 deletions

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import type { Album } from "../../api/types";
import { handleKey, initialUiState, selectionAlbumAt, selectionAt, type UiState } from "../keyboard";
import { handleKey, initialUiState, isRootShelf, selectionAlbumAt, selectionAt, type UiState } from "../keyboard";
import { buildSearchIndex, normalize, results as computeResults, type Results } from "../search";
function album(id: string, over: Partial<Album> = {}): Album {
@@ -291,7 +291,7 @@ describe("keyboard", () => {
const searching = { ...inPlay, view: "browse" as const };
expect(press("Escape", searching)).toEqual([
{ type: "ui", patch: { search: "", selIndex: 0 } },
{ type: "ui", patch: { search: "", searchOpen: false, selIndex: 0 } },
]);
const inTracks = { ...searching, search: "" };
@@ -471,4 +471,33 @@ describe("selectionAt", () => {
const ui: UiState = { ...initialUiState, search: "zzzznothing" };
expect(selectionAt(resultsFor(ui), 0)).toBeNull();
});
it("opens the search box on / and closes it with ESC before anything else", () => {
expect(press("/")).toContainEqual({
type: "ui",
patch: { view: "browse", searchOpen: true },
});
const open = { ...initialUiState, searchOpen: true };
expect(press("Escape", open)).toEqual([
{ type: "ui", patch: { search: "", searchOpen: false, selIndex: 0 } },
]);
});
it("drops the root shelves as soon as the search box is up", () => {
expect(isRootShelf(initialUiState)).toBe(true);
expect(isRootShelf({ ...initialUiState, search: "a" })).toBe(false);
expect(isRootShelf({ ...initialUiState, searchOpen: true })).toBe(false);
});
it("flips between album and title search with / while the box is up", () => {
const open = { ...initialUiState, searchOpen: true, search: "ab" };
expect(press("/", open)).toContainEqual({
type: "ui",
patch: { mode: "tracks", searchOpen: true, selIndex: 0 },
});
expect(press("/", { ...open, mode: "tracks" })).toContainEqual({
type: "ui",
patch: { mode: "albums", searchOpen: true, selIndex: 0 },
});
});
});

View File

@@ -0,0 +1,55 @@
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("../perfSettings");
}
describe("perfSettings", () => {
beforeEach(() => store.clear());
afterEach(() => vi.unstubAllGlobals());
it("is full-fat without pi, ignoring stored values", async () => {
store.set("musicmouse.perf.v1", JSON.stringify({ ambience: false }));
const m = await load("");
expect(m.PERF_ENABLED).toBe(false);
expect(m.PERF).toEqual(m.FULL);
});
it("uses the pi preset under ?pi=1", async () => {
const m = await load("?pi=1");
expect(m.PERF).toEqual(m.PI);
});
it("merges stored overrides over the preset", async () => {
store.set("musicmouse.perf.v1", JSON.stringify({ ambience: true, playbackClockFps: 30 }));
const m = await load("?pi=1");
expect(m.PERF.ambience).toBe(true);
expect(m.PERF.playbackClockFps).toBe(30);
expect(m.PERF.cardBlur).toBe(m.PI.cardBlur);
});
it("drops wrong-typed fields and survives corrupt JSON", async () => {
store.set("musicmouse.perf.v1", JSON.stringify({ ambience: "yes", ambienceScale: null }));
expect((await load("?pi=1")).PERF).toEqual((await load("?pi=1")).PI);
store.set("musicmouse.perf.v1", "{nope");
const m = await load("?pi=1");
expect(m.PERF).toEqual(m.PI);
});
it("round-trips through save and reset", async () => {
const m = await load("?pi=1");
m.savePerfSettings({ ...m.PI, cardBlur: true });
expect((await load("?pi=1")).PERF.cardBlur).toBe(true);
m.resetPerfSettings();
expect((await load("?pi=1")).PERF.cardBlur).toBe(false);
});
});

View File

@@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest";
import type { Album, Track } from "../../api/types";
import {
buildSearchIndex,
effectiveSearch,
listMode,
MAX_SONG_HITS,
normalize,
results as computeResults,
@@ -111,14 +113,14 @@ describe("songMatches", () => {
expect(query({ mode: "tracks", search: "geheime" }).songs).toEqual([]);
});
it("caps an empty query, which would otherwise be the whole library", () => {
it("caps a query that matches most of the library", () => {
const many = Array.from({ length: 60 }, (_, i) =>
album(`m${i}`, { tracks: [track(`Lied ${i}`)] }),
);
const index = buildSearchIndex(many);
const hits = computeResults({
index,
search: "",
search: "lied",
mode: "tracks",
group: null,
category: null,
@@ -129,3 +131,28 @@ describe("songMatches", () => {
expect(hits.at(-1)!.title).toBe(`Lied ${MAX_SONG_HITS - 1}`);
});
});
describe("minimum query length", () => {
it("ignores a single letter and anything that normalizes to one", () => {
expect(effectiveSearch("e")).toBe("");
expect(effectiveSearch(" é ")).toBe("");
expect(effectiveSearch("ab")).toBe("ab");
expect(effectiveSearch("a b")).toBe("a b");
});
it("leaves the screen alone for one letter and filters from two", () => {
const index = buildSearchIndex([album("1"), album("2")]);
const base = { index, mode: "albums" as const, group: "music" as Group, category: null };
expect(listMode({ ...base, search: "a" })).toBe("categories");
expect(computeResults({ ...base, search: "a" }).albums).toHaveLength(0);
expect(listMode({ ...base, search: "al" })).toBe("albums");
expect(computeResults({ ...base, search: "al" }).albums).toHaveLength(2);
});
it("shows no titles until two letters are typed", () => {
const base = { index: INDEX, mode: "tracks" as const, group: null, category: null };
expect(computeResults({ ...base, search: "" }).songs).toEqual([]);
expect(computeResults({ ...base, search: "v" }).songs).toEqual([]);
expect(computeResults({ ...base, search: "va" }).songs.length).toBeGreaterThan(0);
});
});

View File

@@ -7,6 +7,7 @@
*/
import type { Album, AlbumKind, PlayerState } from "../api/types";
import { PERF } from "./perfSettings";
import { GROUP_HUE } from "./theme";
export const isBook = (album: Album): boolean => album.kind === "book";
@@ -37,15 +38,18 @@ function stripes(album: Album, width: number): string {
);
}
/** A book spine: page edges on the right, a darker board on the left. */
/** A book spine: page edges on the right, a darker board on the left.
*
* Stops are sRGB hex on purpose - see the note on `.stage` in app.css: an oklch stop
* makes Gecko interpolate in Oklab on the CPU at every display-list build, and these
* four gradients sit under every book card. The page edges are oklch(97% 0.02 55) and
* oklch(90% 0.03 55) (`GROUP_HUE.audiobooks`), the ridges oklch(98% 0 0 / .35). */
export function spine(album: Album): string {
const [primary, secondary] = colours(album);
const hue = GROUP_HUE.audiobooks;
return [
`linear-gradient(90deg, transparent 0 95%, oklch(97% 0.02 ${hue}) 95% 97.5%,` +
` oklch(90% 0.03 ${hue}) 97.5% 100%)`,
"linear-gradient(90deg, transparent 0 3.5%, oklch(98% 0 0 / .35) 3.5% 4.3%," +
" transparent 4.3% 7%, oklch(98% 0 0 / .35) 7% 7.8%, transparent 7.8%)",
"linear-gradient(90deg, transparent 0 95%, #fff2e9 95% 97.5%, #efd9cc 97.5% 100%)",
"linear-gradient(90deg, transparent 0 3.5%, #f8f8f859 3.5% 4.3%," +
" transparent 4.3% 7%, #f8f8f859 7% 7.8%, transparent 7.8%)",
`linear-gradient(90deg, ${secondary} 0 11%, ${primary} 11% 13%, transparent 13%)`,
`linear-gradient(155deg, ${primary} 0%, ${secondary} 100%)`,
].join(",");
@@ -55,6 +59,17 @@ export function coverBackground(album: Album, stripeWidth: number): string {
return isBook(album) ? spine(album) : stripes(album, stripeWidth);
}
/** What sits under a cover that has real art. The art is opaque, so the stripes or the
* four-gradient spine would be painted for nothing - on a 340-card grid that is real
* display-list work. A flat primary colour is all a still-loading image needs; a book
* keeps one cheap gradient for the page edges its clip leaves showing on the right. */
export function coverUnderlay(album: Album): string {
const [primary] = colours(album);
return isBook(album)
? `linear-gradient(90deg, ${primary} 0 95%, #fff2e9 95% 97.5%, #efd9cc 97.5% 100%)`
: primary;
}
/** The card's own tint: warm paper for books, cool glass for music. Amber
* (`GROUP_HUE.audiobooks`) rather than a duller yellow-brown, so it reads as rich
* rather than washed-out. */
@@ -63,8 +78,16 @@ export const cardBackground = (album: Album): string =>
? `oklch(92% 0.09 ${GROUP_HUE.audiobooks} / .7)`
: `oklch(95% 0.015 ${GROUP_HUE.music} / .66)`;
/** One blurred shadow per card, up to ~340 of them: dropped under `?pi=1`, where a plain
* translucent card reads fine and the shadow is a per-card blur render task. Books keep
* their two inset "board" bands, which are cheap and are what make them look like books. */
export const cardShadow = (album: Album): string =>
isBook(album)
!PERF.cardShadows
? isBook(album)
? `inset -7px 0 0 oklch(86% 0.09 ${GROUP_HUE.audiobooks} / .7), ` +
`inset -11px 0 0 oklch(78% 0.09 ${GROUP_HUE.audiobooks} / .7)`
: "none"
: isBook(album)
? `inset -7px 0 0 oklch(86% 0.09 ${GROUP_HUE.audiobooks} / .7), ` +
`inset -11px 0 0 oklch(78% 0.09 ${GROUP_HUE.audiobooks} / .7), ` +
`0 6px 18px oklch(15% 0.05 ${GROUP_HUE.music} / .35)`

View File

@@ -15,6 +15,8 @@ import type { Group, Mode, Results } from "./search";
export interface UiState {
search: string;
/** `/` was pressed: show the search box before the first letter arrives. */
searchOpen: boolean;
mode: Mode;
/** `null` is the bare root screen (three shelves); otherwise which one is open. */
group: Group | null;
@@ -41,6 +43,7 @@ export interface UiState {
export const initialUiState: UiState = {
search: "",
searchOpen: false,
mode: "albums",
group: null,
category: null,
@@ -179,7 +182,9 @@ function moveSelection(state: UiState, results: Results, dx: number, dy: number)
* should be one step too, not two, regardless of whether a category was reached that
* way or by opening the group first. Always safe to call. */
export function browseBackActions(state: UiState): Action[] {
if (state.search) return [{ type: "ui", patch: { search: "", selIndex: 0 } }];
if (state.search || state.searchOpen) {
return [{ type: "ui", patch: { search: "", searchOpen: false, selIndex: 0 } }];
}
if (state.mode === "tracks") return [{ type: "ui", patch: { mode: "albums", selIndex: 0 } }];
return [{ type: "ui", patch: { group: null, category: null, selIndex: 0 } }];
}
@@ -201,9 +206,28 @@ function escape(state: UiState): Action[] {
return browseBackActions(state);
}
/** The true root: nothing chosen yet, rendered as three shelves rather than a list. */
/** Whether the search box is on screen: something typed, `/` pressed, or track search. */
export function searchBoxShown(state: UiState): boolean {
return state.search.length > 0 || state.searchOpen || state.mode === "tracks";
}
/** The true root: nothing chosen yet, rendered as three shelves rather than a list. The
* shelves go away the moment the search box appears, before there is a query to filter by. */
export function isRootShelf(state: UiState): boolean {
return state.group === null && !state.search && state.mode === "albums" && state.category === null;
return state.group === null && !searchBoxShown(state) && state.category === null;
}
/** Album search <-> title search, keeping whatever has been typed. `searchOpen` keeps the
* box up when the switch lands on albums with nothing typed, where it would otherwise
* vanish and bring the shelves back. */
export function toggleModeActions(state: UiState): Action[] {
return [
{ type: "pop", freq: 460 },
{
type: "ui",
patch: { mode: state.mode === "tracks" ? "albums" : "tracks", searchOpen: true, selIndex: 0 },
},
];
}
export interface KeyEvent {
@@ -340,7 +364,9 @@ export function handleKey(
];
}
case "/":
return [{ type: "ui", patch: { view: "browse" } }];
// With the box already up, `/` flips what it searches; otherwise it opens it.
if (browsing && searchBoxShown(state)) return toggleModeActions(state);
return [{ type: "ui", patch: { view: "browse", searchOpen: true } }];
case "ArrowRight":
if (event.shiftKey) return [{ type: "seek", delta: SEEK_STEP }];

View File

@@ -44,15 +44,11 @@
* answerable by editing the address bar.
*/
/** True when the page was loaded with `?pi=1`. */
export const LOW_POWER = readLowPowerFlag();
import { PERF } from "./perfSettings";
function readLowPowerFlag(): boolean {
// `location` is absent under vitest's default node environment.
if (typeof location === "undefined") return false;
const value = new URLSearchParams(location.search).get("pi");
return value === "1" || value === "true";
}
// Superseded as a single switch: every knob below and in theme.ts now comes from
// `PERF` (lib/perfSettings.ts), which `?pi=1` seeds and the settings dialog overrides.
// This header is kept for the measurements it records.
export interface AmbienceQuality {
/** Multiplies the canvas backing store relative to its on-screen size, before
@@ -80,9 +76,12 @@ export interface AmbienceQuality {
* 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
? { resolutionScale: 0.5, maxBackingStorePx: 1280, maxFps: 30, maxBubbles: 60 }
: { resolutionScale: 1, maxBackingStorePx: 4096, maxFps: 0, maxBubbles: 0 };
export const AMBIENCE_QUALITY: AmbienceQuality = {
resolutionScale: PERF.ambienceScale,
maxBackingStorePx: PERF.ambienceMaxPx,
maxFps: PERF.ambienceMaxFps,
maxBubbles: PERF.ambienceMaxBubbles,
};
/** How many times a second the interpolated playback position may push a new React
* render. 0 means "every animation frame", which is what it has always done.
@@ -96,4 +95,4 @@ export const AMBIENCE_QUALITY: AmbienceQuality = LOW_POWER
*
* Only bites while something is playing, so it is not in the idle table above.
*/
export const PLAYBACK_CLOCK_FPS = LOW_POWER ? 10 : 0;
export const PLAYBACK_CLOCK_FPS = PERF.playbackClockFps;

147
web/src/lib/perfSettings.ts Normal file
View File

@@ -0,0 +1,147 @@
/** Per-knob performance settings, the granular successor to the old all-or-nothing `?pi=1`.
*
* `?pi=1` still switches the panel on, but no longer flips everything at once: it selects
* the `PI` preset as the starting point, and each knob can then be overridden from the
* settings dialog (`PerfPanel`) and is stored in this browser's localStorage. Without
* `?pi=1` the stored values are ignored and the full-fat `FULL` preset applies, so a
* laptop opening the same page always gets the full version - which is what makes "is this
* the profile or the hardware?" answerable by editing the address bar.
*
* Read once at module load. The knobs feed module-level constants (`SHOW_AMBIENCE`, ...)
* and the service worker registration, so applying a change means a page reload.
*
* `SHOW_GLASS_BLUR` is deliberately not a knob: on the Pi it measured five times *worse*
* with the blur off. See the table in lib/lowPower.ts.
*/
export interface PerfSettings {
/** Fade/slide animation when entering a group/category or opening the album modal. */
viewTransitions: boolean;
/** The play view's animated canvas background (gradient + bubbles). */
ambience: boolean;
/** Real backdrop blur on each repeated glass card/row. */
cardBlur: boolean;
/** Decorative CSS loops: the room page's bubbles and the dolphin mascot. */
decorativeAnimations: boolean;
/** Drop shadows under covers in the album cards. */
cardShadows: boolean;
/** Drop shadows under tiles in the browse grid. */
tileShadows: boolean;
/** Typing game: pets move (off = placed but held still). */
petAnimations: boolean;
/** Typing game: rising bubbles. */
tippenBubbles: boolean;
/** Typing game: fade/slide view transitions. */
tippenViewTransitions: boolean;
/** Register the service worker (off also unregisters an existing one). */
serviceWorker: boolean;
/** Pre-decode covers in the background. */
coverWarmup: boolean;
/** Max playback-position renders per second. 0 = every animation frame. */
playbackClockFps: number;
/** Ambient canvas backing-store scale relative to its on-screen size. */
ambienceScale: number;
/** Ceiling on either ambient canvas backing-store axis, in px. */
ambienceMaxPx: number;
/** Ambient canvas frame-rate cap. 0 = whatever the display offers. */
ambienceMaxFps: number;
/** Ceiling on ambient bubbles alive at once. 0 = unlimited. */
ambienceMaxBubbles: number;
}
/** Today's behaviour without `?pi=1`. */
export const FULL: PerfSettings = {
viewTransitions: true,
ambience: true,
cardBlur: true,
decorativeAnimations: true,
cardShadows: true,
tileShadows: true,
petAnimations: true,
tippenBubbles: true,
tippenViewTransitions: true,
serviceWorker: true,
coverWarmup: false,
playbackClockFps: 0,
ambienceScale: 1,
ambienceMaxPx: 4096,
ambienceMaxFps: 0,
ambienceMaxBubbles: 0,
};
/** Today's behaviour with `?pi=1`. */
export const PI: PerfSettings = {
viewTransitions: false,
ambience: false,
cardBlur: false,
decorativeAnimations: false,
cardShadows: false,
tileShadows: false,
petAnimations: false,
tippenBubbles: false,
tippenViewTransitions: false,
serviceWorker: false,
coverWarmup: true,
playbackClockFps: 10,
ambienceScale: 0.5,
ambienceMaxPx: 1280,
ambienceMaxFps: 30,
ambienceMaxBubbles: 60,
};
export const STORAGE_KEY = "musicmouse.perf.v1";
/** True when the page was loaded with `?pi=1`. */
export const PERF_ENABLED = readPiFlag();
function readPiFlag(): boolean {
// `location` is absent under vitest's default node environment.
if (typeof location === "undefined") return false;
const value = new URLSearchParams(location.search).get("pi");
return value === "1" || value === "true";
}
/** Keep only stored fields whose type matches the preset's; anything else is dropped. */
function sanitize(raw: unknown): Partial<PerfSettings> {
if (typeof raw !== "object" || raw === null) return {};
const out: Record<string, boolean | number> = {};
for (const [key, def] of Object.entries(PI)) {
const value = (raw as Record<string, unknown>)[key];
if (typeof value === typeof def && (typeof value !== "number" || Number.isFinite(value))) {
out[key] = value as boolean | number;
}
}
return out as Partial<PerfSettings>;
}
export function loadStoredPerf(): Partial<PerfSettings> {
try {
const text = localStorage.getItem(STORAGE_KEY);
return text ? sanitize(JSON.parse(text)) : {};
} catch {
return {};
}
}
export function loadPerfSettings(): PerfSettings {
if (!PERF_ENABLED) return { ...FULL };
return { ...PI, ...loadStoredPerf() };
}
export function savePerfSettings(settings: PerfSettings): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
} catch {
// Storage blocked: the dialog still reloads, it just comes back with the preset.
}
}
export function resetPerfSettings(): void {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
// ignore
}
}
export const PERF: PerfSettings = loadPerfSettings();

View File

@@ -53,6 +53,16 @@ function searchWords(value: string): string[] {
.filter(Boolean);
}
/** One letter matches most of the library, which is a screen of noise and not a
* search - the first character is the user finding their footing, not asking. */
export const MIN_SEARCH_CHARS = 2;
/** The query that actually filters: `""` until there are at least `MIN_SEARCH_CHARS`
* letters to go on, so the screen stays where it is while the first one is typed. */
export function effectiveSearch(value: string): string {
return searchWords(value).join("").length >= MIN_SEARCH_CHARS ? value : "";
}
/** Every word has to show up somewhere in the haystack, in any order. */
function matchesWords(haystack: string, words: string[]): boolean {
return words.every((word) => haystack.includes(word));
@@ -185,7 +195,7 @@ function trackPool(query: BrowseQuery): TrackEntry[] {
/** Which of the three lists the browse view is showing. */
export function listMode(query: BrowseQuery): "tracks" | "albums" | "categories" {
if (query.mode === "tracks") return "tracks";
if (query.search || query.category) return "albums";
if (effectiveSearch(query.search) || query.category) return "albums";
return "categories";
}
@@ -198,7 +208,7 @@ export function categoryMatches(query: BrowseQuery): Category[] {
export function albumMatches(query: BrowseQuery): Album[] {
if (query.mode === "tracks") return [];
const words = searchWords(query.search);
const words = searchWords(effectiveSearch(query.search));
if (!words.length && !query.category) return [];
let candidates = albumPool(query);
if (query.category) candidates = candidates.filter((e) => e.album.category === query.category);
@@ -206,15 +216,17 @@ export function albumMatches(query: BrowseQuery): Album[] {
return candidates.map((e) => e.album);
}
/** Capped, because an empty query over 900 podcast episodes is not a useful screen. */
/** Capped, because a query like "e" over 900 podcast episodes is not a useful screen. */
export const MAX_SONG_HITS = 40;
export function songMatches(query: BrowseQuery): SongHit[] {
if (query.mode !== "tracks") return [];
const words = searchWords(query.search);
const words = searchWords(effectiveSearch(query.search));
// Titles are five thousand rows: nothing until there is a query, not the first 40.
if (!words.length) return [];
const hits: SongHit[] = [];
for (const track of trackPool(query)) {
if (words.length && !matchesWords(track.haystack, words)) continue;
if (!matchesWords(track.haystack, words)) continue;
hits.push({
album: track.album,
index: track.index,

View File

@@ -11,7 +11,7 @@
import type { CSSProperties } from "react";
import { LOW_POWER } from "./lowPower";
import { PERF } from "./perfSettings";
import type { Group } from "./search";
import type { UiState } from "./keyboard";
@@ -33,7 +33,7 @@ export const ROW_SPACING = 25;
/** 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
* container, not per card, so cost stays flat regardless of grid size. */
export const ANIMATE_VIEW_TRANSITIONS = !LOW_POWER;
export const ANIMATE_VIEW_TRANSITIONS = PERF.viewTransitions;
/** Frost the glass panels/cards/rows with a real backdrop blur.
*
@@ -54,7 +54,7 @@ export const SHOW_GLASS_BLUR = true;
* loop that repaints the full viewport is a floor you cannot get under while it runs at
* 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;
export const SHOW_AMBIENCE = PERF.ambience;
/** 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.
@@ -65,14 +65,14 @@ export const SHOW_AMBIENCE = !LOW_POWER;
* card is one live backdrop copy out of three hundred sitting directly over the
* animated canvas, so every canvas frame re-blurs all of them. Measured on the Pi with
* a full album grid on screen, Chromium: see lib/lowPower.ts. */
export const SHOW_CARD_BLUR = !LOW_POWER;
export const SHOW_CARD_BLUR = PERF.cardBlur;
/** Run the purely decorative CSS animation loops: the room page's bubble field and
* the dolphin mascot's bob/swim. Off under `?pi=1`. On their own they measured as
* noise, but "nothing on this screen moves by itself" is a property worth having
* outright rather than a sum of small wins - a compositor with no animation to service
* has nothing to wake up for. */
export const SHOW_DECORATIVE_ANIMATIONS = !LOW_POWER;
export const SHOW_DECORATIVE_ANIMATIONS = PERF.decorativeAnimations;
// ------------------------------------------------------------------ colors --

View File

@@ -4,7 +4,7 @@
* 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. */
import { LOW_POWER } from "../lowPower";
import { PERF } from "../perfSettings";
/** The turquoise lagoon. Music is 210, Hörbücher 55, "Mein Zimmer" 300. */
export const HUE = 175;
@@ -17,7 +17,7 @@ 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 = !LOW_POWER;
export const SHOW_BUBBLES = PERF.tippenBubbles;
/** 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.
@@ -29,7 +29,7 @@ export const SHOW_BUBBLES = !LOW_POWER;
export const SHOW_AQUARIUM_CREATURES = true;
/** Fade screens in on entry. */
export const ANIMATE_VIEW_TRANSITIONS = !LOW_POWER;
export const ANIMATE_VIEW_TRANSITIONS = PERF.tippenViewTransitions;
/** 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