forked from Keelrleek/SoulStream
artist page: five sections after the hero, and nothing moved to fit them
Latest release, Releases by year, Fans also like, In your library and About slot into the page between what is already there — hero, Popular, Discography grid and all — and CatalogArtist gains three lines: an import and two placements. They live in ArtistExtras so Svelte scopes their styles away from this file, which matters more than usual here: both files name a .hero-bg and a .card, for completely different things. Every section is fed by a call the app already makes. catalog_related is Deezer's related-artists field, literally commented "Fans also like" and simply never rendered on this page; the bio and profile calls already run; latest and the timeline come out of page.albums. Nothing here needs a provider that did not exist before. Latest release is CatalogSearch's top-result hero — the app's only surface for presenting one item prominently — with min-height 0 (there is no stat row to hold space for) and a 6px artwork radius so it rounds like a record; it tints from its OWN cover, scoped, so it never wears the playing track's colour. Fans also like reuses this page's .grid/.release markup, so its columns are the same 150px tracks as the discography above it, drifting 0px on all six. The release grid's min-height reserve had to learn about them. That reserve is the tab-pinning contract — everything under the pinned tab bar must span a pane-height — and when the grid was the last thing on the page the grid had to supply all of it. It is not last any more, so both reserves now discount the real content below it: without that the grid stood on 206px of empty min-height under its final card, a hole nobody could see until something rendered after it. The discount is parent-bottom minus grid-bottom, which is invariant to the grid's own min-height because both edges move with it, so the reserve converges instead of chasing its own write; when nothing follows the grid it is 0 and the figure is exactly the old one. The grid's parent joins the ResizeObserver, because the sections land asynchronously and grow it without touching the grid's box or the pane's. An earlier attempt pulled the sections up by a measured slack instead. The grid reflowed after the measurement and they landed on top of the release cards. Reverted: prefer a fix that can only be a few px wrong over one that can be catastrophically wrong. Verified: expander open and close leaves scrollTop at 1571, and the tab bar sits at 231 across switches.
This commit is contained in:
1 parent
520e2ad4e4
commit
819a2757bc
2 files changed
+894
-5
No files matched your search
@@ -0,0 +1,821 @@
|
||||
<script>
|
||||
// The artist page's added sections, kept in a component of their own.
|
||||
//
|
||||
// NOTHING on the artist page was removed or restyled to make room for
|
||||
// these — CatalogArtist gains only the two lines that place this. Keeping
|
||||
// them here also means Svelte scopes every rule below, so none of it can
|
||||
// reach the hero, the stat chips, Popular or the release grid. That matters
|
||||
// more than usual: this file styles `.hero-bg` and `.card`, and the artist
|
||||
// page has elements by those names doing completely different jobs.
|
||||
//
|
||||
// Shapes approved on research/artist-sections (2026-08-04):
|
||||
// Latest release the search page's top-result hero, which is the app's
|
||||
// only surface for presenting one item prominently.
|
||||
// Releases by year one column per year from release_date, gaps included.
|
||||
// Fans also like the artist page's own release card, so the artwork
|
||||
// lines up with the discography grid above it.
|
||||
// In your library what you own by this artist, gathered.
|
||||
// About the bio with a stat row and genre chips.
|
||||
//
|
||||
// Every one is fed by a call the app ALREADY makes. Nothing here needs a
|
||||
// provider that did not exist before.
|
||||
import Icon from "./Icon.svelte";
|
||||
import {
|
||||
catalog,
|
||||
openCatalogAlbum,
|
||||
openCatalogArtist,
|
||||
streamCatalogAlbum,
|
||||
saveCatalogAlbum,
|
||||
libraryLocalAlbum,
|
||||
} from "../lib/state.svelte.js";
|
||||
import * as api from "../lib/api.js";
|
||||
import { accentFromImage } from "../lib/color.js";
|
||||
|
||||
/** `part` splits the sections across two placements so they land where they
|
||||
* were approved: "latest" sits between Popular and Discography, "rest"
|
||||
* follows the release grid. Both instances read the same fetched data —
|
||||
* see the module-level cache below, which is why mounting twice costs one
|
||||
* request, not two. */
|
||||
let { page, part = "rest" } = $props();
|
||||
|
||||
const artist = $derived(page?.artist ?? null);
|
||||
const albums = $derived(page?.albums ?? []);
|
||||
|
||||
/* ----------------------------------------------------------- fetched data
|
||||
* catalog_related, catalog_artist_bio and catalog_artist_profile are all
|
||||
* already in api.js — `related` is the very list the Now Playing panel's
|
||||
* discovery section uses, where the field is commented "Fans also like".
|
||||
* It had simply never been asked for on this page.
|
||||
*
|
||||
* Cached per artist at module scope so the two placements share one fetch,
|
||||
* and so returning to an artist does not re-ask.
|
||||
*/
|
||||
const extrasCache = new Map();
|
||||
const EXTRAS_CACHE_MAX = 12;
|
||||
|
||||
let related = $state([]);
|
||||
let profile = $state(null);
|
||||
let bio = $state("");
|
||||
|
||||
$effect(() => {
|
||||
const id = artist?.id;
|
||||
const name = artist?.name ?? "";
|
||||
if (id == null) return;
|
||||
const hit = extrasCache.get(id);
|
||||
if (hit) {
|
||||
({ related, profile, bio } = hit);
|
||||
return;
|
||||
}
|
||||
related = [];
|
||||
profile = null;
|
||||
bio = "";
|
||||
(async () => {
|
||||
const [r, p, b] = await Promise.all([
|
||||
api.catalogRelated(id).catch(() => []),
|
||||
api.catalogArtistProfile(id).catch(() => null),
|
||||
api.catalogArtistBio(id, name).catch(() => null),
|
||||
]);
|
||||
// The page may have moved on while those were out.
|
||||
if (catalog.artistPage?.artist?.id !== id) return;
|
||||
const text = typeof b === "string" ? b : (b?.text ?? b?.bio ?? "");
|
||||
const value = { related: r ?? [], profile: p ?? null, bio: text ?? "" };
|
||||
extrasCache.set(id, value);
|
||||
while (extrasCache.size > EXTRAS_CACHE_MAX) {
|
||||
extrasCache.delete(extrasCache.keys().next().value);
|
||||
}
|
||||
({ related, profile, bio } = value);
|
||||
})();
|
||||
});
|
||||
|
||||
/* --------------------------------------------------------------- derived */
|
||||
const latest = $derived.by(() => {
|
||||
let best = null;
|
||||
for (const a of albums) {
|
||||
if (!a.release_date) continue;
|
||||
if (!best || a.release_date > best.release_date) best = a;
|
||||
}
|
||||
return best;
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------- the card's accent
|
||||
* The Latest release card tints from its OWN cover, by the same rule the
|
||||
* discography page's records follow. Left alone it inherits the global
|
||||
* accent, which tracks whatever is PLAYING — so a card for one record wore
|
||||
* the colour of an unrelated track, and changed colour when the track did.
|
||||
*
|
||||
* `accentFromImage` opts into CORS precisely so remote covers can be
|
||||
* sampled directly, which is why the dzcdn URL goes in unmodified. Cached
|
||||
* per cover URL — including the null a cover that will not sample returns,
|
||||
* so a failure is asked once rather than on every re-render.
|
||||
*/
|
||||
const accentCache = new Map();
|
||||
const ACCENT_CACHE_MAX = 24;
|
||||
let coverAccent = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
const cover = latest?.cover ?? null;
|
||||
if (!cover) {
|
||||
coverAccent = null;
|
||||
return;
|
||||
}
|
||||
if (accentCache.has(cover)) {
|
||||
coverAccent = accentCache.get(cover);
|
||||
return;
|
||||
}
|
||||
coverAccent = null;
|
||||
(async () => {
|
||||
const colors = await accentFromImage(cover).catch(() => null);
|
||||
accentCache.set(cover, colors);
|
||||
while (accentCache.size > ACCENT_CACHE_MAX) {
|
||||
accentCache.delete(accentCache.keys().next().value);
|
||||
}
|
||||
// The card may be showing a different record by now.
|
||||
if (latest?.cover === cover) coverAccent = colors;
|
||||
})();
|
||||
});
|
||||
|
||||
/** Scoped override, exactly as the album page applies its browse accent —
|
||||
* local to this card, so the global accent keeps following the player
|
||||
* everywhere else on the page. Undefined until the sample lands, which
|
||||
* leaves the card on the global accent for that moment rather than on
|
||||
* nothing. */
|
||||
const featStyle = $derived(
|
||||
coverAccent
|
||||
? `--accent: ${coverAccent.accent}; --accent-hover: ${coverAccent.hover};`
|
||||
: undefined,
|
||||
);
|
||||
|
||||
/** One column per year across the whole span — INCLUDING the years with no
|
||||
* releases. The gaps are half of what a timeline says about an artist. */
|
||||
const timeline = $derived.by(() => {
|
||||
const byYear = new Map();
|
||||
for (const a of albums) {
|
||||
const y = (a.release_date ?? "").slice(0, 4);
|
||||
if (y) byYear.set(y, (byYear.get(y) ?? 0) + 1);
|
||||
}
|
||||
const years = [...byYear.keys()].sort();
|
||||
if (years.length < 3) return null;
|
||||
const first = Number(years[0]);
|
||||
const last = Number(years[years.length - 1]);
|
||||
const peak = Math.max(...byYear.values());
|
||||
const cols = [];
|
||||
for (let y = first; y <= last; y++) {
|
||||
const n = byYear.get(String(y)) ?? 0;
|
||||
cols.push({ year: y, n, h: n ? Math.max(8, Math.round((n / peak) * 52)) : 2 });
|
||||
}
|
||||
return { cols, first, last, total: albums.length };
|
||||
});
|
||||
|
||||
const owned = $derived.by(() => {
|
||||
const name = artist?.name ?? "";
|
||||
if (!name) return [];
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const a of albums) {
|
||||
const local = libraryLocalAlbum(name, a);
|
||||
if (!local || seen.has(a.title)) continue;
|
||||
seen.add(a.title);
|
||||
out.push({ album: a, local });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------------- format */
|
||||
const fmt = (n) =>
|
||||
n == null
|
||||
? "—"
|
||||
: n >= 1e6
|
||||
? (n / 1e6).toFixed(n >= 1e7 ? 0 : 1) + "M"
|
||||
: n >= 1e3
|
||||
? Math.round(n / 1e3) + "K"
|
||||
: String(n);
|
||||
const year = (a) => (a?.release_date ?? "").slice(0, 4);
|
||||
|
||||
/** The release grid holds artwork at opacity 0 until it loads — the app's
|
||||
* blur-up. Its reveal class is applied by an action at runtime, so a
|
||||
* copied card that does not do the same renders invisible. */
|
||||
function reveal(node) {
|
||||
const done = () => node.classList.add("shown");
|
||||
if (node.complete && node.naturalWidth) done();
|
||||
else node.addEventListener("load", done, { once: true });
|
||||
return {};
|
||||
}
|
||||
|
||||
// The mock's timeline could be clicked to jump to a year. Doing that for
|
||||
// real needs a year on each release card to scroll to, and the release grid
|
||||
// is existing markup this change is not allowed to touch — so the bars read
|
||||
// rather than navigate, and say their count on hover. The jump can come
|
||||
// later, with one data attribute, if it turns out to be wanted.
|
||||
|
||||
/** The tab-pinning contract's leftover, moved to the END of the page.
|
||||
*
|
||||
* The release grid reserves min-height so everything under the pinned tab
|
||||
* bar spans one pane-height — switching to a sparser tab must never shrink
|
||||
* the scroll range, or the browser clamps scrollTop and the page yanks
|
||||
* (measured 509px in the grid's own notes). With these sections following
|
||||
* the grid, its measure now discounts real content below it — but whatever
|
||||
* the content still cannot cover stood as empty grid BETWEEN the last card
|
||||
* and the first section here. At a full-screen pane that hole was ~430px of
|
||||
* nothing in the middle of the page.
|
||||
*
|
||||
* This spacer claims that remainder at the page end instead:
|
||||
*
|
||||
* spacer = paneH − (own top − bar top) + (grid box − last card bottom)
|
||||
*
|
||||
* i.e. what one pane-height still needs after the bar, all real content,
|
||||
* and with the grid counted at its NATURAL height (its current slack is
|
||||
* added back so the two controllers cannot chase each other). The grid's
|
||||
* measure then sees this spacer inside its below-the-grid discount and
|
||||
* settles at natural height — the hole drains from the middle to the end.
|
||||
* Convergence is one pass each way: this spacer's own height affects
|
||||
* neither its top edge nor the grid slack it reads, and the grid's
|
||||
* inflation cancels out of the formula, so re-measures reproduce their own
|
||||
* answers and stop.
|
||||
*
|
||||
* Fail-safe by construction, unlike pulling sections up over the grid (the
|
||||
* reverted first attempt): a stale figure here only makes the page bottom
|
||||
* slightly long or short for a frame — it cannot overlap anything.
|
||||
*/
|
||||
function endReserve(node) {
|
||||
let raf = 0;
|
||||
const measure = () => {
|
||||
raf = 0;
|
||||
if (!node.isConnected) return;
|
||||
const pane = node.closest(".content");
|
||||
const bar = document.querySelector(".disco-tabs");
|
||||
if (!pane || !bar) {
|
||||
node.style.height = "0px";
|
||||
return;
|
||||
}
|
||||
const cards = document.querySelectorAll(".release");
|
||||
const last = cards[cards.length - 1];
|
||||
const grid = last?.closest(".grid");
|
||||
const slack = grid && last
|
||||
? Math.max(
|
||||
0,
|
||||
grid.getBoundingClientRect().bottom
|
||||
- last.getBoundingClientRect().bottom,
|
||||
)
|
||||
: 0;
|
||||
const used = node.getBoundingClientRect().top - bar.getBoundingClientRect().top;
|
||||
const need = Math.round(pane.clientHeight - used + slack);
|
||||
const to = Math.max(0, Math.min(need, pane.clientHeight));
|
||||
const standing = Math.round(parseFloat(node.style.height) || 0);
|
||||
if (to !== standing) node.style.height = `${to}px`;
|
||||
};
|
||||
const schedule = () => {
|
||||
// Deferred one frame so the write never lands inside an observer
|
||||
// delivery — the same discipline the grid's own measure keeps.
|
||||
if (!raf) raf = requestAnimationFrame(measure);
|
||||
};
|
||||
schedule();
|
||||
const ro = new ResizeObserver(schedule);
|
||||
const pane = node.closest(".content");
|
||||
if (pane) ro.observe(pane);
|
||||
if (node.parentElement) ro.observe(node.parentElement);
|
||||
return {
|
||||
destroy() {
|
||||
ro.disconnect();
|
||||
cancelAnimationFrame(raf);
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if artist}
|
||||
{#if part === "latest"}
|
||||
{#if latest}
|
||||
<!-- The app's featured-item surface, from CatalogSearch's top
|
||||
result: the artwork blurred behind itself under a tint and a
|
||||
grain. Not a new panel. -->
|
||||
<div class="ax-head"><h2 class="ax-title">Latest release</h2></div>
|
||||
<div class="feat" style={featStyle}>
|
||||
<div class="feat-bg" style="background-image:url('{latest.cover}')"></div>
|
||||
<div class="feat-tint"></div>
|
||||
<div class="feat-grain"></div>
|
||||
<div class="feat-in">
|
||||
<button
|
||||
class="feat-art"
|
||||
title="Open {latest.title}"
|
||||
onclick={() => openCatalogAlbum(latest.id)}
|
||||
>
|
||||
<img src={latest.cover} alt="" draggable="false" />
|
||||
</button>
|
||||
<div class="feat-copy">
|
||||
<div class="feat-kicker">
|
||||
Latest · {(latest.record_type ?? "album").toUpperCase()}
|
||||
</div>
|
||||
<div class="feat-name">{latest.title}</div>
|
||||
<div class="feat-sub">
|
||||
{year(latest)}
|
||||
{#if latest.track_count}
|
||||
<span class="feat-dot">·</span>
|
||||
{latest.track_count} tracks
|
||||
{/if}
|
||||
</div>
|
||||
<div class="feat-acts">
|
||||
<button class="ax-primary" onclick={() => streamCatalogAlbum(latest)}>
|
||||
<Icon name="play" size={13} /> Play
|
||||
</button>
|
||||
<button class="ax-ghost" onclick={() => saveCatalogAlbum(latest)}>
|
||||
<Icon name="download" size={13} /> Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="ax-rest">
|
||||
{#if timeline}
|
||||
<div class="ax-head">
|
||||
<h2 class="ax-title">Releases by year</h2>
|
||||
<span class="ax-note">
|
||||
{timeline.last - timeline.first + 1} years · {timeline.total} releases
|
||||
</span>
|
||||
</div>
|
||||
<div class="tl">
|
||||
{#each timeline.cols as c (c.year)}
|
||||
<div
|
||||
class="tl-col"
|
||||
class:empty={!c.n}
|
||||
title="{c.year} · {c.n
|
||||
? c.n + (c.n === 1 ? ' release' : ' releases')
|
||||
: 'nothing released'}"
|
||||
>
|
||||
<span class="tl-bar" style="height:{c.h}px"></span>
|
||||
<span class="tl-yr">{String(c.year).slice(2)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if related.length}
|
||||
<div class="ax-head"><h2 class="ax-title">Fans also like</h2></div>
|
||||
<div class="ax-grid">
|
||||
{#each related as a (a.id)}
|
||||
<button class="ax-card" onclick={() => openCatalogArtist(a.id, a)}>
|
||||
<span class="ax-art">
|
||||
{#if a.picture}
|
||||
<img src={a.picture} alt="" draggable="false" use:reveal />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="ax-name">{a.name}</span>
|
||||
<span class="ax-kind">Artist</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if owned.length}
|
||||
<div class="ax-head"><h2 class="ax-title">In your library</h2></div>
|
||||
<div class="ax-owned">
|
||||
{#each owned as o (o.album.id)}
|
||||
<button class="ax-own" onclick={() => openCatalogAlbum(o.album.id)}>
|
||||
<span class="ax-own-art"><img src={o.album.cover} alt="" use:reveal /></span>
|
||||
<span class="ax-own-copy">
|
||||
<span class="ax-own-t">{o.album.title}</span>
|
||||
<span class="ax-own-m">
|
||||
{year(o.album)}{o.album.track_count
|
||||
? ` · ${o.album.track_count} tracks`
|
||||
: ""}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if bio || profile?.listeners || profile?.tags?.length}
|
||||
<div class="ax-head"><h2 class="ax-title">About</h2></div>
|
||||
<div class="ax-about">
|
||||
{#if profile?.listeners || profile?.plays}
|
||||
<div class="ax-stats">
|
||||
{#if profile.listeners}
|
||||
<span><b>{fmt(profile.listeners)}</b><i>listeners</i></span>
|
||||
{/if}
|
||||
{#if profile.plays}
|
||||
<span><b>{fmt(profile.plays)}</b><i>plays</i></span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if bio}<p class="ax-bio">{bio}</p>{/if}
|
||||
{#if profile?.tags?.length}
|
||||
<div class="ax-tags">
|
||||
{#each profile.tags as t (t)}<span class="ax-tag">{t}</span>{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- The tab-pinning contract's leftover, parked at the page end — see
|
||||
endReserve. Empty on purpose; its height is the whole point. -->
|
||||
<div class="ax-endpad" aria-hidden="true" use:endReserve></div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Every class here is `ax-`/`feat-`/`tl-` prefixed. The artist page already
|
||||
has a `.hero-bg`, a `.card`, a `.title` and a `.sub` doing other jobs, and
|
||||
although Svelte scopes this file, the prefixes mean a future :global or a
|
||||
copied snippet still cannot collide. */
|
||||
|
||||
/* Height is written by endReserve; everything else about this box is
|
||||
nothing at all. */
|
||||
.ax-endpad {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* NO negative margin here. Pulling this block up by the grid's measured
|
||||
slack is what the first attempt did, and it overlapped the release grid
|
||||
outright the moment the window resized: the measurement is taken once per
|
||||
observed change, the grid re-flows to a different number of rows AFTER
|
||||
that, and the stale figure then lifts this block over live cards rather
|
||||
than over the empty reserve it was measured against. A gap is a blemish;
|
||||
sections stacked on top of the grid is a broken page. The reserve is
|
||||
fixed at its source instead — see the note in CatalogArtist. */
|
||||
.ax-rest {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* The section heading, matching the page's own Discography/Popular heads. */
|
||||
.ax-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 34px 0 12px;
|
||||
}
|
||||
.ax-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.ax-note {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ---- Latest release ---------------------------------------------------
|
||||
CatalogSearch's top-result hero, value for value. Two departures, both
|
||||
because a record is not an artist: no min-height (that hero reserves
|
||||
230px for a stat row and genre chips this card does not have), and the
|
||||
artwork rounds at 6px like a RECORD rather than the hero's 12px. */
|
||||
.feat {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 22px 24px;
|
||||
box-shadow: 0 22px 54px rgba(0, 0, 0, 0.5);
|
||||
/* The Popular list's own cap, so the two blocks read as one column
|
||||
instead of the card running the full pane while the rows above it
|
||||
stop at 900. */
|
||||
max-width: 900px;
|
||||
}
|
||||
.feat-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
filter: blur(30px) saturate(1.6) brightness(0.5);
|
||||
/* Overscaled so the blur's soft edge never reaches the card's border. */
|
||||
transform: scale(1.25);
|
||||
}
|
||||
.feat-tint {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
rgba(8, 8, 10, 0.9) 28%,
|
||||
rgba(8, 8, 10, 0.4) 72%,
|
||||
rgba(8, 8, 10, 0.78)
|
||||
);
|
||||
}
|
||||
.feat-grain {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.055;
|
||||
mix-blend-mode: overlay;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3'/></filter><rect width='120' height='120' filter='url(%23n)'/></svg>");
|
||||
}
|
||||
.feat-in {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
}
|
||||
.feat-art {
|
||||
flex: none;
|
||||
display: block;
|
||||
width: 132px;
|
||||
height: 132px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.6);
|
||||
transition: transform var(--art-motion);
|
||||
}
|
||||
.feat-art:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
.feat-art img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.feat-kicker {
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
.feat-name {
|
||||
font-weight: 900;
|
||||
letter-spacing: -1.2px;
|
||||
line-height: 1.02;
|
||||
font-size: 34px;
|
||||
}
|
||||
.feat-sub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 700;
|
||||
margin-top: 7px;
|
||||
}
|
||||
.feat-dot {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.feat-acts {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 13px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ax-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 34px;
|
||||
padding: 0 18px;
|
||||
border-radius: 99px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 800;
|
||||
background: var(--accent);
|
||||
color: #08080a;
|
||||
transition: transform var(--art-motion);
|
||||
}
|
||||
.ax-primary:hover {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
.ax-ghost {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 34px;
|
||||
padding: 0 15px;
|
||||
border-radius: 99px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||
transition: 0.18s;
|
||||
}
|
||||
.ax-ghost:hover {
|
||||
border-color: #fff;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* ---- Releases by year ------------------------------------------------- */
|
||||
.tl {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 3px;
|
||||
height: 74px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
.tl-col {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
.tl-col.empty {
|
||||
cursor: default;
|
||||
}
|
||||
.tl-bar {
|
||||
width: 100%;
|
||||
border-radius: 3px 3px 0 0;
|
||||
background: var(--bg-highlight);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.tl-col:not(.empty):hover .tl-bar {
|
||||
background: var(--accent);
|
||||
}
|
||||
.tl-yr {
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tl-col:not(.empty):hover .tl-yr {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ---- Fans also like ---------------------------------------------------
|
||||
The release grid's own geometry: 150px fixed tracks and a 132px cover at
|
||||
6px, so these portraits land on the same columns as the records above
|
||||
them and round the same. Fixed tracks rather than minmax for the reason
|
||||
the grid states — fluid ones sawtooth the card width on every resize. */
|
||||
.ax-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, 150px);
|
||||
/* The release grid's own column gap, and its row rhythm carried on the
|
||||
ITEM's margin rather than a row gap — both copied because they set
|
||||
the pitch. Measured against the grid above: 150 + 16 = a 166px pitch.
|
||||
A different gap here (4px, and space-between) put these on a 159.8px
|
||||
pitch, so the two rows drifted apart by 2.6px at the first column and
|
||||
more at every one after. */
|
||||
gap: 0 16px;
|
||||
}
|
||||
.ax-card {
|
||||
display: block;
|
||||
width: 150px;
|
||||
padding: 9px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 8px;
|
||||
text-align: left;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.ax-card:hover {
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
.ax-art {
|
||||
display: block;
|
||||
width: 132px;
|
||||
height: 132px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
.ax-art img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
opacity: 0;
|
||||
transform: scale(1.04);
|
||||
transition:
|
||||
opacity 300ms ease,
|
||||
transform 300ms cubic-bezier(0.2, 0.8, 0.3, 1);
|
||||
}
|
||||
/* `shown` is added by the `reveal` action once the image has decoded, so
|
||||
the browser's top-down raster fill happens invisibly. Applied in JS,
|
||||
which is why it is not written on the element above. */
|
||||
.ax-art img:global(.shown) {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
.ax-name {
|
||||
display: block;
|
||||
margin-top: 9px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.ax-kind {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ---- In your library --------------------------------------------------- */
|
||||
.ax-owned {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ax-own {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 14px 8px 8px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
text-align: left;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.ax-own:hover {
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
.ax-own-art {
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
flex: none;
|
||||
}
|
||||
.ax-own-art img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
opacity: 0;
|
||||
transition: opacity 240ms ease;
|
||||
}
|
||||
.ax-own-art img:global(.shown) {
|
||||
opacity: 1;
|
||||
}
|
||||
.ax-own-t {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.ax-own-m {
|
||||
display: block;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ---- About -------------------------------------------------------------
|
||||
The search hero's stat row and genre chips, on the page background. No
|
||||
second portrait: this page already opens on a hero of the same artist. */
|
||||
.ax-about {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
max-width: 1180px;
|
||||
}
|
||||
.ax-stats {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
}
|
||||
.ax-stats b {
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.ax-stats i {
|
||||
font-style: normal;
|
||||
font-size: 9.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.ax-bio {
|
||||
margin: 0;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.62;
|
||||
color: var(--text-secondary);
|
||||
max-width: 62ch;
|
||||
}
|
||||
.ax-tags {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ax-tag {
|
||||
font-size: 10.5px;
|
||||
font-weight: 800;
|
||||
padding: 3px 10px;
|
||||
border-radius: 99px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
color: var(--text-secondary);
|
||||
/* Sentence case: MusicBrainz writes tags lowercase, and title-casing
|
||||
turns "rock and indie" into "Rock And Indie". */
|
||||
display: inline-block;
|
||||
}
|
||||
.ax-tag::first-letter {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ax-art img,
|
||||
.ax-own-art img {
|
||||
transition: opacity 120ms ease;
|
||||
transform: none;
|
||||
}
|
||||
.ax-primary:hover,
|
||||
.feat-art:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import { onDestroy, tick, untrack } from "svelte";
|
||||
import ArtistExtras from "../components/ArtistExtras.svelte";
|
||||
import { fade, fly } from "svelte/transition";
|
||||
import { quintOut } from "svelte/easing";
|
||||
import {
|
||||
@@ -2933,7 +2934,17 @@
|
||||
const bar = cachedBar;
|
||||
if (!pane || !bar) return;
|
||||
const chrome = node.getBoundingClientRect().top - bar.getBoundingClientRect().top;
|
||||
let reserve = Math.max(0, Math.round(pane.clientHeight - chrome));
|
||||
// Real content BELOW the grid fills the pane just as well as the
|
||||
// grid itself does. This reserve exists so everything under the
|
||||
// pinned tab bar spans a pane-height; when the grid was the last
|
||||
// thing on the page, the grid had to supply all of it. It no longer
|
||||
// is — the added sections (ArtistExtras) follow it — and without
|
||||
// this discount the grid stood on 206px of empty min-height below
|
||||
// its last card, a hole that only became visible once something
|
||||
// rendered after it. When nothing follows the grid, belowGrid is 0
|
||||
// and the figure is exactly the old one.
|
||||
const below = belowGrid(node);
|
||||
let reserve = Math.max(0, Math.round(pane.clientHeight - chrome - below));
|
||||
// Same argument one level down, for the open expander's own row.
|
||||
// revealIntoView lifts that row to the banner line, and a row in the
|
||||
// last screenful has less beneath it than the pane is tall — so the
|
||||
@@ -2964,10 +2975,18 @@
|
||||
}
|
||||
// The cheap half stays live every frame: the pane's height and
|
||||
// the banner margin move with the window, the row offset does
|
||||
// not.
|
||||
// not. Discounted by the content below the grid for the same
|
||||
// reason as the plain term — the range the reveal needs under
|
||||
// the open row is supplied by the sections after the grid just
|
||||
// as well as by empty min-height, and without the discount the
|
||||
// open panel sat on the same empty band the closed page used
|
||||
// to. Keep in sync with revealReserve, which the reveal itself
|
||||
// stands synchronously.
|
||||
reserve = Math.max(
|
||||
reserve,
|
||||
Math.round(rowOff + pane.clientHeight - revealMargin(node)),
|
||||
Math.round(
|
||||
rowOff + pane.clientHeight - revealMargin(node) - belowGrid(node),
|
||||
),
|
||||
);
|
||||
}
|
||||
setReserve(reserve);
|
||||
@@ -2990,20 +3009,33 @@
|
||||
// the DOM at all.
|
||||
let lastW = -1;
|
||||
let lastPaneH = -1;
|
||||
// The grid's parent is observed too: the sections after the grid land
|
||||
// asynchronously (fetches, images), growing the parent without touching
|
||||
// the grid's or the pane's box — and `below` above is measured against
|
||||
// exactly that growth. Tracked as its own input so the guard still
|
||||
// filters pure echoes; the delivery our own min-height write provokes
|
||||
// does change the parent's height, but measure() then computes an
|
||||
// identical reserve (below is invariant to the grid's inflation) and
|
||||
// setReserve no-ops, so it costs one pass and stops.
|
||||
let lastParentH = -1;
|
||||
const onResize = (entries) => {
|
||||
let w = lastW;
|
||||
let paneH = lastPaneH;
|
||||
let parentH = lastParentH;
|
||||
for (const entry of entries) {
|
||||
const box = entry.borderBoxSize?.[0];
|
||||
if (entry.target === node) {
|
||||
w = Math.round(box ? box.inlineSize : entry.contentRect.width);
|
||||
} else if (entry.target === node.parentElement) {
|
||||
parentH = Math.round(box ? box.blockSize : entry.contentRect.height);
|
||||
} else {
|
||||
paneH = Math.round(box ? box.blockSize : entry.contentRect.height);
|
||||
}
|
||||
}
|
||||
if (w === lastW && paneH === lastPaneH) return;
|
||||
if (w === lastW && paneH === lastPaneH && parentH === lastParentH) return;
|
||||
lastW = w;
|
||||
lastPaneH = paneH;
|
||||
lastParentH = parentH;
|
||||
measure();
|
||||
};
|
||||
// Anything that changes the DOM rather than the box — an expander
|
||||
@@ -3013,6 +3045,7 @@
|
||||
const forced = () => {
|
||||
lastW = -1;
|
||||
lastPaneH = -1;
|
||||
lastParentH = -1;
|
||||
rowOffCols = -1;
|
||||
rowOffXp = null;
|
||||
rowOffRow = null;
|
||||
@@ -3023,6 +3056,7 @@
|
||||
gridMeasures.add(forced);
|
||||
const ro = new ResizeObserver(onResize);
|
||||
ro.observe(node);
|
||||
if (node.parentElement) ro.observe(node.parentElement);
|
||||
const paneEl = cachedPane ?? scrollPaneFor(node);
|
||||
if (paneEl) ro.observe(paneEl);
|
||||
return {
|
||||
@@ -3543,9 +3577,33 @@
|
||||
- vanishingAbove(grid, row, keep);
|
||||
}
|
||||
|
||||
/** Real content following the grid inside the same scroll flow — the added
|
||||
* sections (ArtistExtras) and their end spacer. Both reserves exist to
|
||||
* guarantee scroll RANGE, and range below the grid counts toward it
|
||||
* wherever it lives; without this discount each reserve inflated the grid
|
||||
* as if it were still the last thing on the page, which is exactly the
|
||||
* empty band that showed under the tab bar (closed) and under an open
|
||||
* panel (revealed). Parent-bottom minus grid-bottom is invariant to the
|
||||
* grid's own min-height (both edges move together), so a reserve computed
|
||||
* from it converges instead of chasing its own write. Floored, not
|
||||
* rounded: over-discounting by half a pixel would leave the reveal's ease
|
||||
* idling against a target the range misses by that half-pixel. */
|
||||
function belowGrid(grid) {
|
||||
const parent = grid?.parentElement;
|
||||
if (!parent) return 0;
|
||||
return Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
parent.getBoundingClientRect().bottom
|
||||
- grid.getBoundingClientRect().bottom,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function revealReserve(grid, row, pane, keep) {
|
||||
return Math.round(
|
||||
revealRowOffset(grid, row, keep) + pane.clientHeight - revealMargin(grid),
|
||||
revealRowOffset(grid, row, keep) + pane.clientHeight - revealMargin(grid)
|
||||
- belowGrid(grid),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4985,6 +5043,12 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ADDED, not substituted: everything above and below is untouched.
|
||||
The sections themselves live in ArtistExtras so their styles are
|
||||
scoped away from this file — it names a .hero-bg and a .card of its
|
||||
own, and so does this page, for different things. -->
|
||||
<ArtistExtras {page} part="latest" />
|
||||
|
||||
{#if tabs.length}
|
||||
<div class="disco-head">
|
||||
<h2 class="disco-title">Discography</h2>
|
||||
@@ -5333,6 +5397,10 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- ADDED after the release grid: releases-by-year, fans also like,
|
||||
what you own, and about. Nothing above was moved to fit them. -->
|
||||
<ArtistExtras {page} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user