Stop rendering album cards that are off screen, and split card blur from panel blur

Searching a single letter over a real library puts 340 cards in one grid, each with a
cover image, a shadow and a backdrop blur, all of them live whether or not they are on
screen. On musicdolphin that saturated the renderer badly enough that a DevTools
Runtime.evaluate could not be scheduled on the main thread inside 30 seconds, which is
a fair description of what "sluggish" felt like.

content-visibility: auto on the grid's cards is the browser's own answer: off-screen
cards skip layout, paint and compositing and come back as they scroll near the
viewport. The grid's tracks are sized by minmax(180px, 1fr) rather than by card
content, so skipping that content cannot move the columns; contain-intrinsic-size
supplies the block-axis guess and `auto` remembers each card's real size after its
first render, so scroll height and offsetTop - which BrowseView's keep-the-selection-
on-screen effect reads - stay honest. Not behind ?pi=1: there is no visual difference
to trade away.

SHOW_CARD_BLUR separates the repeated frosted surfaces (every card, every list row)
from the handful of panels wrapped around them, because the two behave nothing alike
and lumping them together is what made the first Pi profile five times slower. A panel
is one live backdrop copy that buys its whole subtree a compositing layer; a card is
one of three hundred sitting directly over the animated canvas. ?pi=1 now drops the
card blur and keeps the panel blur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 15:12:26 +02:00
parent 2514ecbc35
commit 3cdad1e714
3 changed files with 50 additions and 2 deletions

View File

@@ -35,7 +35,7 @@ import { playPop } from "./lib/pop";
import { targetForAlbum } from "./lib/remote"; import { targetForAlbum } from "./lib/remote";
import type { Group, Results, SongHit } from "./lib/search"; import type { Group, Results, SongHit } from "./lib/search";
import { buildSearchIndex, categoryMatches, groupOf, results as computeResults } from "./lib/search"; import { buildSearchIndex, categoryMatches, groupOf, results as computeResults } from "./lib/search";
import { SHOW_AMBIENCE, SHOW_GLASS_BLUR } from "./lib/theme"; import { SHOW_AMBIENCE, SHOW_CARD_BLUR, SHOW_GLASS_BLUR } from "./lib/theme";
/** How long an armed "A" waits for the digit that completes the shortcut. */ /** How long an armed "A" waits for the digit that completes the shortcut. */
const ASSIGN_PENDING_TIMEOUT_MS = 4000; const ASSIGN_PENDING_TIMEOUT_MS = 4000;
@@ -412,7 +412,11 @@ export function App() {
setUi((previous) => ({ ...previous, page: previous.page === page ? "music" : page })); setUi((previous) => ({ ...previous, page: previous.page === page ? "music" : page }));
return ( return (
<div className="stage" data-blur={SHOW_GLASS_BLUR ? undefined : "off"}> <div
className="stage"
data-blur={SHOW_GLASS_BLUR ? undefined : "off"}
data-cardblur={SHOW_CARD_BLUR ? undefined : "off"}
>
{state && SHOW_AMBIENCE && ( {state && SHOW_AMBIENCE && (
<Ambience <Ambience
album={currentAlbum} album={currentAlbum}

View File

@@ -11,6 +11,7 @@
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { LOW_POWER } from "./lowPower";
import type { Group } from "./search"; import type { Group } from "./search";
import type { UiState } from "./keyboard"; import type { UiState } from "./keyboard";
@@ -52,6 +53,17 @@ export const SHOW_GLASS_BLUR = true;
* half the frames) rather than making it go away. */ * half the frames) rather than making it go away. */
export const SHOW_AMBIENCE = true; export const SHOW_AMBIENCE = true;
/** 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.
*
* Separate from `SHOW_GLASS_BLUR` because the two behave nothing alike, and lumping
* them together is what made the first attempt at a Pi profile five times slower. A
* panel is one live backdrop copy that buys its whole subtree a compositing layer. A
* 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;
/** 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. Left on even under `?pi=1`: switching them off on the
* Pi moved the idle figure by 0.4 percentage points, which is noise, and they are the * Pi moved the idle figure by 0.4 percentage points, which is noise, and they are the

View File

@@ -157,6 +157,17 @@ button {
box-shadow: 0 4px 14px var(--shadow); box-shadow: 0 4px 14px var(--shadow);
} }
/* `SHOW_CARD_BLUR` (`lib/theme.ts`) off sets `data-cardblur="off"` - drop the backdrop
blur from the surfaces there are *many* of, and only those. An album grid is up to
343 cards, each one a live backdrop copy sitting directly over the animated canvas,
so every canvas frame re-blurs all of them. The panels below keep their blur: a
panel is one copy, and it is what gives its whole subtree a compositing layer. */
.stage[data-cardblur="off"] .card,
.stage[data-cardblur="off"] .list-row {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
/* `SHOW_GLASS_BLUR` (`lib/theme.ts`) off sets `data-blur="off"` on `.stage` - drop /* `SHOW_GLASS_BLUR` (`lib/theme.ts`) off sets `data-blur="off"` on `.stage` - drop
the (GPU-expensive) backdrop blur everywhere below and fall back to each the (GPU-expensive) backdrop blur everywhere below and fall back to each
element's own plain translucent background. */ element's own plain translucent background. */
@@ -199,6 +210,27 @@ button {
margin: 0 auto; margin: 0 auto;
} }
/* Searching "e" over a real library puts 340 cards in this grid at once, each one a
cover image, a shadow and a backdrop blur, all of them live whether or not they are
on screen. On the Pi that saturated the renderer so completely that a DevTools
evaluate could not be scheduled on the main thread inside 30 seconds - which is
exactly what "sluggish" felt like.
`content-visibility: auto` is the browser's own answer to this and costs nothing
elsewhere: off-screen cards skip layout, paint and compositing entirely, and come
back the moment they scroll near the viewport. The grid's tracks are sized by
`minmax(180px, 1fr)` rather than by card content, so skipping that content cannot
change the column layout; `contain-intrinsic-size` supplies the block-axis guess
(a card measured 185x289 on the device) and `auto` then remembers each card's real
size once it has been rendered once, so scroll height and `offsetTop` - which the
keep-the-selection-on-screen effect in BrowseView reads - stay honest.
Deliberately not behind ?pi=1: there is no visual difference to trade away. */
.grid > .card {
content-visibility: auto;
contain-intrinsic-size: auto 289px;
}
.shelf-row { .shelf-row {
display: flex; display: flex;
gap: 12px; gap: 12px;