Files
musicmouse/web/src/styles/app.css
Martin Bauer 3cdad1e714 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>
2026-09-20 15:12:26 +02:00

347 lines
10 KiB
CSS

/* Ported from claude-design/Dolphin Beats.dc.html. The palette is oklch throughout;
the tokens below are the handful of values that repeat. */
:root {
--ink: oklch(30% 0.04 210);
--paper: oklch(97% 0.01 210);
--accent: oklch(70% 0.16 340);
--accent-dim: oklch(78% 0.14 340);
--sea-deep: oklch(20% 0.045 210);
--shadow: oklch(15% 0.05 210 / 0.35);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Nunito", system-ui, sans-serif;
overscroll-behavior: none;
/* Installed on a phone this is an app, not a document: no rubber-banding, no
long-press callout, no accidental text selection when a small hand misses a
button. Text inside the parent-mode inputs opts back in below. */
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
input,
textarea {
-webkit-user-select: text;
user-select: text;
}
button {
font-family: inherit;
}
@keyframes bubbleRise {
0% {
transform: translateY(0) scale(1);
opacity: 0.55;
}
100% {
transform: translateY(-120vh) scale(1.3);
opacity: 0;
}
}
@keyframes dolphinBob {
0%,
100% {
transform: translateY(0) rotate(-2deg);
}
50% {
transform: translateY(-10px) rotate(2deg);
}
}
@keyframes dolphinSwim {
0% { transform: translate(-20vw, 10vh) rotate(-3deg) scaleX(1); }
15% { transform: translate(2vw, 2vh) rotate(4deg) scaleX(1); }
30% { transform: translate(24vw, 12vh) rotate(-3deg) scaleX(1); }
45% { transform: translate(46vw, 2vh) rotate(4deg) scaleX(1); }
60% { transform: translate(68vw, 12vh) rotate(-3deg) scaleX(1); }
70% { transform: translate(84vw, 6vh) rotate(0deg) scaleX(1); }
74% { transform: translate(84vw, 6vh) rotate(0deg) scaleX(-1); }
85% { transform: translate(52vw, 18vh) rotate(-3deg) scaleX(-1); }
95% { transform: translate(16vw, 6vh) rotate(3deg) scaleX(-1); }
100% { transform: translate(-20vw, 10vh) rotate(-3deg) scaleX(-1); }
}
/* Entrance animations for the browse grid and the album modal - gated by
`ANIMATE_VIEW_TRANSITIONS` in `lib/theme.ts`. */
@keyframes viewEnter {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes sheetEnter {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes backdropEnter {
from { opacity: 0; }
to { opacity: 1; }
}
.view-enter {
animation: viewEnter 200ms ease-out;
}
.sheet-enter {
animation: sheetEnter 200ms ease-out;
}
.backdrop-enter {
animation: backdropEnter 150ms ease;
}
.stage {
position: relative;
width: 100%;
/* dvh follows the iOS toolbar as it collapses; vh is the fallback for older
browsers, where it would otherwise leave a strip of white at the bottom. */
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%
);
}
/* Still used by the room-control page's own `<Bubbles>` field (`RoomView.tsx`) - the
main player's bubbles moved to `Ambience.tsx`'s canvas, which needs real spawn and
turbulence control that a fixed set of CSS animations cannot give it. */
.bubble {
position: absolute;
bottom: -40px;
border-radius: 50%;
animation-name: bubbleRise;
animation-timing-function: linear;
animation-iteration-count: infinite;
pointer-events: none;
}
.ambience {
display: block;
/* A canvas is a replaced element: without an explicit size here, its CSS layout box
falls back to its own width/height *attributes* - the very thing the resize
handler sets from this box's measured size times devicePixelRatio. On any display
where dpr != 1, that is a feedback loop: each resize multiplies the attribute
(and thus, without this rule, the box) by dpr again, diverging within a few
frames until the canvas exceeds the browser's max size and throws. Pinning the
box to 100% of `.stage` breaks the loop - layout no longer depends on the
attribute at all. */
width: 100%;
height: 100%;
}
.pill {
border: none;
cursor: pointer;
font-size: 14px;
font-weight: 800;
padding: 9px 15px;
border-radius: 999px;
white-space: nowrap;
background: oklch(97% 0.01 210 / 0.16);
color: var(--paper);
}
.pill[data-active="true"] {
background: var(--paper);
color: oklch(28% 0.04 210);
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
the (GPU-expensive) backdrop blur everywhere below and fall back to each
element's own plain translucent background. */
.stage[data-blur="off"] .card,
.stage[data-blur="off"] .group-icon,
.stage[data-blur="off"] .glass-panel,
.stage[data-blur="off"] .list-row {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.card {
backdrop-filter: blur(6px);
overflow: hidden;
cursor: pointer;
transition: transform 0.12s ease;
border: none;
padding: 0;
text-align: left;
font: inherit;
display: block;
width: 100%;
}
.card[data-selected="true"] {
outline: 5px solid var(--paper);
outline-offset: 3px;
transform: translateY(-3px);
}
.card[data-current="true"]:not([data-selected="true"]) {
outline: 4px solid var(--accent);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 24px;
max-width: 1180px;
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 {
display: flex;
gap: 12px;
overflow-x: auto;
/* Room for a focused card's outline (5px + 3px offset) plus its -3px lift, on every
side - otherwise the row's own scroll clipping (overflow-x: auto computes
overflow-y to auto too, not visible) cuts the ring off on whichever card is
focused. */
padding: 14px 10px 14px;
cursor: grab;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* old Edge */
}
.shelf-row::-webkit-scrollbar {
display: none; /* Chrome, Safari */
}
.shelf-row.dragging {
cursor: grabbing;
}
.shelf-row .card {
flex: 0 0 148px;
}
/* The emoji glyph used as a group icon (🎵/📖/🎙️) renders thin and low-contrast
sitting directly on the stage gradient. A round frosted badge behind it gives it
the same visual weight as the bold label text next to it. Only shown when
SHOW_ROW_ICONS (`lib/theme.ts`) is on. */
.group-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
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));
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid oklch(97% 0.01 210 / 0.4);
box-shadow:
0 3px 10px var(--shadow),
inset 0 1px 0 oklch(100% 0 0 / 0.35);
}
/* 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));
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid oklch(97% 0.01 210 / 0.16);
border-radius: 22px;
box-shadow:
0 8px 24px var(--shadow),
inset 0 1px 0 oklch(100% 0 0 / 0.14);
}
/* One row inside a glass list (song/track rows). Layered on top of the row's own
selection-state background so it keeps its own tint while still reading as glass. */
.list-row {
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid oklch(97% 0.01 210 / 0.16);
box-shadow:
0 4px 14px var(--shadow),
inset 0 1px 0 oklch(100% 0 0 / 0.12);
transition: transform 0.1s ease;
}
.round {
border-radius: 999px;
border: none;
padding: 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.key-cap {
font: 800 12px ui-monospace, Menlo, monospace;
background: var(--ink);
color: #fff;
padding: 5px 12px;
border-radius: 8px;
min-width: 56px;
text-align: center;
}
.sheet {
background: var(--paper);
border-radius: 24px;
box-shadow: 0 20px 50px oklch(10% 0.04 210 / 0.5);
}
.overlay {
position: absolute;
inset: 0;
background: oklch(15% 0.03 210 / 0.55);
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
}
input[type="number"],
input[type="range"] {
font: inherit;
}