With the ambient canvas off under ?pi=1, the backdrop blur no longer buys anything: it paid for itself by giving each panel a compositing layer above the animating canvas, and with nothing animating underneath it was just cost - enough to make the browse rows visibly sluggish on the device. So the Pi profile now drops every backdrop blur (`glassBlur`, which the music app and the typing game share), and turns the drift and the moving mascot back on: plain CSS transform loops the compositor runs by itself, which measured as noise. The knob set is consolidated to match: one glass toggle instead of panel and card blur, one shadows toggle for cards and tiles, the typing game's drift and view transitions folded into the shared ones, and the backing-store cap fixed by profile rather than a setting. The parent dialog groups them into Darstellung / Abspielen / System and hides the canvas tuning while the canvas is off. Stored settings move to musicmouse.perf.v2, since v1 drafts held the old preset in full. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
457 lines
13 KiB
CSS
457 lines
13 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 var(--hue));
|
|
--paper: oklch(97% 0.01 var(--hue));
|
|
--accent: oklch(70% 0.16 var(--accent-hue));
|
|
--accent-dim: oklch(78% 0.14 var(--accent-hue));
|
|
--sea-deep: oklch(20% 0.045 var(--hue));
|
|
--shadow: oklch(15% 0.05 var(--hue) / 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 mascotBob {
|
|
0%,
|
|
100% {
|
|
transform: translateY(0) rotate(-2deg);
|
|
}
|
|
50% {
|
|
transform: translateY(-10px) rotate(2deg);
|
|
}
|
|
}
|
|
|
|
@keyframes mascotSwim {
|
|
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); }
|
|
}
|
|
|
|
/* The walking mascot's crossing: the same timeline as `mascotSwim`, turn included, but
|
|
along one level line instead of in waves. `mascotWaddle` runs beside it on the
|
|
separate `rotate`/`translate` properties, which compose with this `transform`. */
|
|
@keyframes mascotWalk {
|
|
0% { transform: translate(-20vw, 10vh) scaleX(1); }
|
|
70% { transform: translate(84vw, 10vh) scaleX(1); }
|
|
74% { transform: translate(84vw, 10vh) scaleX(-1); }
|
|
100% { transform: translate(-20vw, 10vh) scaleX(-1); }
|
|
}
|
|
|
|
@keyframes mascotWaddle {
|
|
0%, 100% { rotate: -4deg; translate: 0 0; }
|
|
50% { rotate: 4deg; translate: 0 -6px; }
|
|
}
|
|
|
|
/* 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;
|
|
/* 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. The skin supplies
|
|
them (`stageGradient` in lib/skins/), pre-converted from oklch(55% 0.07 hue) /
|
|
(38% 0.06 hue) / (20% 0.045 hue). */
|
|
background: linear-gradient(180deg, var(--stage-top) 0%, var(--stage-mid) 45%, var(--stage-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;
|
|
}
|
|
|
|
/* A leaf (Drift.tsx, the dinosaur's answer to `.bubble`) tumbling down the stage and
|
|
swaying on its way. Transform only: each leaf's opacity is its own inline style. */
|
|
@keyframes leafFall {
|
|
0% { transform: translate(0, 0) rotate(0deg); }
|
|
25% { transform: translate(28px, 32vh) rotate(80deg); }
|
|
50% { transform: translate(-6px, 64vh) rotate(170deg); }
|
|
75% { transform: translate(24px, 96vh) rotate(250deg); }
|
|
100% { transform: translate(-10px, calc(100vh + 80px)) rotate(330deg); }
|
|
}
|
|
|
|
.leaf {
|
|
position: absolute;
|
|
top: -40px;
|
|
line-height: 1;
|
|
animation-name: leafFall;
|
|
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 var(--hue) / 0.16);
|
|
color: var(--paper);
|
|
}
|
|
|
|
.pill[data-active="true"] {
|
|
background: var(--paper);
|
|
color: oklch(28% 0.04 var(--hue));
|
|
box-shadow: 0 4px 14px var(--shadow);
|
|
}
|
|
|
|
/* `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, rgb(var(--paper-rgb) / .38), rgb(var(--paper-rgb) / .12)) /* oklch(97% 0.01 hue), kept sRGB like .stage */;
|
|
backdrop-filter: blur(8px);
|
|
-webkit-backdrop-filter: blur(8px);
|
|
border: 1px solid oklch(97% 0.01 var(--hue) / 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, rgb(var(--paper-rgb) / .14), rgb(var(--paper-rgb) / .05)) /* oklch(97% 0.01 hue), kept sRGB like .stage */;
|
|
backdrop-filter: blur(10px);
|
|
-webkit-backdrop-filter: blur(10px);
|
|
border: 1px solid oklch(97% 0.01 var(--hue) / 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 var(--hue) / 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 var(--hue) / 0.5);
|
|
}
|
|
|
|
.overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
background: oklch(15% 0.03 var(--hue) / 0.55);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 40px;
|
|
}
|
|
|
|
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 var(--hue) / .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 var(--hue) / .85);
|
|
font-size: 15px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
@keyframes searchbox-blink {
|
|
50% { opacity: 0; }
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.searchbox-caret { animation: none; }
|
|
}
|