Files
SoulStream/src/views/Home.svelte
T
KeelrLeek 547cee6649 dark glass gets a surface ladder, and the 200-750ms stalls are dead - two sessions of uncommitted work landing together
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.
2026-08-27 17:24:20 -05:00

1348 lines
53 KiB
Svelte

<script>
// The landing page — approved on research/home-redesign, 2026-08-04:
// the Desk layout with the P3 "Showcase" polish and the G2 welcome card,
// a 6-month feed window, news on with reviews filtered, and all three
// idea sections (back catalogue, library rail, complete-the-set).
//
// Nothing here is a new design. Every shape is a component another page
// already ships — the artist page's release card, ed-badge and chips,
// ArtistExtras' section heads, featured card and rows, the discography
// page's entrance — and the styles below are copies of those rules, the
// same way CatalogDiscography carries copies of the album page's. The
// only new visual vocabulary (rails with paging arrows, the aside panels,
// the news list, the ambience wash) is the mock's, carried over verbatim.
//
// Data: everything is read from caches home_warm fills in the background
// (measured 48s cold for 9 follows — this page never pays that on open),
// assembled progressively in homeStore, and derived here exactly as the
// artist page derives its tabs: the store holds payloads, the page holds
// the rules.
import { onMount, tick } from "svelte";
import Icon from "../components/Icon.svelte";
import {
homeStore,
loadHome,
libraryStore,
refreshLibrary,
openCatalogAlbum,
openCatalogArtist,
streamCatalogAlbum,
getArt,
goToView,
} from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js";
import { accentFromImage } from "../lib/color.js";
/* ----------------------------------------------------- approved policy */
/** Feed window: Kyle picked 6 months on the mock's control. */
const WINDOW_DAYS = 180;
/** The news panel never shows anything older than this. */
const NEWS_MAX_AGE_DAYS = 45;
/** Reviews out: the feed's job is what happened — releases, tours,
* interviews — not what a critic thought of a record the release section
* already announces. Applied to titles because Google News RSS cannot
* express it in the query. */
const REVIEW_RE = /\breview(s|ed)?\b|album of the week|\branked\b|best songs of|best albums of/i;
/* Rows follow the WINDOW, not a constant. Cards are the artist page's
* fixed 150 + 16 gutter, so how many fit across is arithmetic, and every
* row carries exactly two of them: 8 cards at 1152, 24 at 2560.
*
* This replaces GRID_MAX = 12 and the 6/6/10/6 breaks below. Measured on
* the layout prototype with Kyle's real follows, those constants were
* throwing work away — the back catalogue had 92 albums to offer and
* showed six of them at every width. */
const CARD = 150;
const GAP = 16;
/** Must match the `minmax(300px, 1fr)` and 30px gutter of .band. */
const BAND_COL = 300;
const BAND_GAP = 30;
/* ------------------------------------------------------------- helpers */
const DAY = 86400000;
const daysFrom = (d) => Math.floor((Date.now() - new Date(d).getTime()) / DAY);
function age(d) {
if (!d) return "";
const a = daysFrom(d);
if (a <= 0) return "today";
if (a === 1) return "yesterday";
if (a < 7) return a + "d ago";
if (a < 31) return Math.round(a / 7) + "w ago";
if (a < 365) return Math.round(a / 30.4) + "mo ago";
return (a / 365).toFixed(a < 730 ? 1 : 0) + "y ago";
}
function ageShort(ts) {
const h = (Date.now() - ts * 1000) / 3600000;
if (h < 1) return "just now";
if (h < 24) return Math.round(h) + "h ago";
const a = Math.floor(h / 24);
if (a === 1) return "yesterday";
if (a < 7) return a + "d ago";
return Math.round(a / 7) + "w ago";
}
const future = (d) => d && new Date(d).getTime() > Date.now();
function inDays(d) {
const n = -daysFrom(d);
return n === 1 ? "tomorrow" : "in " + n + " days";
}
/** The artist page's own upcoming wording (its upcomingDate), verbatim. */
function upcomingLabel(album) {
const value = album.release_date;
if (!value) return "Upcoming · Date TBA";
if (value.length === 4) return `Expected ${value} · Date TBA`;
if (value.length < 10) {
const month = new Date(`${value.slice(0, 7)}-01T00:00:00`).toLocaleDateString(undefined, {
month: "short", year: "numeric",
});
return `Expected ${month} · Date TBA`;
}
const date = new Date(`${value.slice(0, 10)}T00:00:00`);
return `Releases ${date.toLocaleDateString(undefined, {
month: "short", day: "numeric", year: "numeric",
})}`;
}
const KIND = { album: "Album", ep: "EP", single: "Single" };
const kindLabel = (t) => KIND[t] || "Album";
const greetWord = () => {
const h = new Date().getHours();
if (h < 12) return "Good morning";
if (h < 18) return "Good afternoon";
return "Good evening";
};
const dateKicker = () =>
new Date().toLocaleDateString(undefined, { weekday: "long" })
+ " · "
+ new Date().toLocaleDateString(undefined, { month: "long", day: "numeric" });
/* ---------------------------------------------------------------- feed
* Deezer newest + the MB identity supplement, deduped per artist by cover
* and by base title (album > ep > single, then newest; the longer title on
* a full tie, which keeps MB's "Weezer (Gold Album)" over Deezer's bare
* "Weezer" for the same record). Upcoming first, then newest; the release
* age rides the artwork as a chip and the sub names the artist. */
const RANK = { album: 3, ep: 2, single: 1 };
const baseTitle = (t) => String(t).replace(/\s*[([].*$/, "").trim().toLowerCase();
const slots = $derived(
homeStore.order.map((id) => homeStore.slots[id]).filter(Boolean),
);
function assembleFeed(windowDays) {
const out = [];
for (const slot of slots) {
const kept = [];
const seen = [
...(slot.albums ?? []),
...((slot.mb ?? []).map((a) => ({ ...a, mbOnly: true }))),
];
for (const r of seen) {
if (!r.release_date || !r.cover) continue;
const upcoming = future(r.release_date);
if (!upcoming && daysFrom(r.release_date) > windowDays) continue;
const cand = { ...r, artistName: slot.name, artistId: slot.id, upcoming };
const rival = kept.find(
(k) => k.cover === cand.cover || baseTitle(k.title) === baseTitle(cand.title),
);
if (!rival) {
kept.push(cand);
continue;
}
const rr = RANK[rival.record_type] || 0;
const cr = RANK[cand.record_type] || 0;
if (cr > rr || (cr === rr && String(cand.release_date) > String(rival.release_date))) {
kept[kept.indexOf(rival)] = cand;
} else if (
cr === rr
&& cand.release_date === rival.release_date
&& cand.title.length > rival.title.length
) {
rival.title = cand.title;
}
}
out.push(...kept);
}
return out.sort(
(x, y) =>
(y.upcoming ? 1 : 0) - (x.upcoming ? 1 : 0)
|| String(y.release_date).localeCompare(String(x.release_date)),
);
}
/* The page's own content width, bound off .home-page below. Every row
* cap hangs off it. perRow only changes when a whole card's worth of
* width appears, so the caps re-derive on a pixel drag but their VALUE
* holds steady and nothing downstream re-runs. */
let pageW = $state(0);
const perRow = $derived(Math.max(1, Math.floor((pageW + GAP) / (CARD + GAP))));
const rowCap = $derived(Math.max(8, perRow * 2));
const bandCols = $derived(Math.max(1, Math.floor((pageW + BAND_GAP) / (BAND_COL + BAND_GAP))));
const feed = $derived(assembleFeed(WINDOW_DAYS));
const lead = $derived(feed[0] ?? null);
const gridItems = $derived(feed.slice(1, 1 + rowCap));
const fresh31 = $derived(feed.filter((r) => !r.upcoming && daysFrom(r.release_date) <= 31).length);
/* Recents, minus edition dupes (Ill Times + its Deluxe is one record) —
* NOT deduped against the feed: a record can be new AND what you were
* just listening to, and belongs in both. */
const jumpRows = $derived.by(() => {
const seen = new Set();
const rows = [];
for (const r of homeStore.recents) {
const key = r.artist + "|" + baseTitle(r.title);
if (seen.has(key)) continue;
seen.add(key);
rows.push(r);
if (rows.length >= rowCap) break;
}
return rows;
});
/* IDEA (approved): albums older than the feed window — what following
* late made you miss. One per artist first, so one deep discography
* cannot own the row. */
const backCatalogue = $derived.by(() => {
const rows = [];
for (const slot of slots) {
for (const r of [...(slot.mb ?? []), ...(slot.albums ?? [])]) {
if (!r.cover || !r.release_date || future(r.release_date)) continue;
if (daysFrom(r.release_date) <= WINDOW_DAYS) continue;
if ((RANK[r.record_type] || 0) < 3) continue;
rows.push({ ...r, artistName: slot.name, artistId: slot.id });
}
}
const seen = new Set();
const picks = [];
for (const pass of [0, 1]) {
for (const r of rows) {
if (picks.length >= rowCap) break;
const once = picks.some((p) => p.artistName === r.artistName);
if (pass === 0 && once) continue;
const key = r.artistName + "|" + baseTitle(r.title);
if (seen.has(key)) continue;
seen.add(key);
picks.push(r);
}
}
return picks;
});
/** Every follow contributes; each card says which one earned it. */
const because = $derived.by(() => {
const followed = new Set(slots.map((s) => s.name));
const seen = new Set();
const pool = [];
// Rounds run until the well is dry rather than stopping at two: with
// a wide window the row wants more than the ten that produced.
for (let round = 0; pool.length < rowCap; round++) {
let added = 0;
for (const slot of slots) {
const pick = (slot.related ?? []).filter(
(r) => !followed.has(r.name) && !seen.has(r.name),
)[round];
if (pick) {
seen.add(pick.name);
pool.push({ ...pick, because: slot.name });
added++;
if (pool.length >= rowCap) break;
}
}
if (!added) break;
}
return pool;
});
const libraryRows = $derived(libraryStore.albums.slice(0, rowCap));
/* Two of the three sidebar panels come back as bands — the news
* treatment, applied again. "Out soon" does NOT: the feed already leads
* with the same upcoming record as its featured card, so the panel was
* repeating the top of the page. Kyle dropped it on 2026-08-05.
*
* IDEA (approved): own some of an artist's albums with a new one coming —
* the shelf gap. Name-matched against the library, the same way the
* artist page's own owned-count works. */
const completeSet = $derived.by(() => {
const owned = {};
for (const a of libraryStore.albums) owned[a.artist] = (owned[a.artist] || 0) + 1;
return slots
.filter((s) => owned[s.name])
.map((s) => {
const albums =
(s.mb ?? []).filter((r) => (RANK[r.record_type] || 0) === 3).length
|| (s.albums ?? []).length;
const up = [...(s.albums ?? []), ...(s.mb ?? [])].find((r) => future(r.release_date));
return { slot: s, own: owned[s.name], albums, up };
});
});
const setNote = (row) =>
`You own ${row.own} of ${row.albums} albums`
+ (row.up ? ` · new one ${inDays(row.up.release_date)}` : "");
const following = $derived(
slots.map((s) => {
const latest = [...(s.albums ?? []), ...(s.mb ?? [])]
.filter((r) => r.release_date)
.sort((x, y) => String(y.release_date).localeCompare(String(x.release_date)))[0];
return { slot: s, latest };
}),
);
const followNote = (row) =>
row.latest
? future(row.latest.release_date)
? "next " + inDays(row.latest.release_date)
: "latest " + age(row.latest.release_date)
: "no releases known";
/* Newest first across every follow, with NO per-artist quota.
*
* This is a deliberate decision, not an oversight. Rust now returns up to
* 40 relevant deduplicated items per artist, so a busy week for one band
* can take most of the band — measured 2026-08-05, King Gizzard held 10
* of 12 slots while four follows appeared not at all. A round-robin quota
* was tried and rejected: it is a NEWS row, and if nothing is happening
* with an artist there is nothing to say about them. Spreading the slots
* evenly would mean showing staler items from quiet artists in place of
* what actually happened today.
*
* The items crowding the row are genuinely different stories — the
* duplicate reports of one story are already collapsed in Rust — so this
* is real coverage, not repetition. */
const newsItems = $derived(
homeStore.news
.filter((n) => n.date > 0 && (Date.now() / 1000 - n.date) / 86400 <= NEWS_MAX_AGE_DAYS)
.filter((n) => !REVIEW_RE.test(n.title))
// Three rows of whatever the band is currently columned at, so a
// wide window reads more of the 45 days rather than the same
// dozen a sidebar column had room for.
.slice(0, Math.max(12, bandCols * 3)),
);
const empty = $derived(
homeStore.started && slots.length === 0 && libraryStore.albums.length === 0,
);
/* ------------------------------------------------------------- actions */
/** ArtistExtras' reveal: `shown` once decoded, so the raster fill happens
* invisibly. Applied in JS, hence the :global rules below. */
function reveal(node) {
const done = () => node.classList.add("shown");
if (node.complete && node.naturalWidth) done();
else node.addEventListener("load", done, { once: true });
return {};
}
/** The release card's cover fade (`is-loaded`), smoothSrc's visible half. */
function coverLoad(node) {
const done = () => node.classList.add("is-loaded");
if (node.complete && node.naturalWidth) done();
else node.addEventListener("load", done, { once: true });
return {};
}
/** Local library art: resolve the file path through the backend, then
* fade in like any other cover. */
function localArt(node, path) {
let live = true;
void getArt(path).then((url) => {
if (live && url) node.src = url;
});
coverLoad(node);
return { destroy: () => (live = false) };
}
/* Per-card accents — the artist page's gesture: sample the cover, write
* the card's own --accent/--accent-hover so the hover play button and the
* date chip tint to the artwork. Two concurrent, cached, failures keep
* the global accent exactly like the app's fallback. */
const accentCache = new Map();
const accentQueue = [];
let accentActive = 0;
function pumpAccents() {
while (accentActive < 2 && accentQueue.length) {
const { node, cover } = accentQueue.shift();
accentActive += 1;
const finish = (colors) => {
accentActive -= 1;
if (colors && node.isConnected) {
node.style.setProperty("--accent", colors.accent);
node.style.setProperty("--accent-hover", colors.hover);
}
pumpAccents();
};
if (accentCache.has(cover)) {
finish(accentCache.get(cover));
} else {
accentFromImage(cover).then(
(colors) => {
accentCache.set(cover, colors);
finish(colors);
},
() => finish(null),
);
}
}
}
function cardAccent(node, cover) {
if (cover) {
accentQueue.push({ node, cover });
pumpAccents();
}
return {};
}
/* The paging rails are gone: rows wrap into the artist page's auto-fill
* grid instead. That takes their arrows, their per-rail ResizeObservers
* and the edge-fade mask with them — the mask ramped a fixed 64px into a
* 150px card, so whatever was left over at the right edge (18px at 1920,
* 160px at 2560) was smeared into an unreadable stub. */
/* -------------------------------------------------------- the entrance
* CatalogDiscography's dealOne/playEntrance with its four approved W1
* numbers, including the inline-hide that closes the WAAPI first-paint
* gap. Gated on the first cover's decode (capped) so the deal is never
* played over empty image boxes. */
const ENTER_MS = 300;
const ENTER_STEP = 55;
const ENTER_LIFT = 28;
const ENTER_EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
const ENTER_ROWS = 8;
let pageEl = $state(null);
const reducedMotion = () =>
typeof matchMedia === "function"
&& matchMedia("(prefers-reduced-motion: reduce)").matches;
function dealOne(el, delay, lift) {
const cs = getComputedStyle(el);
const rest = cs.opacity || "1";
const canLift = lift && cs.transform === "none";
el.style.opacity = "0";
el.style.willChange = canLift ? "transform, opacity" : "opacity";
const anim = el.animate(
[
{ opacity: 0, transform: canLift ? `translateY(${ENTER_LIFT}px)` : "none" },
{ opacity: rest, transform: "none" },
],
{ duration: ENTER_MS, delay, easing: ENTER_EASE, fill: "both" },
);
const release = () => {
el.style.opacity = "";
el.style.willChange = "";
anim.cancel();
};
anim.finished.then(release, release);
}
let entrancePlayed = false;
async function playEntrance() {
if (entrancePlayed || !pageEl || reducedMotion()) return;
entrancePlayed = true;
const firstImg = pageEl.querySelector(".feat img, .grid img");
if (firstImg && firstImg.decode) {
await Promise.race([
firstImg.decode().catch(() => {}),
new Promise((r) => setTimeout(r, 450)),
]);
}
await new Promise(requestAnimationFrame);
if (!pageEl) return;
/* Deal the SECTIONS that are on screen, not "the first row plus four
* panels". A row is no longer a fixed handful of cards — fill-to-width
* puts 8 to 24 in one — so a constant ENTER_ROWS left everything past
* the eighth sitting at full opacity while its neighbours faded in.
* Anything below the fold is still left simply present, the way the
* discography page leaves it. */
const paneBox = pageEl.getBoundingClientRect();
const scrollTop = pageEl.closest(".content")?.getBoundingClientRect() ?? paneBox;
const onScreen = (el) => {
const b = el.getBoundingClientRect();
return b.width > 0 && b.height > 0
&& b.bottom > scrollTop.top + 1 && b.top < scrollTop.bottom - 1;
};
const greet = pageEl.querySelector("[data-greet]");
if (greet) dealOne(greet, 0, true);
let t = 40;
for (const head of pageEl.querySelectorAll(".ax-head")) {
const items = [];
let n = head.nextElementSibling;
while (n && !n.classList.contains("ax-head")) {
if (n.classList.contains("feat")) items.push(n);
else if (n.classList.contains("grid")) items.push(...n.querySelectorAll(".release, .ax-card"));
else if (n.classList.contains("band")) items.push(...n.querySelectorAll(".band-item"));
n = n.nextElementSibling;
}
const shown = items.filter(onScreen);
const headShown = onScreen(head);
if (!shown.length && !headShown) continue;
if (headShown) dealOne(head, t, true);
// Hold each section to roughly the 8 x ENTER_STEP the deal was
// approved at: more items shorten the step rather than letting the
// last card land most of a second late.
const step = shown.length <= ENTER_ROWS
? ENTER_STEP
: Math.max(16, Math.round((ENTER_ROWS * ENTER_STEP) / shown.length));
shown.forEach((el, i) => dealOne(el, t + 40 + i * step, true));
t += 70;
}
}
/* ------------------------------------------------------------ handlers */
function openRelease(r) {
// MB-supplement releases have no Deezer album id to open; the artist
// page (which files them properly) is the honest destination.
if (r.mbOnly) openCatalogArtist(r.artistId, { id: r.artistId, name: r.artistName });
else openCatalogAlbum(r.id);
}
function playRelease(e, r) {
e.stopPropagation();
void streamCatalogAlbum({ ...r, artist: r.artist ?? r.artistName });
}
const openNews = (n) => api.openExternal(n.url);
onMount(() => {
void loadHome();
if (!libraryStore.loaded) void refreshLibrary();
// The pane outlives the view swap, so without this the page begins
// wherever the LAST page was scrolled to — the discography's arrival
// rule, copied: null from consumeScroll IS "fresh arrival", and only
// a fresh arrival deals the entrance (coming Back is a return to
// something already read).
const el = document.querySelector(".content");
const target = consumeScroll();
if (el) {
if (target == null) el.scrollTop = 0;
else restoreScroll(el, target);
}
if (target != null) entrancePlayed = true;
// Deal once material exists: either now (warm reopen) or when the
// first slots land (cold boot).
const stop = setInterval(() => {
if (slots.length || homeStore.recents.length || libraryStore.albums.length) {
clearInterval(stop);
void tick().then(playEntrance);
}
}, 120);
return () => clearInterval(stop);
});
</script>
{#if empty}
<div class="empty">
<p class="big">Welcome to SoulStream</p>
<p class="sub">
Search the network, stream instantly, and follow artists — new
releases, your library and their news will land here.
</p>
<button class="cta" onclick={() => goToView("search")}>Start searching</button>
</div>
{:else}
<div class="home-page" bind:this={pageEl} bind:clientWidth={pageW}>
{#if lead}
<div class="ambience" style="background-image: url('{lead.cover}')"></div>
{/if}
<!-- G2: the app's featured card dressed as the welcome — date kicker,
greeting, the artist page's stat chips, the follows as a stack. -->
<div class="feat gfeat" data-greet use:cardAccent={lead?.cover}>
{#if lead}
<div class="feat-bg" style="background-image: url('{lead.cover}')"></div>
{/if}
<div class="feat-tint"></div>
<div class="feat-grain"></div>
<div class="feat-in">
<div class="feat-copy">
<div class="feat-kicker">{dateKicker()}</div>
<div class="feat-name gname">{greetWord()}</div>
<div class="stat-chips">
<span class="chip"><Icon name="disc" size={13} strokeWidth={3} /> {fresh31} new this month</span>
<span class="chip"><Icon name="users" size={13} strokeWidth={3} /> {homeStore.follows.length} followed</span>
<span class="chip"><Icon name="library" size={13} strokeWidth={3} /> {libraryStore.albums.length} albums</span>
</div>
</div>
<div class="avatars">
{#each slots.slice(0, 7) as s (s.id)}
{#if s.picture}
<img src={s.picture} alt="" title={s.name} use:reveal draggable="false" />
{/if}
{/each}
</div>
</div>
</div>
<!-- ---- New from your artists -------------------------- -->
<div class="ax-head">
<h2 class="ax-title">
New from your artists
<span class="ax-note">· {homeStore.follows.length} followed</span>
</h2>
</div>
{#if lead}
<div class="feat" use:cardAccent={lead.cover}>
<div class="feat-bg" style="background-image: url('{lead.cover}')"></div>
<div class="feat-tint"></div>
<div class="feat-grain"></div>
<div class="feat-in">
<button class="feat-art" title="Open {lead.title}" onclick={() => openRelease(lead)}>
<img src={lead.cover} alt="" use:reveal draggable="false" />
</button>
<div class="feat-copy">
<div class="feat-kicker">
{lead.upcoming ? "Coming" : "New"} · {kindLabel(lead.record_type).toUpperCase()}
</div>
<div class="feat-name">{lead.title}</div>
<div class="feat-sub">
{lead.artistName}
<span class="feat-dot">·</span>
{lead.upcoming ? upcomingLabel(lead) : age(lead.release_date)}
{#if lead.track_count}
<span class="feat-dot">·</span>{lead.track_count} tracks
{/if}
</div>
<div class="feat-acts">
{#if lead.upcoming}
<button class="ax-ghost"><Icon name="clock" size={13} /> Out {inDays(lead.release_date)}</button>
{:else}
<button class="ax-primary" onclick={(e) => playRelease(e, lead)}>
<Icon name="play" size={13} /> Play
</button>
{/if}
<button
class="ax-ghost"
onclick={() => openCatalogArtist(lead.artistId, { id: lead.artistId, name: lead.artistName })}
><Icon name="users" size={13} /> Go to artist</button>
</div>
</div>
</div>
</div>
{/if}
{#snippet releaseCard(r, sub, chip, playable)}
<div
class="release"
role="button"
tabindex="0"
title="Open {r.title}"
use:cardAccent={r.cover}
onclick={() => openRelease(r)}
onkeydown={(e) => e.key === "Enter" && openRelease(r)}
>
<div class="cover-wrap">
{#if chip}<span class="ed-badge">{chip}</span>{/if}
{#if r.cover}
<div class="cover-stack">
<img class="cover" src={r.cover} alt="" loading="lazy" use:coverLoad draggable="false" />
</div>
{:else}
<div class="cover placeholder"><Icon name="disc" size={30} /></div>
{/if}
{#if playable}
<button
class="hoverplay"
title="Play — streams from the best source"
onclick={(e) => playRelease(e, r)}
><Icon name="play" size={13} /></button>
{/if}
</div>
<div class="title">{r.title}</div>
<div class="sub">{sub}</div>
</div>
{/snippet}
<div class="grid">
{#each gridItems as r (r.artistName + r.title)}
{@render releaseCard(
r,
kindLabel(r.record_type) + " · " + r.artistName,
r.upcoming ? inDays(r.release_date) : age(r.release_date),
!r.upcoming && !r.mbOnly,
)}
{/each}
</div>
<!-- One band row, shared by News, Complete the set and Following.
`first` clears the top rule for the leading `bandCols` items —
the grid fills row-major, so those are every COLUMN's first. -->
{#snippet bandRow(pic, title, meta, first, open)}
<button class="band-item" class:first title={title} onclick={open}>
<span class="band-dot">
{#if pic}<img src={pic} alt="" use:reveal />{/if}
</span>
<span class="band-b">
<span class="band-t">{title}</span>
<span class="band-m">{meta}</span>
</span>
</button>
{/snippet}
<!-- ---- News ------------------------------------------- -->
<!-- A full-width band whose COLUMN COUNT follows the window,
three at 1150 and six at 2400, in place of the 312px
sidebar that took 338px off every row at every width. -->
{#if newsItems.length}
<div class="ax-head">
<h2 class="ax-title">
News
<span class="ax-note">· from your follows · last {NEWS_MAX_AGE_DAYS} days</span>
</h2>
</div>
<div class="band">
{#each newsItems as n, i (n.url)}
{@render bandRow(
slots.find((s) => s.name === n.artist)?.picture,
n.title,
n.source + " · " + age(new Date(n.date * 1000).toISOString()),
i < bandCols,
() => openNews(n),
)}
{/each}
</div>
{/if}
<!-- ---- Jump back in ----------------------------------- -->
{#if jumpRows.length}
<div class="ax-head">
<h2 class="ax-title">Jump back in <span class="ax-note">· picks up where you left off</span></h2>
</div>
<div class="grid">
{#each jumpRows as r (r.artist + r.title)}
<div
class="release"
role="button"
tabindex="0"
title="Open {r.title}"
onclick={() => r.catalog_album_id && openCatalogAlbum(r.catalog_album_id)}
onkeydown={(e) => e.key === "Enter" && r.catalog_album_id && openCatalogAlbum(r.catalog_album_id)}
>
<div class="cover-wrap">
{#if r.last}<span class="ed-badge">{ageShort(r.last)}</span>{/if}
<div class="cover-stack">
{#if r.art_url}
<img class="cover" src={r.art_url} alt="" loading="lazy" use:coverLoad draggable="false" />
{:else if r.art_path}
<img class="cover" alt="" use:localArt={r.art_path} draggable="false" />
{/if}
</div>
</div>
<div class="title">
{r.title}
{#if r.kind === "library"}<span class="owned" title="In your library"><Icon name="check" size={11} /></span>{/if}
</div>
<div class="sub">{r.artist}</div>
</div>
{/each}
</div>
{/if}
<!-- ---- From the back catalogue (idea, approved) -------- -->
{#if backCatalogue.length}
<div class="ax-head">
<h2 class="ax-title">From the back catalogue <span class="ax-note">· albums you may have missed</span></h2>
</div>
<div class="grid">
{#each backCatalogue as r (r.artistName + r.title)}
{@render releaseCard(r, r.artistName, age(r.release_date), !r.mbOnly)}
{/each}
</div>
{/if}
<!-- ---- Recommended ------------------------------------ -->
{#if because.length}
<div class="ax-head">
<h2 class="ax-title">Recommended for you <span class="ax-note">· from everyone you follow</span></h2>
</div>
<div class="grid">
{#each because as r (r.id)}
<button class="ax-card" onclick={() => openCatalogArtist(r.id, r)}>
<span class="ax-art">
{#if r.picture}<img src={r.picture} alt="" use:reveal draggable="false" />{/if}
</span>
<span class="ax-name">{r.name}</span>
<span class="ax-kind">Because you follow {r.because}</span>
</button>
{/each}
</div>
{/if}
<!-- ---- Your library (idea, approved) ------------------- -->
{#if libraryRows.length}
<div class="ax-head">
<h2 class="ax-title">Your library <span class="ax-note">· {libraryStore.albums.length} albums</span></h2>
</div>
<div class="grid">
{#each libraryRows as a (a.id)}
<div
class="release"
role="button"
tabindex="0"
title="Open {a.title}"
onclick={() => goToView("library", { albumId: a.id })}
onkeydown={(e) => e.key === "Enter" && goToView("library", { albumId: a.id })}
>
<div class="cover-wrap">
<div class="cover-stack">
<img class="cover" alt="" use:localArt={a.art_path} draggable="false" />
</div>
</div>
<div class="title">
{a.title}
<span class="owned" title="In your library"><Icon name="check" size={11} /></span>
</div>
<div class="sub">{a.artist}</div>
</div>
{/each}
</div>
{/if}
<!-- ---- Complete the set (idea, approved) --------------- -->
{#if completeSet.length}
<div class="ax-head">
<h2 class="ax-title">Complete the set <span class="ax-note">· gaps on your shelf</span></h2>
</div>
<div class="band">
{#each completeSet as row, i (row.slot.id)}
{@render bandRow(
row.slot.picture,
row.slot.name,
setNote(row),
i < bandCols,
() => openCatalogArtist(row.slot.id, { id: row.slot.id, name: row.slot.name }),
)}
{/each}
</div>
{/if}
<!-- ---- Following -------------------------------------- -->
{#if following.length}
<div class="ax-head">
<h2 class="ax-title">Following <span class="ax-note">· {homeStore.follows.length} artists</span></h2>
</div>
<div class="band">
{#each following as row, i (row.slot.id)}
{@render bandRow(
row.slot.picture,
row.slot.name,
followNote(row),
i < bandCols,
() => openCatalogArtist(row.slot.id, { id: row.slot.id, name: row.slot.name }),
)}
{/each}
</div>
{/if}
</div>
{/if}
<style>
/* The page starts BELOW the glass bar (App.svelte's search-page rule —
nothing here runs artwork under the frost) with the mock's 18px of
air. The ambience is the one exception, reaching the pane top behind
the glass on purpose, like the discography's. */
.home-page {
position: relative;
padding-top: calc(var(--bar-h) + 18px);
padding-bottom: 40px;
}
.home-page > :not(.ambience) {
position: relative;
z-index: 1;
}
/* P3 ambience values: the lead release's artwork washed across the page
top, 540px tall at 0.22. */
.ambience {
position: absolute;
left: -28px;
right: -28px;
top: calc(-1 * var(--pane-pad));
height: 540px;
z-index: 0;
pointer-events: none;
background-size: cover;
background-position: center 30%;
filter: blur(90px) saturate(1.5);
opacity: 0.22;
-webkit-mask-image: linear-gradient(180deg, #000 40%, transparent);
mask-image: linear-gradient(180deg, #000 40%, transparent);
}
/* ================= rows =================
The artist page's own release grid: fixed 150px tracks, auto-filled, so
a row carries as many as the window has room for and wraps rather than
scrolling. It replaces the paging rail — and with it the arrows, the
per-rail ResizeObservers and the edge-fade mask. */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, 150px);
gap: 4px 16px;
justify-content: start;
min-width: 0;
}
/* ============ section heads (ArtistExtras, P3 sizes) ============ */
.ax-head {
display: flex;
align-items: center;
justify-content: space-between;
margin: 42px 0 12px;
}
.ax-title {
margin: 0;
font-size: 21px;
font-weight: 800;
color: var(--text-primary);
}
.ax-note {
font-size: 12px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
/* ============ the featured card (ArtistExtras .feat) ============ */
.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);
/* 900 left ~1200px of dead page to the right of the greeting and the
lead card on a 2560 monitor. Full-width is worse — the release card
gets a long empty right half — and centring pulls it off the left
edge its own section head sits on. 1400 is the middle Kyle picked. */
max-width: 1400px;
/* mock: the banner holds the row off (P3: 34px under it) */
margin-bottom: 34px;
}
.feat-bg {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
filter: blur(30px) saturate(1.6) brightness(0.5);
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-copy { min-width: 0; }
/* P3: the featured art a step larger than ArtistExtras' 132px. */
.feat-art {
flex: none;
display: block;
width: 176px;
height: 176px;
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: rgba(255, 255, 255, 0.6); }
/* ---- G2, the welcome card: greeting name at the mock's 36px, chips on
the artist page's stat-chip recipe, the follows as a stack. ---- */
.gfeat { margin: 6px 0 8px; }
.gname { font-size: 36px; letter-spacing: -0.02em; }
.stat-chips {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-top: 12px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
height: 27px;
padding: 0 11px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 14px;
background: rgba(14, 15, 18, 0.4);
color: rgba(255, 255, 255, 0.8);
font-size: 11.5px;
font-weight: 600;
white-space: nowrap;
}
.chip :global(.icon) { color: rgba(255, 255, 255, 0.72); }
.avatars {
display: flex;
align-self: center;
margin-left: auto;
flex: none;
padding-left: 18px;
}
.avatars img {
width: 34px;
height: 34px;
border-radius: 50%;
object-fit: cover;
margin-left: -10px;
box-shadow: 0 0 0 2px rgba(10, 10, 14, 0.85);
}
.avatars img:first-child { margin-left: 0; }
/* ============ release cards (CatalogArtist, verbatim) ============ */
.release {
display: flex;
flex-direction: column;
padding: 9px;
border-radius: var(--radius);
cursor: pointer;
user-select: none;
transition: background 0.15s ease;
text-align: left;
}
.release:hover,
.release:focus-visible {
background: var(--bg-elevated);
}
.cover-wrap { position: relative; }
.cover-stack {
position: relative;
border-radius: 6px;
overflow: hidden;
isolation: isolate;
}
.cover {
display: block;
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 6px;
}
.cover-stack .cover {
position: relative;
opacity: 0;
transform: scale(1.04);
transition: opacity 300ms ease, transform 300ms cubic-bezier(0.2, 0.8, 0.3, 1);
}
/* `is-loaded` is applied at runtime, so Svelte's compiler cannot see it in
the markup and would prune the rule — :global keeps it (the .namebox
lesson). */
.cover-stack .cover:global(.is-loaded) {
opacity: 1;
transform: scale(1);
}
.cover.placeholder {
display: grid;
place-items: center;
aspect-ratio: 1;
background: var(--bg-elevated);
color: var(--text-muted);
}
.ed-badge {
position: absolute;
top: 6px;
right: 6px;
z-index: 1;
display: inline-flex;
align-items: center;
gap: 4px;
line-height: 1;
padding: 4px 7px;
border-radius: 6px;
font-size: 10.5px;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: #fff;
background: color-mix(in srgb, var(--accent) 40%, rgba(10, 10, 12, 0.42));
backdrop-filter: blur(6px) saturate(1.6);
-webkit-backdrop-filter: blur(6px) saturate(1.6);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--accent) 55%, transparent),
0 2px 8px rgba(0, 0, 0, 0.35);
}
.hoverplay {
position: absolute;
right: 8px;
bottom: 8px;
z-index: 2;
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: var(--accent);
color: #000;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.55);
opacity: 0;
transform: translateY(4px);
transition: opacity 0.18s ease, transform 0.18s ease, background 0.18s ease;
}
.hoverplay:hover { background: var(--accent-hover); }
.release:hover .hoverplay,
.release:focus-visible .hoverplay {
opacity: 1;
transform: none;
}
.title {
font-size: 13px;
font-weight: 700;
margin-top: 9px;
line-height: 1.3;
overflow-wrap: anywhere;
}
.owned {
display: inline-block;
vertical-align: -1px;
color: var(--accent-hover);
margin-left: 3px;
}
.sub {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
}
/* ============ artist cards (ArtistExtras) ============ */
/* flex column, NOT block. These are <button>s, and a button lays its
content out in an anonymous box the UA centres VERTICALLY. In
ArtistExtras' rail that never showed, because a flex item is sized by
its content; as a grid item it stretches to the tallest card in the
row, and the leftover height was splitting above and below the
artwork. "Because you follow King Gizzard & The Lizard Wizard" wraps to
three lines where "Because you follow toe" takes one, so the artwork
sat 16px lower on the short ones. Column flow puts the slack under the
text, where it belongs, and keeps the stretch so a row's hover targets
stay the same size. */
.ax-card {
display: flex;
flex-direction: column;
align-items: stretch;
width: 150px;
padding: 9px;
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);
}
.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);
/* the mock's accommodation: "Because you follow X" wraps where the
app's "Artist" never needed to */
white-space: normal;
line-height: 1.35;
}
/* ============ the news band ============
Was a 312px sidebar column; now a full-width block whose column count
follows the window — three at 1150, six at 2400. BAND_COL/BAND_GAP in
the script mirror these two numbers to know which items open a column. */
.band {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 0 30px;
align-items: start;
}
.band-item {
display: flex;
gap: 11px;
padding: 10px 0;
border-top: 1px solid rgba(255, 255, 255, 0.07);
text-align: left;
cursor: pointer;
width: 100%;
}
/* Every COLUMN's first row loses the rule, not just the first column's —
the grid fills row-major, so those are the leading `bandCols` items. */
.band-item.first { border-top: 0; padding-top: 0; }
.band-item:hover .band-t { color: #fff; }
.band-dot {
width: 32px;
height: 32px;
border-radius: 50%;
overflow: hidden;
flex: none;
background: var(--bg-highlight);
}
.band-dot img { width: 100%; height: 100%; object-fit: cover; opacity: 0; transition: opacity 300ms ease; }
.band-dot img:global(.shown) { opacity: 1; }
.band-b { min-width: 0; }
.band-t {
display: block;
font-size: 12.5px;
font-weight: 650;
line-height: 1.34;
margin-bottom: 3px;
color: var(--text-primary);
}
.band-m { font-size: 11px; color: var(--text-muted); }
/* ArtistExtras' rows, stacked in a column inside panels. */
/* ============ empty state (the old Home's, kept) ============ */
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding-top: 15vh;
text-align: center;
}
.big { font-size: 22px; font-weight: 700; }
.sub { color: var(--text-secondary); }
.cta {
margin-top: 10px;
padding: 10px 24px;
border-radius: 24px;
background: var(--accent);
color: #000;
font-weight: 700;
}
.cta:hover { background: var(--accent-hover); }
/* ── dark glass ────────────────────────────────────────────────────────
Every surface below is opaque in solid mode and stays exactly that way;
these are additive :root.glass rules only. Values come from the ladder
in global.css — surfaces on a pane elevate with WHITE, because the pane
is already 0.7075 black over the acrylic and more black would sink them
under it. */
/* Kyle's screenshot: hovering a card dropped an opaque #181818 rectangle
over the pane frost and the ambience wash. The base .release paints
nothing at all, so this hover IS the card's only paint. */
:global(:root.glass) .release:hover,
:global(:root.glass) .release:focus-visible,
:global(:root.glass) .ax-card:hover {
background: var(--glass-hover);
}
/* Artwork wells — what shows while the cover is still loading, or forever
when there is none. A ring instead of a fill keeps the well readable as
a frame rather than a hole. */
:global(:root.glass) .ax-art,
:global(:root.glass) .cover.placeholder {
background: var(--glass-surface);
box-shadow: var(--glass-ring);
}
/* The greeting banner, and ONLY when it has no artwork behind it. With a
lead release the tint sits on an opaque .feat-bg image and is doing
legibility work — untouched. With none, .feat-tint is the first child
(the {#if} renders no element) and that 0.9 black gradient is painting
straight onto the pane: a black slab on the frost. Same diagonal, same
three stops, inverted to white so the banner reads as a lifted glass
panel instead of a hole. */
:global(:root.glass) .feat-tint:first-child {
background: linear-gradient(
100deg,
rgba(255, 255, 255, 0.055) 28%,
rgba(255, 255, 255, 0.02) 72%,
rgba(255, 255, 255, 0.045)
);
}
</style>