From 10e4c75b33192e40753e3bb5ca458047a88e68da Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:47:07 -0500 Subject: [PATCH] home: rows that fill the window, and news across the whole of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kyle picked this off an interactive prototype after four passes: news as a band rather than a sidebar, cards at 150px, rows filling to width, the banner capped at 1400, and no "show all". The news sidebar and the desk grid are gone. News is a full-width band whose column count follows the window — two at 1152, six at 2560 — and the 312px aside stops taking 338px off every row at every width. Rows are wrapping grids now, repeat(auto-fill, 150px), which is the artist page's own release grid. The paging rails went with them, and with the rails went their arrows, their per-rail ResizeObservers and the edge-fade mask. Row caps follow the window instead of being constants: perRow is floor((pageW + 16) / 166) and every row is exactly two lines at any width, which replaced GRID_MAX = 12 and the 6/6/10/6 breaks. Back catalogue goes from 8 cards at 1152 to 24 at 2560 off the same rule. Out soon is dropped on Kyle's instruction — the feed already leads with the same upcoming record as its featured card, so the panel repeated the top of the page. Complete the set and Following come back as bands; the three sections share one snippet, and the news-* classes are band-* because the shape now holds artists as well as headlines. Two bugs fixed on the way. .ax-card is a button with display: block, and a button's content box is vertically centred by the UA, so as a grid item stretched to the row height the slack split above and below the artwork — three-line sub-lines put the image at 9px and one-line at 25px. It is a flex column now. And the entrance dealt a hardcoded eight cards while fill-to-width puts 8 to 24 on screen, so everything past the eighth sat at full opacity while its neighbours faded in; it walks the sections and deals the ones actually on screen, shortening the stagger as the count grows. The news row stays newest-first with no per-artist quota. A round-robin was tried and rejected: it is a news row, and if nothing is happening with an artist there is nothing to say about them. Repetition of one story is bounded in Rust, where it belongs. --- src/views/Home.svelte | 905 +++++++++++++++++++----------------------- 1 file changed, 402 insertions(+), 503 deletions(-) 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