onEnterGroup(shelfGroup, null)}
- onKeyDown={(event) => {
- if (event.key !== "Enter" && event.key !== " ") return;
- event.preventDefault();
- onEnterGroup(shelfGroup, null);
- }}
- aria-label={`Alle ${GROUP_ICON[shelfGroup]} ${GROUP_LABEL[shelfGroup]} durchsuchen`}
- style={{
- marginBottom: ROW_SPACING,
- padding: "12px 14px",
- cursor: "pointer",
- // Once a row has tiles, the focus ring belongs on the tile the keyboard
- // is actually on (below) rather than the row around it - this only
- // stands in for that when there's nothing to focus.
- outline:
- focusedRow === index && shelfCategories.length === 0
- ? "4px solid var(--paper)"
- : undefined,
- outlineOffset: 2,
- ...glassTint(GROUP_HUE[shelfGroup]),
- }}
- >
- {(SHOW_ROW_TITLES || SHOW_ROW_ICONS) && (
-
- ) : (
-
- {shelfCategories.map((entry, tileIndex) => (
- {
- // Otherwise this bubbles to the panel's own onClick, which would
- // open the group root instead of the category just clicked.
- event.stopPropagation();
- onEnterGroup(shelfGroup, entry.key);
- }}
- />
- ))}
-
- )}
-
onEnterGroup(shelfGroup, null)}
+ onKeyDown={(event) => {
+ if (event.key !== "Enter" && event.key !== " ") return;
+ event.preventDefault();
+ onEnterGroup(shelfGroup, null);
+ }}
+ aria-label={`Alle ${GROUP_ICON[shelfGroup]} ${GROUP_LABEL[shelfGroup]} durchsuchen`}
+ style={{
+ marginBottom: ROW_SPACING,
+ padding: "12px 14px",
+ cursor: "pointer",
+ // Once a row has tiles, the focus ring belongs on the tile the keyboard
+ // is actually on (below) rather than the row around it - this only
+ // stands in for that when there's nothing to focus.
+ outline:
+ focusedRow === index && shelfCategories.length === 0
+ ? "4px solid var(--paper)"
+ : undefined,
+ outlineOffset: 2,
+ ...glassTint(GROUP_HUE[shelfGroup]),
+ }}
+ >
+ {(SHOW_ROW_TITLES || SHOW_ROW_ICONS) && (
+
+ ) : (
+
+ {shelfCategories.map((entry, tileIndex) => (
+ {
+ // Otherwise this bubbles to the panel's own onClick, which would
+ // open the group root instead of the category just clicked.
+ event.stopPropagation();
+ onEnterGroup(shelfGroup, entry.key);
+ }}
+ />
+ ))}
+
+ )}
+
)}
@@ -419,7 +458,7 @@ export function BrowseView({
{GROUP_SECTION_LABEL[group]}
)}
- {albumResults.map((album, index) => {
+ {albumResults.slice(0, albumWindow.visible).map((album, index) => {
const navIndex = songs.length + categories.length + index;
const podcast = groupOf(album) === "podcasts";
// An audiobook's artist is almost always the same as the character/
@@ -468,7 +507,11 @@ export function BrowseView({
>
@@ -557,20 +602,34 @@ export function BrowseView({
);
})}
+
{children}
);
}
+
+/** The search field, shown once there is something in it so it is obvious where typing goes. It only
+ * displays: keys are caught by the window handler in App, which is what lets the whole
+ * UI be driven from the keyboard without focusing anything first. */
+function SearchBox({
+ typed,
+ mode,
+ open,
+ onToggleMode,
+ note,
+}: {
+ typed: string;
+ mode: "albums" | "tracks";
+ open: boolean;
+ onToggleMode: () => void;
+ note: string;
+}) {
+ // Nothing to show until a letter is typed or `/` opens it - and in tracks mode the
+ // box is also how the screen says which mode it is in.
+ if (!typed && !open && mode !== "tracks") return null;
+ return (
+
+
+ {/* mousedown is swallowed so the button never takes focus: a focused button
+ would also "click" on the Space or Enter the window handler already used. */}
+
+
+ {!typed && }
+ {typed || Tippe zum Suchen …}
+ {typed && }
+
+
+ {note &&
{note}
}
+
+ );
+}
+
+/** Memoised: `App` re-renders twice a second during playback (position frames), and
+ * every prop here is either a primitive, a memoised value or a stable handler, so the
+ * card grid - up to ~340 cards - stays put unless something it shows has changed. */
+export const BrowseView = memo(BrowseViewImpl);
diff --git a/web/src/components/Cover.tsx b/web/src/components/Cover.tsx
index da98946..76ca006 100644
--- a/web/src/components/Cover.tsx
+++ b/web/src/components/Cover.tsx
@@ -9,9 +9,9 @@
import type { CSSProperties } from "react";
-import { coverUrl } from "../api/client";
+import { THUMB_MAX_SIZE, coverUrl } from "../api/client";
import type { Album } from "../api/types";
-import { aspectOf, coverBackground, isBook } from "../lib/covers";
+import { aspectOf, coverBackground, coverUnderlay, isBook } from "../lib/covers";
interface Props {
album: Album;
@@ -47,7 +47,11 @@ export function Cover({ album, size, fit = "width", radius, className, style, la
flex: "none",
borderRadius: radius ?? defaultRadius,
overflow: "hidden",
- background: coverBackground(album, Math.max(6, Math.round(size / 13))),
+ // Real art is opaque: paint the cheap flat underlay, not the stripes/spine.
+ background:
+ album.has_cover && !locked
+ ? coverUnderlay(album)
+ : coverBackground(album, Math.max(6, Math.round(size / 13))),
...box,
...style,
}}
@@ -90,9 +94,13 @@ export function Cover({ album, size, fit = "width", radius, className, style, la
)}
{album.has_cover && (
+ ({
+ border: "none",
+ cursor: "pointer",
+ background,
+ color,
+ fontSize: 15,
+ fontWeight: 800,
+ padding: "11px 20px",
+ borderRadius: 999,
+ }) as const;
+
+export function PerfPanel({ onClose }: { onClose: () => void }) {
+ const [draft, setDraft] = useState({ ...PERF });
+
+ const save = () => {
+ savePerfSettings(draft);
+ location.reload();
+ };
+ const reset = () => {
+ resetPerfSettings();
+ location.reload();
+ };
+
+ return (
+
+ Wird nur in diesem Browser gespeichert und gilt nur mit ?pi=1.
+
+
+
+
+ );
+}
diff --git a/web/src/components/tippen/AquariumCreatures.tsx b/web/src/components/tippen/AquariumCreatures.tsx
index 83fc09e..4bfcfc6 100644
--- a/web/src/components/tippen/AquariumCreatures.tsx
+++ b/web/src/components/tippen/AquariumCreatures.tsx
@@ -14,7 +14,7 @@
import { useEffect, useRef } from "react";
-import { LOW_POWER } from "../../lib/lowPower";
+import { PERF } from "../../lib/perfSettings";
import { creatureById, createSwimmer, pose, stepSwimmer } from "../../lib/tippen/aquarium";
import type { CreatureId, Swimmer } from "../../lib/tippen/aquarium";
@@ -42,7 +42,7 @@ export function AquariumCreatures({ creatures, opacity }: Props) {
// 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;
+ !PERF.petAnimations || window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let frame = 0;
let lastTime = performance.now();
diff --git a/web/src/components/tippen/Stage.tsx b/web/src/components/tippen/Stage.tsx
index 42330ae..c18973b 100644
--- a/web/src/components/tippen/Stage.tsx
+++ b/web/src/components/tippen/Stage.tsx
@@ -9,7 +9,7 @@
import type { ReactNode } from "react";
import type { CreatureId } from "../../lib/tippen/aquarium";
-import { LOW_POWER } from "../../lib/lowPower";
+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";
@@ -27,7 +27,7 @@ export function Stage({ children, creatures, dimmed }: Props) {
{SHOW_AQUARIUM_CREATURES && }
{SHOW_BUBBLES && }
diff --git a/web/src/hooks/useCoverWarmup.ts b/web/src/hooks/useCoverWarmup.ts
new file mode 100644
index 0000000..b462dc9
--- /dev/null
+++ b/web/src/hooks/useCoverWarmup.ts
@@ -0,0 +1,55 @@
+/** Pull every card thumbnail into the browser's caches once, while the kiosk is idle.
+ *
+ * Cover URLs carry a version and are served `immutable`, so once a thumbnail has been
+ * fetched the browser never asks for it again - but the *first* search over the whole
+ * library would otherwise fetch, and decode, hundreds of them while the user waits. This
+ * does that work up front, a few at a time in idle periods. The `Image`s are held so
+ * their decoded bitmaps stay resident: RAM is not the constraint on the machine this is
+ * for (`?pi=1`, see lib/lowPower.ts), the main thread during a keystroke is.
+ */
+
+import { useEffect } from "react";
+
+import { coverUrl } from "../api/client";
+import type { Album } from "../api/types";
+
+const BATCH = 6;
+
+const idle = (run: () => void): void => {
+ if (typeof requestIdleCallback === "function") requestIdleCallback(run, { timeout: 2000 });
+ else setTimeout(run, 50);
+};
+
+/** Module-level on purpose: dropping these would let the browser discard what they hold. */
+const held = new Map();
+
+export function useCoverWarmup(albums: Album[], enabled: boolean): void {
+ useEffect(() => {
+ if (!enabled) return;
+ let cancelled = false;
+ const pending = albums.filter((a) => a.has_cover && !a.locked);
+ let next = 0;
+
+ const step = () => {
+ if (cancelled || next >= pending.length) return;
+ const batch = pending.slice(next, next + BATCH);
+ next += BATCH;
+ void Promise.all(
+ batch.map((album) => {
+ const url = coverUrl(album.id, { size: "thumb", version: album.cover_v });
+ if (held.has(url)) return undefined;
+ const img = new Image();
+ img.decoding = "async";
+ img.src = url;
+ held.set(url, img);
+ return img.decode().catch(() => undefined);
+ }),
+ ).then(() => idle(step));
+ };
+ idle(step);
+
+ return () => {
+ cancelled = true;
+ };
+ }, [albums, enabled]);
+}
diff --git a/web/src/hooks/useEvent.ts b/web/src/hooks/useEvent.ts
new file mode 100644
index 0000000..df57409
--- /dev/null
+++ b/web/src/hooks/useEvent.ts
@@ -0,0 +1,17 @@
+/** A callback with a stable identity that always calls the latest closure.
+ *
+ * For handlers passed to a `memo`ised child: `App` re-renders twice a second while a
+ * track plays (websocket position frames), and a fresh inline handler each time would
+ * defeat `memo` and re-render the whole card grid with it. */
+
+import { useCallback, useLayoutEffect, useRef } from "react";
+
+export function useEvent(
+ handler: (...args: Args) => Result,
+): (...args: Args) => Result {
+ const latest = useRef(handler);
+ useLayoutEffect(() => {
+ latest.current = handler;
+ });
+ return useCallback((...args: Args) => latest.current(...args), []);
+}
diff --git a/web/src/hooks/useIncrementalCount.ts b/web/src/hooks/useIncrementalCount.ts
new file mode 100644
index 0000000..20e7263
--- /dev/null
+++ b/web/src/hooks/useIncrementalCount.ts
@@ -0,0 +1,68 @@
+/** How many items of a long list to render, growing as the user gets near the end.
+
+Committing every card of a 340-album search result inside one keystroke is what made typing
+feel stuck, and it is work for cards nobody has scrolled to. So a list renders its first
+`initial` items and asks for `step` more whenever a sentinel placed after the list comes
+within a screen of the scroll box's edge, or the keyboard selection reaches the last one
+rendered. A new `resetKey` (another query, group or category) starts over at `initial`, so
+a short search never inherits the length of the previous long one.
+
+Growth runs in a transition: the next keystroke interrupts it rather than waiting for it.
+*/
+
+import { startTransition, useCallback, useEffect, useState } from "react";
+import type { RefObject } from "react";
+
+export function nextCount(count: number, step: number, total: number): number {
+ return Math.min(total, count + step);
+}
+
+export function useIncrementalCount(
+ total: number,
+ initial: number,
+ step: number,
+ resetKey: string,
+ root: RefObject,
+ selected: number,
+): { visible: number; sentinelRef: (node: HTMLElement | null) => void } {
+ const [state, setState] = useState({ key: resetKey, count: initial });
+ const [sentinel, setSentinel] = useState(null);
+
+ // Adjusting state during render is React's documented way to reset on a changed input;
+ // an effect would paint one frame of the old, long list first.
+ let count = state.count;
+ if (state.key !== resetKey) {
+ count = initial;
+ setState({ key: resetKey, count });
+ } else if (count < initial) {
+ // The grid measured its real column count after first paint.
+ count = initial;
+ }
+
+ const grow = useCallback(() => {
+ startTransition(() =>
+ setState((previous) => ({ ...previous, count: nextCount(Math.max(previous.count, initial), step, total) })),
+ );
+ }, [initial, step, total]);
+
+ // Recreated on every growth: an observer only reports *changes*, so one that stays
+ // attached to a sentinel that is still on screen would never ask a second time.
+ useEffect(() => {
+ if (!sentinel || count >= total) return;
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries.some((entry) => entry.isIntersecting)) grow();
+ },
+ { root: root.current, rootMargin: "100% 0px" },
+ );
+ observer.observe(sentinel);
+ return () => observer.disconnect();
+ }, [sentinel, count, total, grow, root]);
+
+ // Arrowing down past the fold: the selected item must exist to be scrolled to.
+ useEffect(() => {
+ if (selected >= count - 1 && count < total) grow();
+ }, [selected, count, total, grow]);
+
+ return { visible: Math.min(count, total), sentinelRef: setSentinel };
+}
diff --git a/web/src/lib/__tests__/keyboard.test.ts b/web/src/lib/__tests__/keyboard.test.ts
index c97e14e..7aeaa0c 100644
--- a/web/src/lib/__tests__/keyboard.test.ts
+++ b/web/src/lib/__tests__/keyboard.test.ts
@@ -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 {
@@ -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 },
+ });
+ });
});
diff --git a/web/src/lib/__tests__/perfSettings.test.ts b/web/src/lib/__tests__/perfSettings.test.ts
new file mode 100644
index 0000000..7a102d8
--- /dev/null
+++ b/web/src/lib/__tests__/perfSettings.test.ts
@@ -0,0 +1,55 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const store = new Map();
+
+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);
+ });
+});
diff --git a/web/src/lib/__tests__/search.test.ts b/web/src/lib/__tests__/search.test.ts
index 9198352..464ebd0 100644
--- a/web/src/lib/__tests__/search.test.ts
+++ b/web/src/lib/__tests__/search.test.ts
@@ -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);
+ });
+});
diff --git a/web/src/lib/covers.ts b/web/src/lib/covers.ts
index 3873b6e..d8f134b 100644
--- a/web/src/lib/covers.ts
+++ b/web/src/lib/covers.ts
@@ -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)`
diff --git a/web/src/lib/keyboard.ts b/web/src/lib/keyboard.ts
index 9ddd969..508922d 100644
--- a/web/src/lib/keyboard.ts
+++ b/web/src/lib/keyboard.ts
@@ -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 }];
diff --git a/web/src/lib/lowPower.ts b/web/src/lib/lowPower.ts
index 9402ba5..7796a85 100644
--- a/web/src/lib/lowPower.ts
+++ b/web/src/lib/lowPower.ts
@@ -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;
diff --git a/web/src/lib/perfSettings.ts b/web/src/lib/perfSettings.ts
new file mode 100644
index 0000000..6d107f5
--- /dev/null
+++ b/web/src/lib/perfSettings.ts
@@ -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 {
+ if (typeof raw !== "object" || raw === null) return {};
+ const out: Record = {};
+ for (const [key, def] of Object.entries(PI)) {
+ const value = (raw as Record)[key];
+ if (typeof value === typeof def && (typeof value !== "number" || Number.isFinite(value))) {
+ out[key] = value as boolean | number;
+ }
+ }
+ return out as Partial;
+}
+
+export function loadStoredPerf(): Partial {
+ 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();
diff --git a/web/src/lib/search.ts b/web/src/lib/search.ts
index 0584a80..4458615 100644
--- a/web/src/lib/search.ts
+++ b/web/src/lib/search.ts
@@ -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,
diff --git a/web/src/lib/theme.ts b/web/src/lib/theme.ts
index b9e059c..8443864 100644
--- a/web/src/lib/theme.ts
+++ b/web/src/lib/theme.ts
@@ -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 --
diff --git a/web/src/lib/tippen/theme.ts b/web/src/lib/tippen/theme.ts
index 00c89b7..d511f1e 100644
--- a/web/src/lib/tippen/theme.ts
+++ b/web/src/lib/tippen/theme.ts
@@ -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
diff --git a/web/src/main.tsx b/web/src/main.tsx
index d075851..8194c57 100644
--- a/web/src/main.tsx
+++ b/web/src/main.tsx
@@ -2,6 +2,7 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
+import { PERF } from "./lib/perfSettings";
import "./styles/app.css";
import "./styles/room.css";
import "./styles/tippen.css";
@@ -15,8 +16,20 @@ createRoot(document.getElementById("root")!).render(
// Registered after load so it never competes with the first paint. It is what makes
// iOS offer "Zum Home-Bildschirm" as an app rather than a bookmark; see public/sw.js
// for what it does and deliberately does not cache.
+//
+// Not under `?pi=1`: that is the kiosk, which *is* the server. There is nothing to
+// install, and the worker only adds a fetch handler (and a `cache.put` of every response)
+// in front of requests that are already on localhost. Any worker an earlier visit left
+// behind is removed so the kiosk stops paying for it.
if ("serviceWorker" in navigator && import.meta.env.PROD) {
window.addEventListener("load", () => {
+ if (!PERF.serviceWorker) {
+ void navigator.serviceWorker
+ .getRegistrations()
+ .then((registrations) => Promise.all(registrations.map((r) => r.unregister())))
+ .catch(() => undefined);
+ return;
+ }
void navigator.serviceWorker.register("/sw.js").catch(() => {
// A plain http:// origin that is not localhost cannot register one. The app
// works exactly the same, it just cannot be installed.
diff --git a/web/src/styles/app.css b/web/src/styles/app.css
index 55ff3fb..95d2723 100644
--- a/web/src/styles/app.css
+++ b/web/src/styles/app.css
@@ -104,12 +104,11 @@ button {
height: 100vh;
height: 100dvh;
overflow: hidden;
- background: linear-gradient(
- 180deg,
- oklch(55% 0.07 210) 0%,
- oklch(38% 0.06 210) 45%,
- var(--sea-deep) 100%
- );
+ /* 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%);
}
/* Still used by the room-control page's own `` field (`RoomView.tsx`) - the
@@ -270,7 +269,7 @@ button {
flex: none;
border-radius: 999px;
font-size: 17px;
- background: linear-gradient(160deg, oklch(97% 0.01 210 / 0.38), oklch(97% 0.01 210 / 0.12));
+ background: linear-gradient(160deg, #eef7f961, #eef7f91f) /* oklch(97% 0.01 210 / .38 -> .12) */;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid oklch(97% 0.01 210 / 0.4);
@@ -282,7 +281,7 @@ 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, oklch(97% 0.01 210 / 0.14), oklch(97% 0.01 210 / 0.05));
+ background: linear-gradient(160deg, #eef7f924, #eef7f90d) /* oklch(97% 0.01 210 / .14 -> .05) */;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid oklch(97% 0.01 210 / 0.16);
@@ -344,3 +343,89 @@ input[type="number"],
input[type="range"] {
font: inherit;
}
+
+/* The search field. Big on purpose: typing is how the whole app is driven, so where the
+ letters land has to be unmissable. */
+.searchbox-row {
+ flex: none;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+ margin: 6px 32px 10px;
+}
+
+.searchbox {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ width: min(680px, 100%);
+ min-height: 68px;
+ box-sizing: border-box;
+ padding: 10px 26px 10px 14px;
+ border-radius: 999px;
+ background: var(--paper);
+ color: var(--ink);
+ border: 3px solid var(--accent);
+ box-shadow: 0 6px 22px var(--shadow);
+ font-size: 30px;
+ font-weight: 800;
+}
+
+.searchbox-mode {
+ flex: none;
+ font-size: 16px;
+ padding: 8px 14px;
+ border-radius: 999px;
+ background: var(--ink);
+ color: #fff;
+ border: none;
+ font: inherit;
+ font-size: 16px;
+ cursor: pointer;
+}
+
+.searchbox-mode:hover {
+ background: var(--accent);
+}
+
+.searchbox-text {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.searchbox-placeholder {
+ color: oklch(30% 0.04 210 / .45);
+}
+
+.searchbox-caret {
+ display: inline-block;
+ width: 3px;
+ height: 1em;
+ margin-left: 3px;
+ vertical-align: -0.12em;
+ background: var(--accent);
+ animation: searchbox-blink 1.1s steps(1) infinite;
+}
+
+/* Empty: the caret sits where the first letter will land, ahead of the placeholder. */
+.searchbox[data-empty] .searchbox-caret {
+ margin: 0 3px 0 0;
+}
+
+.searchbox-note {
+ color: oklch(90% 0.02 210 / .85);
+ font-size: 15px;
+ font-weight: 700;
+}
+
+@keyframes searchbox-blink {
+ 50% { opacity: 0; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .searchbox-caret { animation: none; }
+}