diff --git a/src/views/Home.svelte b/src/views/Home.svelte index 977fe36..5c9efe5 100644 --- a/src/views/Home.svelte +++ b/src/views/Home.svelte @@ -44,7 +44,20 @@ * 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; - const GRID_MAX = 12; + + /* 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; @@ -156,11 +169,19 @@ ); } + /* 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 + GRID_MAX)); + const gridItems = $derived(feed.slice(1, 1 + rowCap)); const fresh31 = $derived(feed.filter((r) => !r.upcoming && daysFrom(r.release_date) <= 31).length); - const outSoon = $derived(assembleFeed(3650).filter((r) => r.upcoming)); /* 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 @@ -173,7 +194,7 @@ if (seen.has(key)) continue; seen.add(key); rows.push(r); - if (rows.length >= 6) break; + if (rows.length >= rowCap) break; } return rows; }); @@ -195,7 +216,7 @@ const picks = []; for (const pass of [0, 1]) { for (const r of rows) { - if (picks.length >= 6) break; + 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); @@ -212,24 +233,34 @@ const followed = new Set(slots.map((s) => s.name)); const seen = new Set(); const pool = []; - for (let round = 0; round < 2 && pool.length < 10; round++) { + // 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 === 0 ? 0 : 1]; + )[round]; if (pick) { seen.add(pick.name); pool.push({ ...pick, because: slot.name }); - if (pool.length >= 10) break; + added++; + if (pool.length >= rowCap) break; } } + if (!added) break; } return pool; }); - const libraryRows = $derived(libraryStore.albums.slice(0, 6)); + const libraryRows = $derived(libraryStore.albums.slice(0, rowCap)); - /* IDEA (approved): own some of an artist's albums with a new one coming — + /* 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(() => { @@ -245,6 +276,9 @@ 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) => { @@ -254,15 +288,35 @@ 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)) - // Eleven follows yield ~35 fresh items; the panel is a sidebar, - // not a feed reader. Newest dozen — the 45-day window above is - // the honesty rule, this is just column height. - .slice(0, 12), + // 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( @@ -336,39 +390,11 @@ return {}; } - /* Rails — Spotify's paging gesture in the app's clothes. The action wires - * the two buttons and keeps the edge-fade attributes honest. */ - function rail(node) { - const track = node.querySelector(".rail-track"); - const left = node.querySelector(".rail-btn.left"); - const right = node.querySelector(".rail-btn.right"); - const sync = () => { - const max = track.scrollWidth - track.clientWidth - 1; - node.dataset.left = track.scrollLeft > 4 ? "1" : ""; - node.dataset.right = track.scrollLeft < max ? "1" : ""; - left.disabled = track.scrollLeft <= 4; - right.disabled = track.scrollLeft >= max; - }; - // A viewport minus one card, so the last visible column stays as the - // next page's first — the reader keeps their place. - const page = (dir) => track.scrollBy({ left: dir * (track.clientWidth - 166), behavior: "smooth" }); - const goL = () => page(-1); - const goR = () => page(1); - left.addEventListener("click", goL); - right.addEventListener("click", goR); - track.addEventListener("scroll", sync, { passive: true }); - const ro = new ResizeObserver(sync); - ro.observe(track); - sync(); - return { - destroy() { - left.removeEventListener("click", goL); - right.removeEventListener("click", goR); - track.removeEventListener("scroll", sync); - ro.disconnect(); - }, - }; - } + /* 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 @@ -410,7 +436,7 @@ async function playEntrance() { if (entrancePlayed || !pageEl || reducedMotion()) return; entrancePlayed = true; - const firstImg = pageEl.querySelector(".feat img, .rail-track img"); + const firstImg = pageEl.querySelector(".feat img, .grid img"); if (firstImg && firstImg.decode) { await Promise.race([ firstImg.decode().catch(() => {}), @@ -419,22 +445,47 @@ } await new Promise(requestAnimationFrame); if (!pageEl) return; - const chrome = [ - ["[data-greet]", 0, true], - [".col .ax-head", 40, true], - [".col .feat", 60, true], - ]; - for (const [sel, delay, lift] of chrome) { - const el = pageEl.querySelector(sel); - if (el) dealOne(el, delay, lift); + + /* 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; } - const first = pageEl.querySelector(".rail"); - const cards = first ? first.querySelectorAll(".release") : []; - for (let i = 0; i < Math.min(cards.length, ENTER_ROWS); i++) { - dealOne(cards[i], 80 + i * ENTER_STEP, true); - } - [...pageEl.querySelectorAll(".panel")].slice(0, 4) - .forEach((p, i) => dealOne(p, 100 + i * ENTER_STEP, true)); } /* ------------------------------------------------------------ handlers */ @@ -450,10 +501,6 @@ } const openNews = (n) => api.openExternal(n.url); - /** "Show all" unfolds the rail into a wrapped grid in place — there is no - * full-feed page yet, and a button that navigates nowhere would lie. */ - const expanded = $state({}); - onMount(() => { void loadHome(); if (!libraryStore.loaded) void refreshLibrary(); @@ -491,7 +538,7 @@ {:else} -
+
{#if lead}
{/if} @@ -524,303 +571,263 @@
-
-
- -
-

- New from your artists - · {homeStore.follows.length} followed -

- -
- {#if lead} -
-
-
-
-
- -
-
- {lead.upcoming ? "Coming" : "New"} · {kindLabel(lead.record_type).toUpperCase()} -
-
{lead.title}
-
- {lead.artistName} - · - {lead.upcoming ? upcomingLabel(lead) : age(lead.release_date)} - {#if lead.track_count} - ·{lead.track_count} tracks - {/if} -
-
- {#if lead.upcoming} - - {:else} - - {/if} - -
-
+
+
+ {lead.upcoming ? "Coming" : "New"} · {kindLabel(lead.record_type).toUpperCase()} +
+
{lead.title}
+
+ {lead.artistName} + · + {lead.upcoming ? upcomingLabel(lead) : age(lead.release_date)} + {#if lead.track_count} + ·{lead.track_count} tracks + {/if} +
+
+ {#if lead.upcoming} + + {:else} + + {/if} +
- {/if} - {#snippet releaseCard(r, sub, chip, playable)} +
+
+ {/if} + {#snippet releaseCard(r, sub, chip, playable)} +
openRelease(r)} + onkeydown={(e) => e.key === "Enter" && openRelease(r)} + > +
+ {#if chip}{chip}{/if} + {#if r.cover} +
+ +
+ {:else} +
+ {/if} + {#if playable} + + {/if} +
+
{r.title}
+
{sub}
+
+ {/snippet} +
+ {#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} +
+ + + {#snippet bandRow(pic, title, meta, first, open)} + + {/snippet} + + + + {#if newsItems.length} +
+

+ News + · from your follows · last {NEWS_MAX_AGE_DAYS} days +

+
+
+ {#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} +
+ {/if} + + + {#if jumpRows.length} +
+

Jump back in · picks up where you left off

+
+
+ {#each jumpRows as r (r.artist + r.title)}
openRelease(r)} - onkeydown={(e) => e.key === "Enter" && openRelease(r)} + onclick={() => r.catalog_album_id && openCatalogAlbum(r.catalog_album_id)} + onkeydown={(e) => e.key === "Enter" && r.catalog_album_id && openCatalogAlbum(r.catalog_album_id)} >
- {#if chip}{chip}{/if} - {#if r.cover} -
- -
- {:else} -
- {/if} - {#if playable} - - {/if} + {#if r.last}{ageShort(r.last)}{/if} +
+ {#if r.art_url} + + {:else if r.art_path} + + {/if} +
-
{r.title}
-
{sub}
-
- {/snippet} -
- -
- {#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} -
- -
- - - {#if jumpRows.length} -
-

Jump back in · picks up where you left off

-
-
- -
- {#each jumpRows as r (r.artist + r.title)} -
r.catalog_album_id && openCatalogAlbum(r.catalog_album_id)} - onkeydown={(e) => e.key === "Enter" && r.catalog_album_id && openCatalogAlbum(r.catalog_album_id)} - > -
- {#if r.last}{ageShort(r.last)}{/if} -
- {#if r.art_url} - - {:else if r.art_path} - - {/if} -
-
-
- {r.title} - {#if r.kind === "library"}{/if} -
-
{r.artist}
-
- {/each} +
+ {r.title} + {#if r.kind === "library"}{/if}
- +
{r.artist}
- {/if} - - - {#if backCatalogue.length} -
-

From the back catalogue · albums you may have missed

-
-
- -
- {#each backCatalogue as r (r.artistName + r.title)} - {@render releaseCard(r, r.artistName, age(r.release_date), !r.mbOnly)} - {/each} -
- -
- {/if} - - - {#if because.length} -
-

Recommended for you · from everyone you follow

-
-
- -
- {#each because as r (r.id)} - - {/each} -
- -
- {/if} - - - {#if libraryRows.length} -
-

Your library · {libraryStore.albums.length} albums

-
-
- -
- {#each libraryRows as a (a.id)} -
goToView("library", { albumId: a.id })} - onkeydown={(e) => e.key === "Enter" && goToView("library", { albumId: a.id })} - > -
-
- -
-
-
- {a.title} - -
-
{a.artist}
-
- {/each} -
- -
- {/if} + {/each}
+ {/if} - -
+ + {#if completeSet.length} +
+

Complete the set · gaps on your shelf

+
+
+ {#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} +
+ {/if} + + + {#if following.length} +
+

Following · {homeStore.follows.length} artists

+
+
+ {#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} +
+ {/if}
{/if} @@ -857,23 +864,17 @@ mask-image: linear-gradient(180deg, #000 40%, transparent); } - /* ================= the desk ================= */ - .desk { + /* ================= 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: 1fr 312px; - gap: 26px; - align-items: start; - } - /* A grid item's min-width is auto — without this the column refuses to - shrink below its widest rail, the rail never clips, and the PANE - scrolls sideways instead of the track. */ - .desk > * { min-width: 0; } - .aside { - display: flex; - flex-direction: column; - gap: 20px; - position: sticky; - top: calc(var(--bar-h) + 16px); + grid-template-columns: repeat(auto-fill, 150px); + gap: 4px 16px; + justify-content: start; + min-width: 0; } /* ============ section heads (ArtistExtras, P3 sizes) ============ */ @@ -894,20 +895,6 @@ color: var(--text-muted); font-variant-numeric: tabular-nums; } - .disco-all { - display: inline-flex; - align-items: center; - gap: 4px; - font-size: 12.5px; - font-weight: 800; - color: var(--text-secondary); - padding: 5px 8px; - border-radius: 6px; - } - .disco-all:hover { - color: var(--text-primary); - background: var(--bg-highlight); - } /* ============ the featured card (ArtistExtras .feat) ============ */ .feat { @@ -918,8 +905,12 @@ align-items: center; padding: 22px 24px; box-shadow: 0 22px 54px rgba(0, 0, 0, 0.5); - max-width: 900px; - /* mock: the banner holds the rail off (P3: 34px under it) */ + /* 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 { @@ -1069,63 +1060,6 @@ } .avatars img:first-child { margin-left: 0; } - /* ============ rails (the mock's Spotify gesture) ============ */ - .rail { position: relative; min-width: 0; } - .rail-track { - display: flex; - gap: 16px; - overflow-x: auto; - scroll-behavior: smooth; - scrollbar-width: none; - -ms-overflow-style: none; - padding: 2px 2px 4px; - min-width: 0; - } - .rail-track::-webkit-scrollbar { display: none; } - .rail-track > :global(*) { - flex: 0 0 150px; - margin-bottom: 0; - } - /* "Show all" unfolds the rail in place. */ - .rail.open .rail-track { flex-wrap: wrap; overflow: visible; } - .rail.open .rail-btn { display: none; } - .rail:global([data-right="1"]) .rail-track { - -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 64px), transparent); - mask-image: linear-gradient(90deg, #000 calc(100% - 64px), transparent); - } - .rail:global([data-left="1"][data-right="1"]) .rail-track { - -webkit-mask-image: linear-gradient(90deg, transparent, #000 64px, #000 calc(100% - 64px), transparent); - mask-image: linear-gradient(90deg, transparent, #000 64px, #000 calc(100% - 64px), transparent); - } - .rail:global([data-left="1"]:not([data-right="1"])) .rail-track { - -webkit-mask-image: linear-gradient(90deg, transparent, #000 64px); - mask-image: linear-gradient(90deg, transparent, #000 64px); - } - .rail-btn { - position: absolute; - top: 52px; - z-index: 5; - width: 34px; - height: 34px; - border-radius: 50%; - display: grid; - place-items: center; - background: rgba(10, 10, 14, 0.72); - backdrop-filter: blur(10px) saturate(1.4); - -webkit-backdrop-filter: blur(10px) saturate(1.4); - box-shadow: 0 2px 12px rgba(0, 0, 0, 0.5), inset 0 0 0 1px rgba(255, 255, 255, 0.09); - color: #fff; - cursor: pointer; - opacity: 0; - transform: scale(0.9); - transition: opacity 0.16s ease, transform 0.16s ease, background 0.16s ease; - } - .rail:hover .rail-btn { opacity: 1; transform: none; } - .rail-btn:hover { background: rgba(30, 30, 36, 0.9); } - .rail-btn[disabled] { opacity: 0 !important; pointer-events: none; } - .rail-btn.left { left: -15px; } - .rail-btn.right { right: -15px; } - /* ============ release cards (CatalogArtist, verbatim) ============ */ .release { display: flex; @@ -1240,8 +1174,20 @@ } /* ============ artist cards (ArtistExtras) ============ */ + /* flex column, NOT block. These are