android: the artist page, redesigned — the editorial shape

Kyle called the artist page "super glitchy" and asked for a rework in the
style of other music apps on mobile; from three mockups he picked the
editorial one (research/mobile-mockup, look B). What made it glitchy: six
sections each popped in on their own as their data landed, so the page
reflowed six times; the hero dissolved again every time a better picture
arrived; a cloud of stat chips wrapped to three lines; and the discography
tab jumped when the identity merge re-filed releases.

The page now: a 300dp portrait with the name on it and the play disc beside
it, Shuffle · Follow · one line of facts, the latest release as a card, Top
Songs as swipeable columns of four with dots ("See All" opens the list),
Albums and Singles & EPs as carousels with a link to the full discography,
On this phone, Similar Artists, and About with the bio, tags and socials.
The first sharp picture is the picture, locked; a cold visit renders
skeletons the size of the sections that are coming and swaps once; there
are no tabs on the page, the rails simply grow. No band over the hero — the
shell's two floating discs are the only chrome, as everywhere else. The
hero and its copy behind the scroller are both 300px now, as they must be.
This commit is contained in:
KeelrLeek committed 2026-09-18 17:29:17 -05:00
1 parent bb7f1f1b2d
commit 69bb722590
2 files changed
+319 -193

No files matched your search

+237 -183
View File
@@ -1,15 +1,25 @@
<script> <script>
/* The artist page, the desktop's (CatalogArtist.svelte + ArtistExtras) /* The artist page — Kyle's pick from three redesigns (2026-09-18,
at phone scale: the banner with the wordmark or the name on it, the research/mobile-mockup `?look=b`): the editorial shape. A 300dp
identity row — Play popular, Follow, releases, fans, years active, portrait with the name ON it and the play disc beside it, one line of
the genre, the socials — Popular with its listener bars, the latest facts, the LATEST RELEASE card first, Top Songs as swipeable columns
release, the discography filed the desktop's way (tabs, editions of four, then Albums / Singles & EPs / On this phone as carousels,
folded into families, what the library holds marked), releases by Similar Artists, and About.
year, fans also like, what is on this phone, and About.
Opens cold: the library's own shell at once (its portrait blurred as What made the old page "super glitchy" (his word) and what this one
a seed when it is only a cover), the catalog lookup dissolving the does about it:
rest in — dissolve, never pop. */ - six sections each popped in on their own as their data landed, so
the page reflowed six times — now the cold page renders skeletons
the size of what is coming and swaps once when the catalog lands;
- the hero re-dissolved every time a better picture arrived — now the
first sharp picture is the picture, locked;
- a cloud of stat chips wrapped to three lines — now one line, cut
with an ellipsis;
- the discography tab jumped when the identity merge re-filed
releases — there are no tabs here; the rails just grow.
No bar over the hero: a pushed page floats its two controls (the
shell's .fchrome discs) and the title is the page's own. */
import { untrack } from "svelte"; import { untrack } from "svelte";
import Icon from "../../components/Icon.svelte"; import Icon from "../../components/Icon.svelte";
import Release from "../Release.svelte"; import Release from "../Release.svelte";
@@ -22,14 +32,14 @@
import { push, openHalf } from "../nav.svelte.js"; import { push, openHalf } from "../nav.svelte.js";
import { catalog, lookupArtist, artistKey, fans, yearOf, looseTitle, loadExtras, loadPopular, loadHeroSeed, loadOverview, picture } from "../catalog.svelte.js"; import { catalog, lookupArtist, artistKey, fans, yearOf, looseTitle, loadExtras, loadPopular, loadHeroSeed, loadOverview, picture } from "../catalog.svelte.js";
import { resolver, streamSong, streamCatalogAlbum } from "../resolve.svelte.js"; import { resolver, streamSong, streamCatalogAlbum } from "../resolve.svelte.js";
import { discoTabs, activeYears, latestOf, timelineOf, isUpcoming, SOCIAL_LABEL } from "../disco.js"; import { discoTabs, activeYears, latestOf, isUpcoming, SOCIAL_LABEL } from "../disco.js";
import { shuffled, mmss, ago, fmtCount } from "../util.js"; import { shuffled, mmss, ago, fmtCount } from "../util.js";
/** `seed`: the search's own row (a CatalogArtist), when the page was /** `seed`: the search's own row (a CatalogArtist), when the page was
* opened from one — the identity is then known, no name search. */ * opened from one — the identity is then known, no name search. */
let { name, seed = null, screenEl = null } = $props(); let { name, seed = null, screenEl = null } = $props();
/* the desktop's play-stats chips: this phone's own listening to the artist */ /* this phone's own listening to the artist, for the About paragraph */
let plays = $state({ key: "", seconds: 0, last: null, topTitle: null, topCount: 0 }); let plays = $state({ key: "", seconds: 0, last: null, topTitle: null, topCount: 0 });
$effect(() => { $effect(() => {
const n = name; const n = name;
@@ -38,7 +48,7 @@
api.artistPlayStats(n).then((st) => { api.artistPlayStats(n).then((st) => {
if (plays.key !== n) return; if (plays.key !== n) return;
plays = { key: n, seconds: st?.seconds ?? 0, last: st?.last_played_at ?? null, topTitle: st?.top_title ?? null, topCount: st?.top_count ?? 0 }; plays = { key: n, seconds: st?.seconds ?? 0, last: st?.last_played_at ?? null, topTitle: st?.top_title ?? null, topCount: st?.top_count ?? 0 };
}, () => { /* the chips stay hidden */ }); }, () => { /* the line stays out */ });
}); });
const playTime = $derived(!plays.seconds || plays.seconds < 60 ? "" : plays.seconds < 3600 ? Math.round(plays.seconds / 60) + " min" : (plays.seconds / 3600).toFixed(1) + " hours"); const playTime = $derived(!plays.seconds || plays.seconds < 60 ? "" : plays.seconds < 3600 ? Math.round(plays.seconds / 60) + " min" : (plays.seconds / 3600).toFixed(1) + " hours");
@@ -52,18 +62,11 @@
const entry = $derived(catalog.artists[artistKey(name)] ?? null); const entry = $derived(catalog.artists[artistKey(name)] ?? null);
const page = $derived(entry?.page ?? null); const page = $derived(entry?.page ?? null);
const artistId = $derived(page?.artist?.id ?? entry?.id ?? seed?.id ?? null); const artistId = $derived(page?.artist?.id ?? entry?.id ?? seed?.id ?? null);
/* the two fast lanes the page does not wait for: the banner on its own
(a hero chosen on this phone is read off the file, so it is up before
any network answers) and the one-round content stage. Both sit strictly
BELOW the page everywhere they are read — what the exhaustive pass
returns always wins. */
const heroSeed = $derived(artistId != null ? catalog.heroSeeds[artistId]?.banner ?? null : null); const heroSeed = $derived(artistId != null ? catalog.heroSeeds[artistId]?.banner ?? null : null);
const overview = $derived(artistId != null ? catalog.overviews[artistId] ?? null : null); const overview = $derived(artistId != null ? catalog.overviews[artistId] ?? null : null);
const shownName = $derived(page?.artist?.name || overview?.artist?.name || seed?.name || name); const shownName = $derived(page?.artist?.name || overview?.artist?.name || seed?.name || name);
const heroUrl = $derived(page?.banner || heroSeed || picture(page?.artist?.picture) || picture(entry?.seed?.picture) || picture(seed?.picture) || null); const heroUrl = $derived(page?.banner || heroSeed || picture(page?.artist?.picture) || picture(entry?.seed?.picture) || picture(seed?.picture) || null);
const logoUrl = $derived(page?.logos?.[0] ?? null); const logoUrl = $derived(page?.logos?.[0] ?? null);
/* once per name — untracked, or the entry the lookup writes would re-arm
this effect, and a lookup that fails would fail forever, hot */
$effect(() => { const n = name, sd = seed; untrack(() => lookupArtist(n, sd)); }); $effect(() => { const n = name, sd = seed; untrack(() => lookupArtist(n, sd)); });
$effect(() => { if (seedPath) wantArt(seedPath); }); $effect(() => { if (seedPath) wantArt(seedPath); });
$effect(() => { if (heroUrl) wantArt(heroUrl); }); $effect(() => { if (heroUrl) wantArt(heroUrl); });
@@ -72,21 +75,24 @@
const nm = page?.artist?.name || name; const nm = page?.artist?.name || name;
if (id != null) untrack(() => loadExtras(id, nm)); if (id != null) untrack(() => loadExtras(id, nm));
}); });
/* three lanes off the same id, all of them ahead of the page: Popular's
ranked rows, the banner on its own, and the first page of releases.
Asked the moment an id is known — from the search's own row, or from
the identity search — so the page can paint before it is built */
$effect(() => { const id = artistId; if (id != null) untrack(() => { loadPopular(id); loadHeroSeed(id); loadOverview(id); }); }); $effect(() => { const id = artistId; if (id != null) untrack(() => { loadPopular(id); loadHeroSeed(id); loadOverview(id); }); });
const seedSrc = $derived(seedPath ? art[seedPath] : ""); const seedSrc = $derived(seedPath ? art[seedPath] : "");
const heroSrc = $derived(heroUrl ? art[heroUrl] : ""); const heroSrc = $derived(heroUrl ? art[heroUrl] : "");
/* the copy behind the scroller, which is the one allowed under the clock */ /* the first sharp picture is THE picture. It used to follow `heroSrc`,
$effect(() => { heroArt.src = heroSrc || seedSrc || ""; return () => { heroArt.src = ""; }; }); and every better banner that arrived — the seed, the portrait, the
page's own — dissolved in over the last one: three dissolves on one
visit read as the page flickering. */
let heroShown = $state("");
$effect(() => { const s = heroSrc; if (s && !heroShown) heroShown = s; });
$effect(() => { heroArt.src = heroShown || seedSrc || ""; return () => { heroArt.src = ""; }; });
const extras = $derived(artistId != null ? catalog.extras[artistId] ?? null : null); const extras = $derived(artistId != null ? catalog.extras[artistId] ?? null : null);
const socials = $derived(page?.socials?.length ? page.socials : (extras?.socials ?? [])); const socials = $derived(page?.socials?.length ? page.socials : (extras?.socials ?? []));
/* nothing from the catalog yet: the shell, with skeletons where the
sections will be, and one swap when it lands */
const loading = $derived(entry?.status === "loading" && !page);
/* the library's rows for this artist, by loose title, so Popular and the const libByTitle = $state(new Map());
discography can say what is already here */ let libMap = $state(new Map());
let libByTitle = $state(new Map());
$effect(() => { $effect(() => {
const as = albums; const as = albums;
let live = true; let live = true;
@@ -97,56 +103,56 @@
try { tr = await tracksOf(a.id); } catch { tr = []; } try { tr = await tracksOf(a.id); } catch { tr = []; }
for (const t of tr) if (!m.has(looseTitle(t.title))) m.set(looseTitle(t.title), { album: a, track: t }); for (const t of tr) if (!m.has(looseTitle(t.title))) m.set(looseTitle(t.title), { album: a, track: t });
} }
if (live) libByTitle = m; if (live) libMap = m;
})(); })();
return () => { live = false; }; return () => { live = false; };
}); });
void libByTitle;
/* ── Popular ─────────────────────────────────────────────────────── */ /* ── the facts line ──────────────────────────────────────────────── */
/* the page's own list once it lands (the same Last.fm order, from the const fanCount = $derived(page?.artist?.fans || overview?.artist?.fans || seed?.fans || 0);
same inputs); the fast lane's until then, and for an artist whose
page came back with no Popular at all */
const fastPopular = $derived(artistId != null ? catalog.popular[artistId]?.tracks ?? [] : []);
const popularAll = $derived(page?.popular_tracks?.length ? page.popular_tracks : fastPopular);
let showAllPopular = $state(false);
const popular = $derived((showAllPopular ? popularAll : popularAll.slice(0, 5)).map((t) => ({ t, hit: libByTitle.get(looseTitle(t.title)) || null })));
const popPeak = $derived(popularAll.reduce((m, t) => Math.max(m, t.listeners || 0), 0));
const share = (t) => (popPeak ? Math.max(0.04, (t.listeners || 0) / popPeak) : 0);
/* ── the discography, filed ──────────────────────────────────────── */
/* the overview's raw first page holds the discography up until the full
page lands with the classified one — never the other way round: the
page's list is a strict upgrade, folded and filed */
const discoAll = $derived(page?.albums ?? overview?.albums ?? []); const discoAll = $derived(page?.albums ?? overview?.albums ?? []);
const tabs = $derived(discoTabs(discoAll)); const years = $derived(activeYears(discoAll));
let tab = $state("album"); const facts = $derived([fans(fanCount), years, page?.top_genre].filter(Boolean).join(" · "));
let tabFor = null;
$effect(() => { /* ── Top Songs ───────────────────────────────────────────────────── */
const id = artistId; const fastPopular = $derived(artistId != null ? catalog.popular[artistId]?.tracks ?? [] : []);
const ts = tabs; const popularAll = $derived((page?.popular_tracks?.length ? page.popular_tracks : fastPopular).map((t) => ({ t, hit: libMap.get(looseTitle(t.title)) || null })));
if (!ts.length) return; let showAllPopular = $state(false);
untrack(() => { const PAGE = 4;
const albumsFirst = () => ts.find((t) => t.key === "album")?.key ?? ts[0].key; const topPages = $derived.by(() => {
if (tabFor !== id) { tabFor = id; tab = albumsFirst(); } const top = popularAll.slice(0, 12);
else if (!ts.some((t) => t.key === tab)) tab = albumsFirst(); const out = [];
}); for (let i = 0; i < top.length; i += PAGE) out.push(top.slice(i, i + PAGE));
return out;
}); });
/* the grid is a SLICE — the whole tab lives on the discography page, let pageIdx = $state(0);
which the head's "All" and the link under the grid both open. Before let pagesEl = $state(null);
this an artist with 140 releases rendered 140 cards here. */ function onPages() {
const GRID_CAP = 12; const el = pagesEl;
const activeTab = $derived(tabs.find((t) => t.key === tab) ?? null); const col = el?.firstElementChild;
const families = $derived((activeTab?.items ?? []).slice(0, GRID_CAP)); if (!el || !col) return;
const tabCount = $derived(activeTab?.items.length ?? 0); pageIdx = Math.max(0, Math.min(topPages.length - 1, Math.round(el.scrollLeft / (col.offsetWidth + 12))));
}
/* ── the discography, as rails ───────────────────────────────────── */
const tabs = $derived(discoTabs(discoAll));
const RAIL_CAP = 12;
const albumFamilies = $derived((tabs.find((t) => t.key === "album")?.items ?? []).slice(0, RAIL_CAP));
const shortTabs = $derived(tabs.filter((t) => t.key === "ep" || t.key === "single"));
const shortFamilies = $derived(
shortTabs.flatMap((t) => t.items.map((f) => ({ f, kind: t.key === "ep" ? "EP" : "Single" })))
.sort((a, b) => String(b.f.release_date ?? "").localeCompare(String(a.f.release_date ?? "")))
.slice(0, RAIL_CAP),
);
const inLib = (a) => albums.find((x) => looseTitle(x.title) === looseTitle(a.title)) || null; const inLib = (a) => albums.find((x) => looseTitle(x.title) === looseTitle(a.title)) || null;
const familyLib = (f) => (f.editions || [f]).map(inLib).find(Boolean) || null; const familyLib = (f) => (f.editions || [f]).map(inLib).find(Boolean) || null;
const familyKeyOf = (f) => f.familyKey ?? f.key ?? f.id; const familyKeyOf = (f) => f.familyKey ?? f.key ?? f.id;
const latest = $derived(latestOf(discoAll)); const latest = $derived(latestOf(discoAll));
const timeline = $derived(timelineOf(discoAll));
const years = $derived(activeYears(discoAll));
const releasedCount = $derived(discoAll.filter((a) => !isUpcoming(a)).length); const releasedCount = $derived(discoAll.filter((a) => !isUpcoming(a)).length);
const latestKind = $derived(latest ? ((latest.record_type || "album").toLowerCase() === "single" ? "Single" : (latest.record_type || "album").toLowerCase() === "ep" ? "EP" : "Album") : "");
/* ── the identity row ────────────────────────────────────────────── */ /* ── identity ────────────────────────────────────────────────────── */
const followed = $derived(isFollowed(shownName)); const followed = $derived(isFollowed(shownName));
let followBusy = $state(false); let followBusy = $state(false);
async function follow() { async function follow() {
@@ -155,11 +161,13 @@
try { await toggleFollow(artistId, shownName, page?.artist?.picture ?? seed?.picture ?? null); } catch (e) { player.error = String(e); } try { await toggleFollow(artistId, shownName, page?.artist?.picture ?? seed?.picture ?? null); } catch (e) { player.error = String(e); }
followBusy = false; followBusy = false;
} }
/** The opener plugin (an Android intent); the desktop command as the
* fallback for a build without it. */
function openLink(url) { function openLink(url) {
openUrl(url).catch(() => api.openExternal(url).catch((e) => { player.error = String(e); })); openUrl(url).catch(() => api.openExternal(url).catch((e) => { player.error = String(e); }));
} }
const SOCIAL_ICON = {
bandcamp: "brand-bandcamp", instagram: "brand-instagram", youtube: "brand-youtube", twitter: "brand-x", x: "brand-x",
facebook: "brand-facebook", soundcloud: "brand-soundcloud", tiktok: "brand-tiktok", website: "globe",
};
/* ── playing ─────────────────────────────────────────────────────── */ /* ── playing ─────────────────────────────────────────────────────── */
const current = (t) => player.track?.id === "cat:" + t.id; const current = (t) => player.track?.id === "cat:" + t.id;
@@ -176,7 +184,11 @@
}); });
} catch (e) { player.error = String(e); } } catch (e) { player.error = String(e); }
} }
function playTop() { const p = popular[0]; if (p) playPopular(p); } async function playTop() {
const p = popularAll[0];
if (p) { await playPopular(p); return; }
if (albums.length) { try { await playAlbum(albums[0], await tracksOf(albums[0].id), 0); } catch (e) { player.error = String(e); } }
}
function morePopular(p) { function morePopular(p) {
if (p.hit) { openHalf("track", { album: p.hit.album, track: p.hit.track }); return; } if (p.hit) { openHalf("track", { album: p.hit.album, track: p.hit.track }); return; }
const t = p.t; const t = p.t;
@@ -192,11 +204,16 @@
await streamCatalogAlbum({ ...a, artist: shownName }, 0); await streamCatalogAlbum({ ...a, artist: shownName }, 0);
} catch (e) { player.error = String(e); } } catch (e) { player.error = String(e); }
} }
async function shuffleAll() { /** Shuffle: the records on this phone when there are any; a random
* popular song otherwise, so the button always does something. */
async function shuffle() {
try { try {
const es = []; if (albums.length) {
for (const a of albums) for (const t of await tracksOf(a.id)) es.push({ t, album: a }); const es = [];
if (es.length) await playEntries(shuffled(es), 0, shownName); for (const a of albums) for (const t of await tracksOf(a.id)) es.push({ t, album: a });
if (es.length) { await playEntries(shuffled(es), 0, shownName); return; }
}
if (popularAll.length) await playPopular(popularAll[Math.floor(Math.random() * Math.min(popularAll.length, 10))]);
} catch (e) { player.error = String(e); } } catch (e) { player.error = String(e); }
} }
function openRecord(a) { function openRecord(a) {
@@ -211,132 +228,170 @@
} }
function openRelated(a) { push("artist", { name: a.name, seed: a }, screenEl); } function openRelated(a) { push("artist", { name: a.name, seed: a }, screenEl); }
let bioOpen = $state(false); let bioOpen = $state(false);
const empty = $derived(!albums.length && !popularAll.length && !tabs.length && !loading);
</script> </script>
<div class="hero"> <div class="hero ar">
<div class="hero-img seed" style:background-image={seedSrc ? `url("${seedSrc}")` : null}></div> <div class="hero-img seed" style:background-image={seedSrc ? `url("${seedSrc}")` : null}></div>
{#if heroSrc} {#if heroShown}
<div class="hero-img dissolve" style:background-image={`url("${heroSrc}")`}></div> <div class="hero-img dissolve" style:background-image={`url("${heroShown}")`}></div>
{/if} {/if}
<div class="hero-scrim"></div> <div class="hero-scrim"></div>
<div class="hero-in"> <div class="hero-in row2">
{#if logoUrl} <div class="hero-id">
<img class="hero-logo" src={logoUrl} alt={shownName} /> <div class="ar-kick">Artist</div>
{:else} {#if logoUrl}
<div class="hero-name">{shownName}</div> <img class="hero-logo" src={logoUrl} alt={shownName} />
{/if} {:else}
<h1 class="hero-name">{shownName}</h1>
{/if}
</div>
<button class="disc" onclick={playTop} disabled={!popularAll.length && !albums.length} aria-label="Play {shownName}"><Icon name="play" size={22} /></button>
</div> </div>
</div> </div>
<div class="hero-acts"> <div class="ar-acts">
<button class="disc lg" onclick={playTop} disabled={!popular.length} aria-label="Play popular songs"><Icon name="play" size={26} /></button> <button class="btn btn-ghost" onclick={shuffle} disabled={!albums.length && !popularAll.length}><Icon name="shuffle" size={16} />Shuffle</button>
<div class="stats"> <button class="btn btn-line" class:on={followed} onclick={follow} disabled={artistId == null || followBusy} aria-pressed={followed}>
<button class="s fb" class:on={followed} onclick={follow} disabled={artistId == null || followBusy} aria-pressed={followed}> <Icon name={followed ? "check" : "plus"} size={15} />{followed ? "Following" : "Follow"}
<Icon name={followed ? "check" : "plus"} size={12} strokeWidth={3} />{followed ? "Following" : "Follow"} </button>
</button> {#if facts}<span class="ar-facts">{facts}</span>{/if}
{#if releasedCount}<span class="s"><Icon name="disc" size={12} strokeWidth={3} />{releasedCount} releases</span>{/if}
{#if page?.artist?.fans || overview?.artist?.fans || seed?.fans}<span class="s"><Icon name="users" size={12} strokeWidth={3} />{fans(page?.artist?.fans || overview?.artist?.fans || seed?.fans)}</span>{/if}
{#if years}<span class="s"><Icon name="calendar" size={12} strokeWidth={3} />{years}</span>{/if}
{#if page?.top_genre}<span class="s"><Icon name="tag" size={12} strokeWidth={3} />{page.top_genre}</span>{/if}
{#if playTime}<span class="s played"><Icon name="history" size={12} strokeWidth={3} />{playTime} played</span>{/if}
{#if albums.length}<button class="s quiet" onclick={shuffleAll}><Icon name="shuffle" size={12} strokeWidth={3} />{albums.length} here · shuffle</button>{/if}
{#each socials as link (link.kind + link.url)}
<button class="s quiet soc" onclick={() => openLink(link.url)}><Icon name={link.kind === "website" ? "globe" : "link"} size={12} strokeWidth={3} />{SOCIAL_LABEL[link.kind] || link.kind}</button>
{/each}
</div>
</div> </div>
{#if entry?.status === "loading"}
<div class="sec-note" style="padding-top: 12px">Looking up the rest on the catalog…</div>
{:else if entry?.status === "error"}
<div class="sec-note" style="padding-top: 12px">The catalog did not answer — only what this phone holds is here.</div>
{/if}
{#if popular.length}
<div class="sec"><h2>Popular</h2></div>
{#each popular as p, i (p.t.id)}
<button class="row pop" class:playing={p.hit ? player.track?.path === p.hit.track.path : current(p.t)} onclick={() => playPopular(p)} oncontextmenu={(e) => { e.preventDefault(); morePopular(p); }}>
<span class="rank">{i + 1}</span>
<span class="art">{#if p.t.cover}<img src={p.t.cover} alt="" loading="lazy" />{:else}<Icon name="disc" size={20} />{/if}</span>
<span class="b">
<span class="t">{p.t.title}</span>
<span class="m">{p.t.album}{p.hit ? " · in your library" : ""}{current(p.t) && resolver.active ? " · finding the best source…" : ""}</span>
{#if popPeak}<span class="lbar"><i style="transform: scaleX({share(p.t).toFixed(3)})"></i><em>{fmtCount(p.t.listeners)}</em></span>{/if}
</span>
<span class="r">{mmss(p.t.duration_secs)}</span>
</button>
{/each}
{#if popularAll.length > 5}
<button class="more-link" onclick={() => (showAllPopular = !showAllPopular)}>{showAllPopular ? "Show less" : "Show more"}</button>
{/if}
{/if}
{#if latest} {#if latest}
<div class="sec"><h2>Latest release</h2></div> <div class="ar-latest">
<div class="feat" style="margin: 0 var(--pad)"> <button class="ar-latest-art" onclick={() => openRecord(latest)} aria-label="Open {latest.title}">
<div class="feat-bg" style:background-image={latest.cover ? `url("${latest.cover}")` : null}></div> {#if latest.cover}<img src={latest.cover} alt="" />{:else}<span class="ph"><Icon name="disc" size={28} /></span>{/if}
<div class="feat-in"> </button>
<button class="feat-art" onclick={() => openRecord(latest)} aria-label="Open {latest.title}"> <div class="ar-latest-copy">
{#if latest.cover}<img src={latest.cover} alt="" />{:else}<span class="ph"><Icon name="disc" size={28} /></span>{/if} <div class="k">Latest release{yearOf(latest.release_date) ? ` · ${yearOf(latest.release_date)}` : ""}</div>
</button> <div class="ar-latest-t">{latest.title}</div>
<div class="feat-copy"> <div class="m">{latestKind}{latest.track_count ? ` · ${latest.track_count} ${latest.track_count === 1 ? "song" : "songs"}` : ""}{inLib(latest) ? " · on this phone" : ""}</div>
<div class="k">Latest · {(latest.record_type || "album").toUpperCase()}</div> <div class="ar-latest-acts">
<div class="feat-name">{latest.title}</div> <button class="btn btn-fill sm" onclick={() => playRecord(latest)}><Icon name="play" size={14} />Play</button>
<div class="m">{yearOf(latest.release_date)}{latest.track_count ? ` · ${latest.track_count} tracks` : ""}{inLib(latest) ? " · in your library" : ""}</div> <button class="btn btn-ghost sm" onclick={() => openRecord(latest)}>Open</button>
<div class="feat-acts">
<button class="btn btn-fill" onclick={() => playRecord(latest)}><Icon name="play" size={14} />Play</button>
<button class="btn btn-ghost" onclick={() => openRecord(latest)}>Open</button>
</div>
</div> </div>
</div> </div>
</div> </div>
{:else if loading}
<div class="ar-latest skel-card" aria-hidden="true">
<span class="ar-latest-art skel"></span>
<div class="ar-latest-copy"><span class="skel ln" style="width: 42%; height: 9px"></span><span class="skel ln" style="width: 70%; margin-top: 9px"></span><span class="skel ln" style="width: 50%; height: 9px; margin-top: 8px"></span></div>
</div>
{/if} {/if}
{#if tabs.length} {#if popularAll.length}
<div class="sec"> <div class="sec">
<h2>Discography <em>· {releasedCount} on the catalog</em></h2> <h2>Top Songs</h2>
<button class="all" onclick={() => push("disco", { name }, screenEl)} aria-label="All releases">All<Icon name="chevron-right" size={14} /></button> {#if popularAll.length > PAGE}<button class="all" onclick={() => (showAllPopular = !showAllPopular)}>{showAllPopular ? "Less" : "See All"}<Icon name="chevron-right" size={14} /></button>{/if}
</div> </div>
{#if tabs.length > 1} {#if showAllPopular}
<div class="segtabs disco-tabs" role="tablist" aria-label="Discography"> {#each popularAll as p, i (p.t.id)}
{#each tabs as t (t.key)} <button class="row ar-pop" class:playing={p.hit ? player.track?.path === p.hit.track.path : current(p.t)} onclick={() => playPopular(p)} oncontextmenu={(e) => { e.preventDefault(); morePopular(p); }}>
<button class:on={tab === t.key} onclick={() => (tab = t.key)} role="tab" aria-selected={tab === t.key} aria-label={t.label + ", " + t.items.length}>{t.label}<span class="count" aria-hidden="true">{t.items.length}</span></button> <span class="ar-n">{i + 1}</span>
<span class="art">{#if p.t.cover}<img src={p.t.cover} alt="" loading="lazy" />{:else}<Icon name="disc" size={20} />{/if}</span>
<span class="b">
<span class="t">{p.t.title}</span>
<span class="m">{fmtCount(p.t.listeners) ? `${fmtCount(p.t.listeners)} listeners` : (p.t.album || "")}{p.hit ? " · on this phone" : ""}{current(p.t) && resolver.active ? " · finding a copy…" : ""}</span>
</span>
<span class="r"><span class="ar-dur">{mmss(p.t.duration_secs)}</span></span>
</button>
{/each}
{:else}
<div class="ar-pages" bind:this={pagesEl} onscroll={onPages}>
{#each topPages as col, ci (ci)}
<div class="ar-col">
{#each col as p (p.t.id)}
<button class="row ar-pop tight" class:playing={p.hit ? player.track?.path === p.hit.track.path : current(p.t)} onclick={() => playPopular(p)} oncontextmenu={(e) => { e.preventDefault(); morePopular(p); }}>
<span class="art">{#if p.t.cover}<img src={p.t.cover} alt="" loading="lazy" />{:else}<Icon name="disc" size={20} />{/if}</span>
<span class="b">
<span class="t">{p.t.title}</span>
<span class="m">{p.t.album || (fmtCount(p.t.listeners) ? `${fmtCount(p.t.listeners)} listeners` : "")}{p.hit ? " · on this phone" : ""}{current(p.t) && resolver.active ? " · finding a copy…" : ""}</span>
</span>
<span class="r" role="button" tabindex="0" aria-label="More for {p.t.title}" onkeydown={() => {}} onclick={(e) => { e.stopPropagation(); morePopular(p); }}><Icon name="more" size={18} /></span>
</button>
{/each}
</div>
{/each} {/each}
</div> </div>
{#if topPages.length > 1}
<div class="ar-dots" aria-hidden="true">{#each topPages as _, i (i)}<i class:on={i === pageIdx}></i>{/each}</div>
{/if}
{/if} {/if}
<div class="grid2"> {:else if loading}
{#each families as f (familyKeyOf(f))} <div class="sec"><h2>Top Songs</h2></div>
<div class="ar-pages" aria-hidden="true"><div class="ar-col">
{#each [58, 44, 66, 50] as w (w)}
<div class="skel-row"><span class="skel art"></span><span class="b"><span class="skel ln" style="width: {w}%"></span><span class="skel ln" style="width: {w - 14}%; height: 9px; margin-top: 7px"></span></span></div>
{/each}
</div></div>
{/if}
{#if albumFamilies.length}
<div class="sec">
<h2>Albums <em>· {tabs.find((t) => t.key === "album")?.items.length ?? albumFamilies.length}</em></h2>
<button class="all" onclick={() => push("disco", { name, tab: "album" }, screenEl)} aria-label="All albums">See All<Icon name="chevron-right" size={14} /></button>
</div>
<div class="rail ar-rail">
{#each albumFamilies as f (familyKeyOf(f))}
{@const l = familyLib(f)} {@const l = familyLib(f)}
<Release <Release
title={f.title} title={f.title}
sub={tab === "upcoming" ? "Upcoming" : [yearOf(f.release_date), f.track_count ? `${f.track_count} tracks` : ""].filter(Boolean).join(" · ")} sub={[yearOf(f.release_date), f.track_count ? `${f.track_count} tracks` : ""].filter(Boolean).join(" · ")}
url={f.cover} url={f.cover}
path={l?.art_path ?? null} path={l?.art_path ?? null}
badge={(f.editions?.length ?? 1) > 1 ? `${f.editions.length} editions` : (l ? "in your library" : null)} badge={(f.editions?.length ?? 1) > 1 ? `${f.editions.length} editions` : (l ? "on this phone" : null)}
onclick={() => openFamily(f)} onclick={() => openFamily(f)}
onmore={() => openHalf("catalogAlbum", { album: { ...(f.editions?.[0] ?? f), artist: name } })} onmore={() => openHalf("catalogAlbum", { album: { ...(f.editions?.[0] ?? f), artist: name } })}
/> />
{/each} {/each}
</div> </div>
{#if tabCount > GRID_CAP} {:else if loading}
<button class="more-link dg-all" onclick={() => push("disco", { name, tab }, screenEl)}>Show all {tabCount} {activeTab?.label ?? "releases"}</button> <div class="sec"><h2>Albums</h2></div>
{/if} <div class="rail ar-rail" aria-hidden="true">
{#each [72, 58, 80] as w (w)}
<div class="release skel-card"><span class="cover-wrap skel"></span><span class="skel ln" style="width: {w}%; margin-top: 10px"></span><span class="skel ln" style="width: {w - 30}%; height: 9px; margin-top: 7px"></span></div>
{/each}
</div>
{/if} {/if}
{#if timeline} {#if shortFamilies.length}
<div class="sec"><h2>Releases by year <em>· {timeline.last - timeline.first + 1} years · {timeline.total} releases</em></h2></div> <div class="sec">
<div class="tl"> <h2>Singles &amp; EPs <em>· {shortTabs.reduce((n, t) => n + t.items.length, 0)}</em></h2>
{#each timeline.cols as c (c.year)} <button class="all" onclick={() => push("disco", { name, tab: shortTabs[0].key }, screenEl)} aria-label="All singles and EPs">See All<Icon name="chevron-right" size={14} /></button>
<div class="tl-col" class:empty={!c.n} title="{c.year} · {c.n ? c.n + (c.n === 1 ? ' release' : ' releases') : 'nothing released'}"> </div>
<span class="tl-bar" style="height:{c.h}px"></span> <div class="rail ar-rail sm">
<span class="tl-yr">{String(c.year).slice(2)}</span> {#each shortFamilies as { f, kind } (familyKeyOf(f))}
</div> {@const l = familyLib(f)}
<Release
title={f.title}
sub={[yearOf(f.release_date), kind].filter(Boolean).join(" · ")}
url={f.cover}
path={l?.art_path ?? null}
badge={l ? "on this phone" : null}
onclick={() => openFamily(f)}
onmore={() => openHalf("catalogAlbum", { album: { ...(f.editions?.[0] ?? f), artist: name } })}
/>
{/each}
</div>
{/if}
{#if tabs.length && releasedCount > albumFamilies.length + shortFamilies.length}
<button class="more-link ar-disco" onclick={() => push("disco", { name }, screenEl)}>The full discography · {releasedCount} releases<Icon name="chevron-right" size={14} /></button>
{/if}
{#if albums.length}
<div class="sec"><h2>On this phone <em>· {albums.length} record{albums.length === 1 ? "" : "s"}{tracks ? ` · ${tracks} tracks` : ""}</em></h2></div>
{#if plays.topTitle}<div class="sec-note">Most played here: {plays.topTitle} · {plays.topCount}×{plays.last ? " · last " + ago(plays.last) : ""}</div>{/if}
<div class="rail ar-rail sm">
{#each albums as a (a.id)}
<Release title={a.title} sub={String(a.year || "")} path={a.art_path} onclick={() => push("album", { id: a.id }, screenEl)} />
{/each} {/each}
</div> </div>
{/if} {/if}
{#if extras?.related?.length} {#if extras?.related?.length}
<div class="sec"><h2>Fans also like</h2></div> <div class="sec"><h2>Similar Artists</h2></div>
<div class="rail"> <div class="rail">
{#each extras.related.slice(0, 12) as a (a.id)} {#each extras.related.slice(0, 12) as a (a.id)}
<button class="acard" onclick={() => openRelated(a)}> <button class="acard" onclick={() => openRelated(a)}>
@@ -348,35 +403,34 @@
</div> </div>
{/if} {/if}
{#if albums.length} {#if extras?.bio || extras?.profile?.listeners || extras?.profile?.tags?.length || socials.length || playTime}
<div class="sec"><h2>On this phone <em>· {albums.length} record{albums.length === 1 ? "" : "s"}{tracks ? ` · ${tracks} tracks` : ""}</em></h2></div>
{#if plays.topTitle}<div class="sec-note">Most played here: {plays.topTitle} · {plays.topCount}×{plays.last ? " · last " + ago(plays.last) : ""}</div>{/if}
<div class="grid2">
{#each albums as a (a.id)}
<Release title={a.title} sub={String(a.year || "")} path={a.art_path} onclick={() => push("album", { id: a.id }, screenEl)} />
{/each}
</div>
{/if}
{#if extras?.bio || extras?.profile?.listeners || extras?.profile?.tags?.length}
<div class="sec"><h2>About</h2></div> <div class="sec"><h2>About</h2></div>
<div class="about"> <div class="ar-about">
{#if extras.profile?.listeners || extras.profile?.plays} {#if extras?.profile?.listeners || extras?.profile?.plays || playTime}
<div class="stats"> <div class="ar-facts wide">
{#if extras.profile.listeners}<span class="s"><Icon name="users" size={12} strokeWidth={3} />{fmtCount(extras.profile.listeners)} listeners</span>{/if} {[extras?.profile?.listeners ? `${fmtCount(extras.profile.listeners)} listeners` : "", extras?.profile?.plays ? `${fmtCount(extras.profile.plays)} plays` : "", playTime ? `${playTime} played here` : ""].filter(Boolean).join(" · ")}
{#if extras.profile.plays}<span class="s"><Icon name="play" size={12} strokeWidth={3} />{fmtCount(extras.profile.plays)} plays</span>{/if}
</div> </div>
{/if} {/if}
{#if extras.bio} {#if extras?.bio}
<p class="bio" class:open={bioOpen} onclick={() => (bioOpen = !bioOpen)} role="button" tabindex="0" aria-expanded={bioOpen} onkeydown={() => {}}>{extras.bio}</p> <p class="ar-bio" class:open={bioOpen} onclick={() => (bioOpen = !bioOpen)} role="button" tabindex="0" aria-expanded={bioOpen} onkeydown={() => {}}>{extras.bio}</p>
{/if} {/if}
{#if extras.profile?.tags?.length} {#if extras?.profile?.tags?.length}
<div class="tags">{#each extras.profile.tags.slice(0, 6) as t (t)}<span class="chip quiet">{t}</span>{/each}</div> <div class="chips ar-tags">{#each extras.profile.tags.slice(0, 6) as t (t)}<span class="chip quiet">{t}</span>{/each}</div>
{/if}
{#if socials.length}
<div class="ar-socials">
{#each socials as link (link.kind + link.url)}
<button class="iconbtn muted sm" onclick={() => openLink(link.url)} aria-label={SOCIAL_LABEL[link.kind] || link.kind} title={SOCIAL_LABEL[link.kind] || link.kind}><Icon name={SOCIAL_ICON[link.kind] || "link"} size={18} /></button>
{/each}
</div>
{/if} {/if}
</div> </div>
{/if} {/if}
{#if !albums.length && !popular.length && !tabs.length && entry?.status !== "loading"} {#if entry?.status === "error" && !albums.length && !popularAll.length}
<div class="sec-note" style="padding-top: 12px">The catalog did not answer — only what this phone holds is here.</div>
{/if}
{#if empty}
<div class="stub"><b>Nothing here yet.</b>No records by {shownName} on this phone, and the catalog {entry?.status === "error" ? "didn't answer" : "had nothing to add"}.</div> <div class="stub"><b>Nothing here yet.</b>No records by {shownName} on this phone, and the catalog {entry?.status === "error" ? "didn't answer" : "had nothing to add"}.</div>
{/if} {/if}
+82 -10
View File
@@ -555,7 +555,7 @@ html[data-material="solid"] .card, html[data-material="solid"] .group { backgrou
/* ── the artist hero ────────────────────────────────────────────────────── /* ── the artist hero ──────────────────────────────────────────────────────
The desktop hero is a wide banner beside the identity block. A phone has The desktop hero is a wide banner beside the identity block. A phone has
no beside, so the portrait goes full-bleed and the name sits ON it. */ no beside, so the portrait goes full-bleed and the name sits ON it. */
.hero { position: relative; height: 258px; display: flex; align-items: flex-end; overflow: hidden; margin-bottom: -6px; } .hero { position: relative; height: 300px; display: flex; align-items: flex-end; overflow: hidden; margin-bottom: -6px; }
.hero-img { position: absolute; inset: 0; background-size: cover; background-position: center 24%; transform: scale(1.04); } .hero-img { position: absolute; inset: 0; background-size: cover; background-position: center 24%; transform: scale(1.04); }
/* the blurred SEED: the picture the library already has, soft and /* the blurred SEED: the picture the library already has, soft and
oversized, until a sharp one exists */ oversized, until a sharp one exists */
@@ -568,18 +568,18 @@ html[data-material="solid"] .card, html[data-material="solid"] .group { backgrou
} }
/* The hero's picture again, behind the scroller and outside its mask, so the /* The hero's picture again, behind the scroller and outside its mask, so the
photograph is allowed under the clock while the words are not. Its geometry photograph is allowed under the clock while the words are not. Its geometry
has to match .hero/.hero-img exactly — 258px tall, the same 24% focal point, has to match .hero/.hero-img exactly — 300px tall, the same 24% focal point,
the same 1.04 scale — or the seam shows. */ the same 1.04 scale — or the seam shows. */
.hero-behind { .hero-behind {
position: absolute; position: absolute;
left: 0; left: 0;
right: 0; right: 0;
top: 0; top: 0;
height: 258px; height: 300px;
z-index: 0; z-index: 0;
pointer-events: none; pointer-events: none;
/* the clip .hero gives its own picture; without it the 1.04 below renders /* the clip .hero gives its own picture; without it the 1.04 below renders
268px in a 258px box and five pixels of photograph appear under the 312px in a 300px box and twelve pixels of photograph appear under the
hero's foot, below the black the scrim fades to */ hero's foot, below the black the scrim fades to */
overflow: hidden; overflow: hidden;
transform: translateY(var(--hero-shift, 0px)); transform: translateY(var(--hero-shift, 0px));
@@ -1039,17 +1039,11 @@ html[data-material="solid"] .halfsheet { background: #1c1c20; backdrop-filter: n
release on the app's featured surface (the artwork blurred behind itself); release on the app's featured surface (the artwork blurred behind itself);
releases by year; About. */ releases by year; About. */
.hero-logo { display: block; max-width: 78%; max-height: 96px; object-fit: contain; object-position: left bottom; filter: drop-shadow(0 2px 12px rgba(0, 0, 0, 0.6)); } .hero-logo { display: block; max-width: 78%; max-height: 96px; object-fit: contain; object-position: left bottom; filter: drop-shadow(0 2px 12px rgba(0, 0, 0, 0.6)); }
.hero-acts { display: flex; align-items: center; gap: 12px; padding: 12px var(--pad) 0; }
.hero-acts .stats { flex: 1; min-width: 0; }
.stats button.s { cursor: pointer; } .stats button.s { cursor: pointer; }
.stats .s.fb { background: rgba(255, 255, 255, 0.11); color: var(--text); } .stats .s.fb { background: rgba(255, 255, 255, 0.11); color: var(--text); }
.stats .s.fb.on { background: var(--accent); color: #04121a; } .stats .s.fb.on { background: var(--accent); color: #04121a; }
.stats .s.fb:disabled { opacity: 0.5; } .stats .s.fb:disabled { opacity: 0.5; }
.stats .s.quiet { color: var(--text-secondary); } .stats .s.quiet { color: var(--text-secondary); }
.row.pop .rank { width: 16px; text-align: right; font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; flex: none; }
.lbar { display: flex; align-items: center; gap: 8px; margin-top: 5px; height: 10px; }
.lbar i { display: block; flex: 1; height: 3px; border-radius: 2px; background: var(--accent); transform-origin: left center; opacity: 0.85; }
.lbar em { font-style: normal; font-size: 10.5px; color: var(--text-muted); font-variant-numeric: tabular-nums; min-width: 32px; text-align: right; }
.more-link { display: block; padding: 8px var(--pad) 2px; font-size: 12.5px; font-weight: 750; color: var(--accent-hover); } .more-link { display: block; padding: 8px var(--pad) 2px; font-size: 12.5px; font-weight: 750; color: var(--accent-hover); }
.feat { position: relative; border-radius: var(--r-card); overflow: hidden; background: var(--surface); box-shadow: var(--ring); } .feat { position: relative; border-radius: var(--r-card); overflow: hidden; background: var(--surface); box-shadow: var(--ring); }
.feat-bg { position: absolute; inset: -30px; background-size: cover; background-position: center; filter: blur(28px) saturate(1.2); opacity: 0.55; } .feat-bg { position: absolute; inset: -30px; background-size: cover; background-position: center; filter: blur(28px) saturate(1.2); opacity: 0.55; }
@@ -1683,3 +1677,81 @@ html[data-motion="off"] *, html[data-motion="off"] *::before, html[data-motion="
} }
.dg-eds { display: flex; flex-direction: column; gap: 6px; padding: 0 var(--pad) 12px calc(var(--pad) + 58px); } .dg-eds { display: flex; flex-direction: column; gap: 6px; padding: 0 var(--pad) 12px calc(var(--pad) + 58px); }
.dg-eds .ed { width: 100%; } .dg-eds .ed { width: 100%; }
/* ── the artist page, B (2026-09-18) ──────────────────────────────────────
The editorial shape Kyle picked from three mockups: the name ON the
300dp portrait with the play disc beside it, one line of facts, the
latest release first, Top Songs as swipeable columns of four, the
records as carousels, About last. The bar over the hero is the shell's
two floating discs; nothing else. */
.hero.ar .hero-in { padding-bottom: 14px; }
.hero-in.row2 { display: flex; align-items: flex-end; justify-content: space-between; gap: 14px; }
.hero-in.row2 .hero-id { min-width: 0; flex: 1; }
.hero-in.row2 .disc { flex: none; margin-bottom: 2px; }
.hero.ar .hero-name { margin: 0; overflow-wrap: anywhere; }
.ar-kick { font-size: 11px; font-weight: 800; letter-spacing: 0.9px; text-transform: uppercase; color: rgba(255, 255, 255, 0.7); margin-bottom: 5px; text-shadow: 0 1px 6px rgba(0, 0, 0, 0.5); }
.ar-acts { display: flex; align-items: center; gap: 10px; padding: 14px var(--pad) 6px; }
.ar-facts { margin-left: auto; min-width: 0; font-size: 12.5px; font-weight: 600; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ar-facts.wide { margin: 0 0 8px; white-space: normal; }
.btn-line { box-shadow: inset 0 0 0 1.5px rgba(255, 255, 255, 0.22); color: #fff; background: transparent; }
.btn-line.on { box-shadow: inset 0 0 0 1.5px var(--accent); color: var(--accent-hover); }
.btn-line:disabled { opacity: 0.5; }
.btn.sm { height: 34px; padding: 0 14px; font-size: 13px; }
/* the latest release */
.ar-latest { display: flex; gap: 14px; margin: 6px var(--pad) 4px; padding: 12px; border-radius: var(--r-card); background: var(--surface); box-shadow: var(--ring); }
.ar-latest-art { flex: none; width: 96px; height: 96px; border-radius: 10px; overflow: hidden; background: rgba(255, 255, 255, 0.06); display: block; padding: 0; }
.ar-latest-art img { width: 100%; height: 100%; object-fit: cover; display: block; }
.ar-latest-art .ph { display: flex; width: 100%; height: 100%; align-items: center; justify-content: center; color: var(--text-muted); }
.ar-latest-copy { min-width: 0; flex: 1; display: flex; flex-direction: column; justify-content: center; }
.ar-latest-copy .k { font-size: 10.5px; letter-spacing: 0.8px; text-transform: uppercase; font-weight: 800; color: var(--accent-hover); }
.ar-latest-t { font-size: 15px; font-weight: 800; letter-spacing: -0.2px; line-height: 1.2; margin: 3px 0 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ar-latest-copy .m { font-size: 12px; color: var(--text-secondary); font-weight: 600; }
.ar-latest-acts { display: flex; gap: 8px; margin-top: 10px; }
/* top songs, in columns of four */
.row.ar-pop .ar-n { width: 18px; text-align: right; color: var(--text-muted); font-weight: 700; font-variant-numeric: tabular-nums; font-size: 13px; flex: none; }
.row.ar-pop .r { display: inline-flex; align-items: center; gap: 10px; color: var(--text-muted); }
.row.ar-pop .ar-dur { font-variant-numeric: tabular-nums; font-size: 12.5px; }
.ar-pages { display: flex; gap: 12px; overflow-x: auto; scroll-snap-type: x mandatory; padding: 0 var(--pad); scrollbar-width: none; scroll-padding-inline: var(--pad); }
.ar-pages::-webkit-scrollbar { display: none; }
.ar-col { flex: none; width: 86%; scroll-snap-align: start; }
.ar-col .row { margin: 0 calc(-1 * var(--pad)); }
.ar-col .row.tight { padding-top: 6px; padding-bottom: 6px; }
.ar-dots { display: flex; justify-content: center; gap: 6px; padding: 6px 0 2px; }
.ar-dots i { width: 6px; height: 6px; border-radius: 50%; background: rgba(255, 255, 255, 0.22); transition: background var(--m-fast); }
.ar-dots i.on { background: #fff; }
/* the carousels */
.rail { scroll-padding-inline: var(--pad); }
.ar-rail .release { width: 156px; }
.ar-rail.sm .release { width: 132px; }
.more-link.ar-disco { display: inline-flex; align-items: center; gap: 4px; padding: 14px var(--pad) 4px; }
/* about */
.ar-about { padding: 0 var(--pad); }
.ar-bio { font-size: 13.5px; line-height: 1.6; color: var(--text-secondary); display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; margin: 0; }
.ar-bio.open { display: block; }
.ar-tags { padding: 0; margin-top: 12px; }
.ar-socials { display: flex; gap: 6px; margin-top: 12px; }
.ar-socials .iconbtn { background: rgba(255, 255, 255, 0.055); }
/* skeletons: the shape of what is coming, so the page never jumps when it
arrives (the mockup's .skel, at the app's radii) */
.skel { display: block; background: rgba(255, 255, 255, 0.07); border-radius: 6px; animation: skel-breathe 1.4s ease-in-out infinite; }
.skel.ln { height: 11px; }
.skel.art { width: 46px; height: 46px; border-radius: 8px; flex: none; }
.skel.cover-wrap { aspect-ratio: 1; width: 100%; border-radius: var(--r-art); }
.skel-row { display: flex; align-items: center; gap: 12px; padding: 8px 0; }
.skel-row .b { flex: 1; min-width: 0; }
.skel-card { pointer-events: none; }
.ar-latest.skel-card .ar-latest-art { background: rgba(255, 255, 255, 0.07); }
@keyframes skel-breathe { 0%, 100% { opacity: 1; } 50% { opacity: 0.55; } }
/* the listener / progress bars other screens draw under a row (CatalogAlbum,
Transfers, Diagnostics) — the artist page's Popular used them too, once */
.lbar { display: flex; align-items: center; gap: 8px; margin-top: 5px; height: 10px; }
.lbar i { display: block; flex: 1; height: 3px; border-radius: 2px; background: var(--accent); transform-origin: left center; opacity: 0.85; }
.lbar em { font-style: normal; font-size: 10.5px; color: var(--text-muted); font-variant-numeric: tabular-nums; min-width: 32px; text-align: right; }
/* a rail's cards line up at the TOP — a two-line title must not lift its cover */
.ar-rail { align-items: flex-start; }