/* 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;
/* 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
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, #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);
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, #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);
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;
}
/* 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; }
}