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
-