forked from Keelrleek/SoulStream
SURFACES: panes add black, surfaces INSIDE panes add white. Every pane stands on 0.7075 black over the acrylic (.content 0.35 over .app 0.55), so a translucent black card nets 0.81 - darker than its own pane and inside the ~0.85 band already rejected once on the artist page. Solid mode's ladder is measured UPWARD from #121212 (+6/+18/+24), so glass elevates with WHITE: new --glass-surface/-hover/-press/-hairline/-well/-ring under :root.glass (0.03/0.08/0.11/0.10/0.18), which is independently the vocabulary .btn-ghost:hover and .ax-ghost:hover already shipped. The four solid tokens are NEVER redefined - they are consumed inside color-mix() and gradient stops where an alpha multiplies through and kills the call site (0.74 x 0.03 = an invisible chip; the album row's text-overflow fade works by BEING opaque), and they paint modal chrome that has to stay solid. 129 additive :global(:root.glass) declarations across 18 files, all 129 verified inside a glass block by walking the diff - solid mode byte-identical, confirmed structurally, by computed style with the class off, and visually. Three of them are not colour swaps: the album row's .rowbtns shim was OCCLUDING the last ~19px of the listeners bar and nothing translucent can occlude, so .tcount masks out just before the buttons instead; Playlist's banner scrim and the discography's pinned ambience both landed on opaque --bg-base and now dissolve like the album banner; and the 0.9 black hero tint on Home/CatalogSearch/Discography paints straight onto the pane when there is no artwork, so :first-child (the {#if} renders no element) flips it to white. Menus, dialogs, toasts and the import surfaces stay opaque deliberately, and the Settings copy for the mode now says so instead of claiming cards do. Measured with a known ground substituted behind the transparent window, since CDP never sees the acrylic: library toolbar chroma +2 as shipped, +16 with a white fill AND its backdrop-filter dropped, +30 for the bare pane beside it. RESIZE: Windows drops the legacy accent-policy acrylic for the whole of a live resize and the window stays transparent through it, so the desktop punches through sharp - measured by toggling window_glass alone, desktop icon labels readable through the titlebar. The blur cannot be kept (documented since Win10 2004, and the modern DWM backdrop was already rejected for the overlay-promotion blink), so armGlassResizeGuard grounds the canvas to --bg-sidebar for the length of the gesture. STALLS: "choppy but the FPS says 144" was 200-750ms MAIN-THREAD TASKS, not frame pacing - idle-scheduled work fires at scroll pauses by definition. Twin bake moved to display resolution and off the ready task (was a same-task 4K WebGL readback plus software blur, 100-170ms per rotation AND per artist entry); gl.finish() -> gl.flush() in buildMaster, which had been draining the whole 4K EASU/RCAS/grade chain on the main thread; copyCanvas realloc guarded; one app-wide scroll clock in scrollbar.js (markAppScroll/appScrollIdleFor) so all three periscopes stop cloning mid-gesture on ANY pane rather than only their own; bounded reclone with the querySelectorAll census deleted, the gate widened 250->1000ms, and the build/attach split across an idle callback and a rAF behind a generation guard; the hero rotation tick gated on scroll recency and its payload cache sized to the working set (2->4 entries, 24->32MiB, 32->64MiB); sessionTick no longer force-rewrites every 12s while merely PAUSED. Scroll storm went from 109-616ms long tasks to zero. Hover recalc and GC were measured innocent - do not re-chase them. 98/98 tests throughout.
845 lines
30 KiB
Svelte
845 lines
30 KiB
Svelte
<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;
|
||
}
|
||
}
|
||
|
||
/* ── dark glass ────────────────────────────────────────────────────────
|
||
Ladder in global.css; solid mode untouched. These sections sit low on
|
||
the artist page, below the hero's dissolve, where the ground is plain
|
||
pane frost. */
|
||
:global(:root.glass) .ax-card:hover,
|
||
:global(:root.glass) .ax-own:hover {
|
||
background: var(--glass-hover);
|
||
}
|
||
:global(:root.glass) .ax-art,
|
||
:global(:root.glass) .ax-own {
|
||
background: var(--glass-surface);
|
||
}
|
||
:global(:root.glass) .ax-own {
|
||
border-color: var(--glass-hairline);
|
||
}
|
||
/* The activity timeline's bars are a chart, not chrome: the resting bar is
|
||
the "no releases" tone and the accent marks the hovered year, so the
|
||
two have to stay clearly apart. A white veil at the surface value keeps
|
||
that contrast against the frost the way #242424 does against #121212. */
|
||
:global(:root.glass) .tl-bar {
|
||
background: var(--glass-press);
|
||
}
|
||
</style>
|