Baseline: full tree snapshot after git repository loss

The previous .git directory was found gutted (only info/ remained), so
history is unrecoverable locally. This commit restores version control
with the tree exactly as it stands: the working app, the preview-tracks
launcher on the artist hero, the scrollable-track-previews design bundle
(docs + approved mockups), and the Phase 0 validation artifacts.
This commit is contained in:
KeelrLeek committed 2026-07-22 17:57:00 -05:00
commit a667b4b28e
161 files changed
+53170

No files matched your search

+902
View File
@@ -0,0 +1,902 @@
<script>
import { untrack } from "svelte";
import {
player,
ui,
toggleNowPlaying,
getArt,
getCatalogArt,
jumpToQueueIndex,
discovery,
loadDiscovery,
openCatalogArtist,
streamCatalogAlbum,
openCurrentArtist,
openCurrentAlbum,
openCurrentSong,
libraryStore,
artBroken,
markArtBroken,
playerArtSource,
} from "../lib/state.svelte.js";
import Icon from "./Icon.svelte";
import SaveMenu from "./SaveMenu.svelte";
import { floatScroll } from "../lib/scrollbar.js";
const currentItem = $derived(player.queue[player.queueIndex] ?? null);
// Gate stream-only sections (quality, source, Save) on the displayed
// item's own stream field — the source of truth for what's on screen.
// Library tracks never carry one, so they never show "Save to Library"
// (and a track already in the library always plays locally, never streams).
// player.track.source is derived and can lag across gapless boundaries.
const isStream = $derived(!!currentItem?.stream);
const quality = $derived(currentItem?.quality ?? null);
// Album context: the queue is a catalog album/continuation with >1 track.
const albumMode = $derived(
!!player.catalogRef.albumId && player.queue.length > 1,
);
// Artist name for the Save action: album items carry "artist — album".
const artistName = $derived.by(() => {
const sub = currentItem?.sub ?? "";
return sub.includes(" — ") ? sub.split(" — ")[0].trim() : sub;
});
// Artist / album split for the separate, clickable identity lines.
const metaParts = $derived.by(() => {
const sub = player.track?.sub ?? "";
return sub.includes(" — ")
? sub.split(" — ").map((s) => s.trim())
: [sub.trim(), ""];
});
const LOSSLESS = new Set(["FLAC", "WAV", "ALAC", "AIFF", "APE"]);
// Discovery: load more-from-artist / fans-also-like / about when the
// playing artist changes, but only while the panel is visible. The old
// always-mounted fetch could launch a full artist refinement in the
// background while the user was opening an unrelated artist page.
$effect(() => {
const name = artistName;
const visible = ui.nowPlaying;
if (name && visible) {
// loadDiscovery reads/writes its own reactive progress fields.
// Without this boundary a failed/empty lookup makes `loading=false`
// retrigger the effect indefinitely until Svelte aborts the flush.
untrack(() => loadDiscovery(name));
}
});
function openDiscoveryArtist() {
if (discovery.artistId == null) return;
openCatalogArtist(discovery.artistId, {
id: discovery.artistId,
name: artistName,
picture: discovery.picture,
fans: discovery.fans,
album_count: discovery.albumCount,
});
}
// Collapse the bio back to the clamped state whenever the artist changes.
let bioExpanded = $state(false);
$effect(() => {
discovery.artist;
bioExpanded = false;
});
// Keep the panel body mounted just long enough to finish the close slide,
// then release its reactive waveform work and filtered layers entirely.
// Reopening cancels the pending teardown and restores content immediately.
let panelContentVisible = $state(ui.nowPlaying);
$effect(() => {
if (ui.nowPlaying) {
panelContentVisible = true;
return;
}
const timer = setTimeout(() => (panelContentVisible = false), 300);
return () => clearTimeout(timer);
});
const norm = (s) => (s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
// "More from": full albums ahead of EPs/singles, newest first within each
// tier, drop the album that's playing now, cap at 6.
const typeRank = (t) => (t === "album" ? 0 : t === "ep" ? 1 : 2);
const moreAlbums = $derived.by(() => {
const here = norm(currentItem?.sub?.split(" — ")[1] ?? "");
return [...discovery.albums]
.filter((a) => norm(a.title) !== here)
.sort(
(a, b) =>
typeRank(a.record_type) - typeRank(b.record_type) ||
(b.release_date ?? "").localeCompare(a.release_date ?? ""),
)
.slice(0, 6);
});
const relatedArtists = $derived(discovery.related.slice(0, 4));
// How many of this artist's albums are already in the local library.
const inLibraryCount = $derived.by(() => {
const a = norm(artistName);
if (!a) return 0;
return libraryStore.albums.filter((x) => norm(x.artist) === a).length;
});
function albumYear(a) {
return a.release_date ? a.release_date.slice(0, 4) : "";
}
function fmtFans(n) {
if (!n) return "—";
if (n >= 1e6) return `${(n / 1e6).toFixed(1).replace(/\.0$/, "")}M`;
if (n >= 1e3) return `${Math.round(n / 1e3)}k`;
return String(n);
}
// Album art (local path or catalog CDN) → data URL, reused for the glow.
let artUrl = $state(null);
$effect(() => {
// A closed panel should not duplicate PlayerBar's cold cover-art IPC or
// retain a large data URL that has no visible consumer.
const item = panelContentVisible ? currentItem : null;
artUrl = null;
if (!item) return;
let active = true;
if (item.art) {
getArt(item.art).then((u) => active && (artUrl = u));
} else if (item.artUrl) {
getCatalogArt(playerArtSource(item)).then((u) => active && (artUrl = u));
}
return () => (active = false);
});
const bufferPct = $derived(
player.streamTotal > 0
? Math.min(100, (player.streamReceived / player.streamTotal) * 100)
: 0,
);
const playedPct = $derived(
player.duration ? Math.min(100, (player.position / player.duration) * 100) : 0,
);
// Average dense source peaks into fixed, equally sized display bars. Using
// an average (rather than picking one peak) stops isolated transients from
// making random bars hit the ceiling.
// 78 × 2px bars plus 77 × 2px gaps = 310px. The sidebar's 312px
// waveform lane can center that exact integer footprint, avoiding the
// fractional gap rounding that made some neighboring bars look closer.
const BARS = 78;
const bars = $derived.by(() => {
const w = player.waveform;
if (!w || w.length === 0) return [];
const out = [];
for (let i = 0; i < BARS; i++) {
const from = Math.floor((i / BARS) * w.length);
const to = Math.max(from + 1, Math.floor(((i + 1) / BARS) * w.length));
let sum = 0;
for (let j = from; j < to; j++) sum += w[j] ?? 0;
out.push(sum / (to - from));
}
return out;
});
const playedBars = $derived((playedPct / 100) * bars.length);
function barHeight(value, index) {
const capped = Math.pow(Math.min(0.88, value * 1.18), 0.8);
// Stable per-bar grain keeps a dense loud passage from flattening
// into a ruler of identical maximum-height bars. It is deterministic
// (no visual flicker as the playhead moves) and only matters near the
// cap, where source peaks otherwise converge on the same value.
const grain = ((Math.sin((index + 1) * 12.9898) * 43758.5453) % 1 + 1) % 1;
const peakFactor = Math.max(0, (capped - 0.58) / 0.3);
const level = Math.max(0, Math.min(0.88, capped + (grain - 0.5) * 0.12 * peakFactor));
return 14 + level * 72; // preserve headroom; never peak against the top
}
function barFill(index) {
return Math.max(0, Math.min(1, playedBars - index));
}
function fmtTime(s) {
if (s == null || !isFinite(s)) return "–:––";
s = Math.max(0, Math.floor(s));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
function fmtSize(bytes) {
if (!bytes) return null;
return `${(bytes / 1048576).toFixed(1)} MB`;
}
function fmtRate(q) {
if (!q?.sampleRate) return null;
const khz = (q.sampleRate / 1000).toFixed(1).replace(/\.0$/, "");
return q.bitDepth ? `${khz} kHz · ${q.bitDepth}-bit` : `${khz} kHz`;
}
</script>
<aside class="nowplaying" class:closed={!ui.nowPlaying}>
<!-- Frosted-glass header: it overlays the scrolling content and blurs
whatever sits beneath it, so the album art's colors seep through and
shift as you scroll the art up under the glass. -->
<header class="np-head" data-scrollhead>
<span class="np-title">Now playing</span>
<button class="np-icon" title="Hide panel" onclick={toggleNowPlaying}>
<Icon name="chevron-left" size={16} />
</button>
</header>
<div class="np-scroll" use:floatScroll>
{#if panelContentVisible}
{#if currentItem}
<!-- glowing cover art -->
<div class="hero">
{#if artUrl}
<img class="glow" src={artUrl} alt="" aria-hidden="true" draggable="false" />
<div class="cover-wrap">
<img class="cover" src={artUrl} alt="" draggable="false" />
<button
class="expand"
title="View artwork"
aria-label="View album artwork full screen"
onclick={() => (ui.fullscreenArt = true)}
>
<Icon name="expand" size={15} />
</button>
</div>
{:else}
<div class="cover placeholder"><Icon name="music" size={40} /></div>
{/if}
</div>
<!-- identity: song / artist / album, each its own link -->
<div class="idn">
<div class="idn-text">
<button class="tt link" onclick={openCurrentSong} title={player.track?.title}>
{player.track?.title ?? "—"}
</button>
{#if metaParts[0]}
<button class="ar link" onclick={openCurrentArtist} title={metaParts[0]}>
{metaParts[0]}
</button>
{/if}
{#if metaParts[1]}
<button class="ar link album" onclick={openCurrentAlbum} title={metaParts[1]}>
{metaParts[1]}
</button>
{/if}
</div>
</div>
<!-- mini waveform + times -->
{#if bars.length}
<div class="wave" aria-hidden="true">
{#each bars as v, i}
<i
style="height:{barHeight(v, i)}%; --fill:{barFill(i)}"
></i>
{/each}
</div>
{/if}
<div class="times">
<span>{fmtTime(player.position)}</span>
<span>{fmtTime(player.duration)}</span>
</div>
<!-- quality chips -->
{#if quality}
<div class="chips">
{#if quality.format}
<span class="chip" class:loss={LOSSLESS.has(quality.format)}>
{#if LOSSLESS.has(quality.format)}<span class="dot"></span>{/if}
{quality.format}
</span>
{/if}
{#if quality.bitrate}
<span class="chip" class:loss={LOSSLESS.has(quality.format)}>
{quality.bitrate} kbps{quality.vbr ? " VBR" : ""}
</span>
{/if}
{#if fmtRate(quality)}<span class="chip">{fmtRate(quality)}</span>{/if}
{#if fmtSize(quality.size)}<span class="chip">{fmtSize(quality.size)}</span>{/if}
</div>
{/if}
<!-- source card (streaming only) -->
{#if isStream && currentItem.stream}
<div class="src">
<div class="src-lab">Streaming from</div>
<div class="peer">
<div class="av">{currentItem.stream.username.slice(0, 1).toUpperCase()}</div>
<div class="who" title={currentItem.stream.username}>{currentItem.stream.username}</div>
</div>
{#if player.streamTotal > 0}
<div class="buf">
<div class="bar"><i style="width:{bufferPct}%"></i></div>
<div class="meta">
<span>Buffered {(player.streamReceived / 1048576).toFixed(1)} / {(player.streamTotal / 1048576).toFixed(1)} MB</span>
<span>{Math.round(bufferPct)}%</span>
</div>
</div>
{/if}
</div>
<!-- save the file(s) you're already streaming (no re-search) -->
<div class="actions-row">
<SaveMenu align="down" />
</div>
{/if}
<!-- queue / album tracklist -->
{#if player.queue.length > 1}
<div class="section">
<div class="lab">{albumMode ? "From the album" : "Queue"}</div>
<div class="list">
{#each player.queue as item, i}
<button class="row" class:now={i === player.queueIndex} onclick={() => jumpToQueueIndex(i)}>
<span class="n">
{#if i === player.queueIndex}
<span
class="eq"
class:active={ui.nowPlaying && player.status === "playing" && ui.windowActive}
><b></b><b></b><b></b></span>
{:else}{i + 1}{/if}
</span>
<span class="nm" title={item.title}>{item.title}</span>
<span class="du">{fmtTime(item.duration)}</span>
</button>
{/each}
</div>
</div>
{/if}
<!-- Discovery: more from the artist -->
{#if moreAlbums.length}
<div class="section">
<div class="lab divlab">More from {artistName}</div>
<div class="grid2">
{#each moreAlbums as al}
<button class="mini" title="Play {al.title}" onclick={() => streamCatalogAlbum(al)}>
<div class="art">
{#if al.cover && !artBroken[al.cover]}<img src={al.cover} alt="" loading="lazy" draggable="false" onerror={() => markArtBroken(al.cover)} />{/if}
</div>
<div class="mt">{al.title}</div>
<div class="my">{albumYear(al)}{al.record_type && al.record_type !== "album" ? ` · ${al.record_type}` : ""}</div>
</button>
{/each}
</div>
</div>
{/if}
<!-- Discovery: fans also like -->
{#if relatedArtists.length}
<div class="section">
<div class="lab divlab">Fans also like</div>
<div class="fans">
{#each relatedArtists as ar}
<button class="fan" title={ar.name} onclick={() => openCatalogArtist(ar.id, ar)}>
<div class="av">
{#if ar.picture}<img src={ar.picture} alt="" loading="lazy" draggable="false" />{/if}
</div>
<div class="fn">{ar.name}</div>
</button>
{/each}
</div>
</div>
{/if}
<!-- Discovery: about the artist -->
{#if discovery.artistId && (discovery.picture || discovery.albumCount)}
<div class="section">
<div class="lab divlab">About the artist</div>
<div class="aboutcard">
<button class="bandphoto" title="Open {artistName}" onclick={openDiscoveryArtist}>
{#if discovery.picture}
<img src={discovery.picture} alt="" loading="lazy" draggable="false" />
{:else}
<span class="ph"><Icon name="music" size={28} /></span>
{/if}
</button>
<div class="about-text">
<button class="nm" onclick={openDiscoveryArtist}>{artistName}</button>
{#if discovery.bio}
<div class="bio">
<p class:clamped={!bioExpanded}>{discovery.bio}</p>
<button class="more" onclick={() => (bioExpanded = !bioExpanded)}>
{bioExpanded ? "Show less" : "Read more"}
</button>
</div>
{/if}
</div>
<div class="about-body">
<div class="st"><b>{discovery.albumCount ?? "—"}</b>releases</div>
<div class="st"><b>{fmtFans(discovery.fans)}</b>fans</div>
<div class="st"><b>{inLibraryCount}</b>in library</div>
</div>
</div>
</div>
{/if}
{:else}
<div class="empty">
<Icon name="music" size={30} />
<span>Nothing playing</span>
</div>
{/if}
{/if}
</div>
</aside>
<style>
.nowplaying {
grid-area: nowplaying;
background: var(--bg-player);
border-radius: var(--radius);
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
position: relative;
/* Fixed width so the panel keeps its shape and slides off-screen when
the grid column collapses to 0, rather than squishing its content.
(The grid column uses --nowplaying-width, which animates 340→0.) */
width: 340px;
justify-self: start;
visibility: visible;
transition:
transform 0.28s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.24s ease,
visibility 0s linear;
}
.nowplaying.closed {
transform: translateX(calc(100% + 8px));
opacity: 0;
visibility: hidden;
pointer-events: none;
transition:
transform 0.28s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.24s ease,
visibility 0s linear 0.28s;
}
/* Frosted-glass header floating over the scrolling content: backdrop-filter
blurs whatever is beneath it, so the album colors seep through and shift
as the art scrolls up under the glass. */
.np-head {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 46px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 14px;
z-index: 5;
background: rgba(20, 20, 24, 0.28);
backdrop-filter: blur(20px) saturate(1.7);
-webkit-backdrop-filter: blur(20px) saturate(1.7);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.04);
}
.np-title {
font-size: 13px;
font-weight: 600;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
}
.np-icon {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 6px;
color: var(--text-secondary);
}
.np-icon:hover { background: var(--bg-highlight); color: var(--text-primary); }
.np-scroll {
overflow-y: auto;
overflow-anchor: none;
overscroll-behavior: none;
/* Clear the 46px glass header so the art isn't overlapped at rest; the
art still slides under the glass (tinting it) as you scroll. */
padding: 54px 14px 18px;
display: flex;
flex-direction: column;
gap: 16px;
position: relative;
z-index: 1;
}
/* Keep every section at its natural height and let the panel scroll —
a flex column shrinks children by default, which collapsed the
percentage-height waveform bars on shorter windows. */
.np-scroll > * {
flex-shrink: 0;
}
/* glowing cover art (ambilight: a blurred copy of the same image) */
.hero { position: relative; padding: 6px 4px 10px; }
.glow {
position: absolute;
inset: -2% -2% 6%;
z-index: 0;
width: 104%;
height: 100%;
object-fit: cover;
border-radius: 30px;
filter: blur(38px) saturate(1.4);
opacity: 0.62;
transform: scale(1.02);
pointer-events: none;
}
.cover-wrap { position: relative; z-index: 1; }
.cover {
position: relative;
z-index: 1;
width: 100%;
aspect-ratio: 1;
border-radius: 12px;
object-fit: cover;
background: var(--bg-highlight);
box-shadow: 0 18px 38px -18px rgba(0, 0, 0, 0.7);
display: block;
}
/* Corner expand control, Spotify-style: a dark glass disc pinned inside
the art's top-right corner, revealed on hover (or keyboard focus). */
.expand {
position: absolute;
top: 10px;
right: 10px;
z-index: 2;
width: 30px;
height: 30px;
border-radius: 50%;
display: grid;
place-items: center;
color: #fff;
background: rgba(10, 10, 14, 0.6);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
opacity: 0;
transform: translateY(-3px) scale(0.9);
transition: opacity 0.16s ease, transform 0.16s ease, background 0.16s ease;
}
.cover-wrap:hover .expand,
.expand:focus-visible {
opacity: 1;
transform: none;
}
.expand:hover { background: rgba(10, 10, 14, 0.85); }
@media (prefers-reduced-motion: reduce) {
.expand { transition: opacity 0.16s ease; transform: none; }
}
.cover.placeholder {
display: grid;
place-items: center;
color: var(--text-muted);
}
.idn { display: flex; align-items: flex-start; gap: 10px; }
.idn-text { min-width: 0; display: flex; flex-direction: column; align-items: flex-start; }
/* Each identity line is its own link — width-constrained so the hover
underline hugs the text, and ellipsis-truncated. */
.tt,
.ar {
max-width: 100%;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
}
.tt {
font-size: 21px;
font-weight: 700;
letter-spacing: -0.01em;
line-height: 1.2;
}
.tt.link:hover { text-decoration: underline; }
.ar {
color: var(--text-secondary);
font-size: 13.5px;
margin-top: 3px;
}
.ar.link:hover { color: var(--text-primary); text-decoration: underline; }
.ar.album {
font-size: 12.5px;
color: var(--text-muted);
margin-top: 1px;
}
.ar.album.link:hover { color: var(--text-primary); }
/* mini waveform */
.wave {
display: flex;
justify-content: center;
align-items: end;
gap: 2px;
height: 38px;
}
.wave i {
flex: 0 0 2px;
width: 2px;
border-radius: 1px;
background: color-mix(in srgb, var(--accent) calc(var(--fill) * 100%), var(--text-muted));
min-height: 2px;
}
.times {
display: flex;
justify-content: space-between;
font-size: 11.5px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
margin-top: -8px;
}
/* quality chips */
.chips { display: flex; flex-wrap: wrap; gap: 7px; }
.chip {
font-size: 12px;
padding: 4px 11px;
border-radius: 999px;
background: var(--bg-highlight);
color: var(--text-primary);
font-weight: 550;
display: inline-flex;
align-items: center;
gap: 6px;
}
.chip.loss { color: #4bd08a; }
.chip .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
/* source card */
.src {
background: var(--bg-elevated);
border-radius: 10px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.src-lab {
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
font-weight: 600;
}
.peer { display: flex; align-items: center; gap: 10px; min-width: 0; }
.av {
width: 32px;
height: 32px;
border-radius: 9px;
flex: 0 0 auto;
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
display: grid;
place-items: center;
font-weight: 700;
font-size: 13px;
color: #04222f;
}
.who {
font-size: 13px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.buf { display: flex; flex-direction: column; gap: 5px; }
.buf .bar { height: 6px; border-radius: 3px; background: var(--bg-highlight); overflow: hidden; }
.buf .bar i {
display: block;
height: 100%;
border-radius: 3px;
background: linear-gradient(90deg, var(--accent), var(--accent-hover));
}
.buf .meta {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
/* save action row */
.actions-row {
display: flex;
align-items: center;
gap: 8px;
}
/* tracklist */
.section { display: flex; flex-direction: column; gap: 10px; }
.lab {
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
font-weight: 600;
}
.list { display: flex; flex-direction: column; }
.row {
display: flex;
align-items: center;
gap: 12px;
padding: 7px 8px;
border-radius: 6px;
font-size: 13px;
color: var(--text-secondary);
text-align: left;
width: 100%;
}
.row:hover { background: var(--bg-highlight); }
.row .n {
width: 16px;
text-align: center;
font-size: 12px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
flex: 0 0 auto;
}
.row .nm { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.row .du { font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; flex: 0 0 auto; }
.row.now { color: var(--accent); font-weight: 600; }
.row.now .n { color: var(--accent); }
.eq { display: inline-flex; align-items: flex-end; gap: 1.5px; width: 14px; height: 11px; }
.eq b { width: 2.5px; background: var(--accent); border-radius: 1px; }
/* A stepped equalizer keeps the playing cue alive without asking Chromium
to repaint the glass panel and artist page on every display frame. */
.eq.active b { animation: eq 1.2s steps(1, end) infinite; }
.eq b:nth-child(1) { height: 40%; animation-delay: -0.2s; }
.eq b:nth-child(2) { height: 90%; }
.eq b:nth-child(3) { height: 60%; animation-delay: -0.4s; }
@keyframes eq { 0%, 100% { transform: scaleY(0.4); } 50% { transform: scaleY(1); } }
@media (prefers-reduced-motion: reduce) { .eq.active b { animation: none; } }
/* discovery: more-from-artist grid */
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
/* min-width:0 lets both columns stay an equal 1fr; without it a long
(nowrap) title forces its column wider and shrinks the other. */
.mini { text-align: left; min-width: 0; }
.mini .art {
width: 100%;
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
background: var(--bg-highlight);
}
.mini .art img { width: 100%; height: 100%; object-fit: cover; display: block; }
.mini:hover .art { outline: 2px solid var(--accent); outline-offset: -2px; }
.mini .mt {
font-size: 12.5px;
font-weight: 600;
margin-top: 6px;
line-height: 1.25;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 6;
}
.mini .my { font-size: 11.5px; color: var(--text-muted); text-transform: capitalize; }
/* discovery: fans also like — a fixed row of 5 (no horizontal scroll,
which always left one avatar half-cut at the edge). Explicit square
avatars (aspect-ratio inside a flex row gets squished to an oval);
space-between fits all five within the panel with no cutoff. */
.fans { display: flex; justify-content: space-evenly; padding: 4px 0; }
.fan { width: 52px; text-align: center; }
.fan .av {
width: 52px;
height: 52px;
border-radius: 50%;
overflow: hidden;
background: var(--bg-highlight);
}
.fan .av img { width: 100%; height: 100%; object-fit: cover; display: block; }
.fan:hover .av { outline: 2px solid var(--accent); outline-offset: 2px; }
.fan .fn {
font-size: 11px;
margin-top: 7px;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* discovery: about the artist */
.aboutcard {
display: block;
width: 100%;
border-radius: 12px;
overflow: hidden;
background: var(--bg-elevated);
}
/* Full square Deezer image, uncropped (square img in a square box), with a
tall bottom fade to the card colour so the photo melts into the text. */
.bandphoto {
display: block;
position: relative;
width: 100%;
aspect-ratio: 1;
text-align: left;
}
.bandphoto img { width: 100%; height: 100%; object-fit: cover; display: block; }
.bandphoto .ph {
width: 100%;
aspect-ratio: 1;
display: grid;
place-items: center;
color: var(--text-muted);
}
.bandphoto::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(
to top,
var(--bg-elevated) 6%,
color-mix(in srgb, var(--bg-elevated) 55%, transparent) 22%,
transparent 48%
);
}
/* Name + bio ride up into the faded bottom of the photo, so the text
reads as emerging from the image. */
.about-text {
position: relative;
z-index: 2;
margin-top: -52px;
padding: 0 13px;
}
.about-text .nm {
display: block;
text-align: left;
font-size: 19px;
font-weight: 700;
color: #fff;
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.75);
margin-bottom: 8px;
}
.about-text .nm:hover { text-decoration: underline; }
.bio { padding: 0 0 2px; }
.bio p {
margin: 0;
font-size: 12.5px;
line-height: 1.5;
color: var(--text-secondary);
}
.bio p.clamped {
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.bio .more {
margin-top: 4px;
font-size: 12px;
font-weight: 600;
color: var(--accent-hover);
}
.bio .more:hover { text-decoration: underline; }
.about-body { display: flex; gap: 18px; padding: 12px 13px 13px; }
.about-body .st { font-size: 11px; color: var(--text-muted); }
.about-body .st b {
display: block;
color: var(--text-primary);
font-size: 14px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 60px 0;
color: var(--text-muted);
font-size: 13px;
}
</style>