/* 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 `` 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; }