From 3f4f5c305af7b26c0c0da4cbd61935f0de4a395a Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:46:31 -0500 Subject: [PATCH] now playing: the transfer moves into the bar, and the panel speaks the app's headings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hover popover is deleted. It showed the peer, the rate, a percentage and a second progress bar — every one of which the status line and the hairline beside it already carry — so it was a duplicate you had to hover for. The two things unique to it move up: the save action into the bar, and nothing else. Deleted with it, the reveal action, its 250ms close grace and the `.np-statuswrap` hit area, all of which existed only to make a popover reachable across the 25px gap between the status line and itself. Release sections were the one heading in the app that was 13px, Title Case, full white on an opaque sticky bar; every other one — `.lab` here, `.disc-head` and `.rail-lab` on the catalog pages — is 11px uppercase muted. They now match, with the hairline rule the catalog pages use, and they no longer pin: the bar existed only to hide rows sliding under a sticky heading. Those headings sit on the blurred record, not a flat page, so `--text-muted` and #5c5c5c were chosen against a background that is not there. Each carries a dark plate now, years get the album page's over-artwork treatment, and the hairline goes 0.07 -> 0.14, which over a bright cover was the difference between a faint line and no line. "From the album" rendered `player.queue`, which the album fill grows ONE TRACK AT A TIME as each Soulseek source resolves — a nine-track record read "5 tracks" while you watched the rest arrive. It renders the record's own tracklist now, matched to queue items by provider id, and a row with no source yet resolves on demand when clicked. The About pane gains a record head, a lede, stat tiles, pills and a source line, all re-laid-out from data already in hand. The artist page's expander gets the same lede and pills — no record head or tiles there, because its rail already carries them. --- src/components/NowPlaying.svelte | 552 +++++++++++++++++++++---------- src/views/CatalogArtist.svelte | 92 +++++- 2 files changed, 459 insertions(+), 185 deletions(-) diff --git a/src/components/NowPlaying.svelte b/src/components/NowPlaying.svelte index b79ac25..4c354d6 100644 --- a/src/components/NowPlaying.svelte +++ b/src/components/NowPlaying.svelte @@ -7,6 +7,7 @@ getArt, getCatalogArt, jumpToQueueIndex, + streamSong, discovery, loadDiscovery, openCatalogArtist, @@ -29,6 +30,7 @@ import SaveMenu from "./SaveMenu.svelte"; import * as api from "../lib/api.js"; import { floatScroll } from "../lib/scrollbar.js"; + import { splitDescription } from "../lib/editions.js"; import { accentFromImage } from "../lib/color.js"; const currentItem = $derived(player.queue[player.queueIndex] ?? null); @@ -81,9 +83,11 @@ const quality = $derived(currentItem?.quality ?? null); // Album context: the queue is a catalog album/continuation with >1 track. - const albumMode = $derived( - !!player.catalogRef.albumId && player.queue.length > 1, - ); + // Knowing the record is enough. It used to also require a queue longer than + // one, which meant the album section could not appear until the background + // fill had sourced a SECOND track — so playing a single track showed + // nothing, then a partial list. + const albumMode = $derived(!!player.catalogRef.albumId); // Artist name for the Save action: album items carry "artist — album". const artistName = $derived.by(() => { @@ -245,13 +249,31 @@ let previous = null; const rows = items.map((album) => { const year = key === "upcoming" ? "Upcoming" : albumYear(album) || "—"; - const heading = year === previous ? null : year; + // A divider that only repeats the section it sits under is + // noise: Upcoming headed its rows with the word "Upcoming" + // again, directly below a heading already reading Upcoming. + const heading = + year === previous || year.toLowerCase() === label.toLowerCase() + ? null + : year; previous = year; return { album, heading }; }); return [{ key, label, count: items.length, rows }]; }); }); + /** The artwork this row is already painting, handed to the album page so + * its skeleton opens with the record's cover rather than a grey block. */ + function albumSeed(album) { + if (!album?.cover || artBroken[album.cover]) return null; + return { + cover: album.cover, + title: album.title ?? null, + artist: album.artist ?? discovery?.name ?? null, + kind: album.record_type ?? null, + tracks: album.track_count ?? null, + }; + } function fmtFans(n) { if (!n) return "—"; if (n >= 1e6) return `${(n / 1e6).toFixed(1).replace(/\.0$/, "")}M`; @@ -416,6 +438,15 @@ /** The hairline has no percentage to show until bytes start arriving. */ const hairlineSeeking = $derived(isStream && player.streamTotal === 0); + // The hover-revealed transfer popover is gone. It showed the peer, the + // rate, a percentage and a second progress bar — every one of which the + // status line and the hairline beside it already carry — so it was a + // duplicate that had to be hovered for. What was unique to it now lives in + // the bar: the peer as a monogram, and the save action. Deleted with it: + // the reveal action, its 250ms close grace, and the `.np-statuswrap` hit + // area, all of which existed only to make a popover reachable across the + // 25px gap between the status line and itself. + // ---- drag to resize ---------------------------------------------------- // Pointer capture, like the player bar's scrubber: the pointer keeps // delivering moves after it leaves the 9px grip, which a mousemove @@ -576,29 +607,133 @@ const listenersOf = (item) => item?.trackId != null ? (listenersById.get(String(item.trackId)) ?? null) : null; - /** "7 October 2014 · 13 tracks · 42 min", from whichever parts are known. */ - const creditsLine = $derived.by(() => { - const parts = []; - const date = catalogPage?.album?.release_date ?? null; - if (date) { - const d = new Date(date); - parts.push( - isNaN(d) - ? date - : d.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" }), - ); + /** The RECORD's tracklist, not the queue's progress through it. + * + * This section rendered `player.queue` directly, and the queue is grown one + * track at a time by the album fill — each row appeared only once its + * Soulseek source had resolved. So a nine-track record read "5 tracks" + * while you watched the rest arrive one by one. `catalogPage.tracks` is the + * whole record in a single fetch, and it now lands early (see the + * `catalogRef` assignment in `streamSong`), so the list is complete + * immediately and the queue is demoted to what it actually is: playback + * state layered on top. + * + * A row with no queue item yet is still playable — clicking it resolves a + * source on demand, exactly as the fill would have. Falls back to the queue + * whenever the record is unknown (a local album, a plain queue). */ + const albumRows = $derived.by(() => { + const queue = player.queue ?? []; + const tracks = catalogPage?.tracks ?? []; + if (!albumMode || !tracks.length) { + return queue.map((item, index) => ({ + key: `q${index}`, + title: item.title, + duration: item.duration ?? null, + listeners: listenersOf(item), + queueIndex: index, + trackId: item.trackId ?? null, + })); } - const n = catalogPage?.tracks?.length ?? player.queue.length; - if (n) parts.push(`${n} track${n === 1 ? "" : "s"}`); - const secs = (catalogPage?.tracks ?? player.queue).reduce( + // Match by provider id first; a title match is the fallback for items + // queued before an id was known. + const byTrackId = new Map(); + const byTitle = new Map(); + queue.forEach((item, index) => { + if (item.trackId != null) byTrackId.set(String(item.trackId), index); + const key = norm(item.title); + if (key && !byTitle.has(key)) byTitle.set(key, index); + }); + return tracks.map((t, i) => ({ + key: `t${t.id ?? i}`, + title: t.title, + duration: t.duration_secs ?? t.duration ?? null, + listeners: t.listeners ?? null, + queueIndex: + (t.id != null ? byTrackId.get(String(t.id)) : undefined) + ?? byTitle.get(norm(t.title)) + ?? -1, + trackId: t.id ?? null, + })); + }); + + /** Play a row. Already sourced → jump to it; not yet → resolve it now. */ + function playAlbumRow(row) { + if (row.queueIndex >= 0) { + jumpToQueueIndex(row.queueIndex); + return; + } + streamSong(artistName, row.title, { + cover: catalogPage?.album?.cover ?? currentItem?.artUrl ?? null, + trackId: row.trackId, + albumId: player.catalogRef.albumId, + album: albumTitle || null, + expectSecs: row.duration ?? null, + }); + } + + // ---- About pane facts --------------------------------------------------- + // Everything below is re-laid-out from data already in hand — the record's + // catalog payload, its editorial info, and the artist's own releases. None + // of it costs a request the panel was not already making. + + /** "My Bloody Valentine · 1991 · 11 tracks", under the record's name. */ + const recordSub = $derived.by(() => { + const year = catalogPage?.album?.release_date?.slice(0, 4) ?? null; + const n = catalogPage?.tracks?.length ?? 0; + return [artistName, year, n ? `${n} track${n === 1 ? "" : "s"}` : null] + .filter(Boolean) + .join(" · "); + }); + + /** Lede + body, from the shared splitter — the album page and the artist + * page's expander promote their descriptions with the same function. */ + const descParts = $derived(splitDescription(albumInfo?.description)); + const descLede = $derived(descParts.lede); + const descRest = $derived(descParts.rest); + + /** Year / tracks / runtime / listeners, from the record's own payload. */ + const recordTiles = $derived.by(() => { + const tiles = []; + const year = catalogPage?.album?.release_date?.slice(0, 4); + if (year) tiles.push({ label: "Released", value: year }); + const n = catalogPage?.tracks?.length ?? 0; + if (n) tiles.push({ label: "Tracks", value: String(n) }); + const secs = (catalogPage?.tracks ?? []).reduce( (sum, t) => sum + (t.duration_secs ?? t.duration ?? 0), 0, ); if (secs) { const mins = Math.round(secs / 60); - parts.push(mins >= 60 ? `${Math.floor(mins / 60)} hr ${mins % 60} min` : `${mins} min`); + tiles.push({ + label: "Runtime", + value: mins >= 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins}m`, + }); } - return parts.join(" · "); + const listeners = catalogPage?.album_listeners ?? null; + if (listeners) tiles.push({ label: "Listeners", value: fmtListeners(listeners) }); + return tiles; + }); + + /** "3 November 1991" for the pill row. */ + const releasedOn = $derived.by(() => { + const value = catalogPage?.album?.release_date; + if (!value) return ""; + const date = new Date(value); + return isNaN(date) + ? value + : date.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" }); + }); + + /** "1988–2013" — the span of the artist's own releases, nothing fetched. */ + const activeYears = $derived.by(() => { + const years = (discovery.albums ?? []) + .map((a) => Number(a.release_date?.slice(0, 4))) + .filter((y) => Number.isFinite(y) && y > 1900); + if (!years.length) return ""; + const first = Math.min(...years); + const last = Math.max(...years); + if (first === last) return String(first); + return last >= new Date().getFullYear() - 1 ? `${first}–present` : `${first}–${last}`; }); // Width-responsive: the pill's stats spread rather than bunch, and the @@ -846,38 +981,16 @@ {metaParts[1] || player.track?.title || "Nothing playing"} + + {#if isStream && currentItem?.stream} + + {/if} {#if isStream} {/if} - - - {#if isStream && currentItem?.stream} -
-
-
{currentItem.stream.username.slice(0, 1).toUpperCase()}
-
- {currentItem.stream.username} - - {#if cached} - complete · {fmtSize(player.streamTotal)} - {:else if player.streamTotal > 0} - {fmtSize(player.streamReceived)} / {fmtSize(player.streamTotal)}{rateText ? ` · ${rateText}` : ""} - {:else} - connecting… - {/if} - -
- -
- {#if player.streamTotal > 0} -
- {/if} -
- {/if} -
@@ -1007,38 +1120,39 @@ {fmtTime(upNext.duration)} {/if} - {#if player.queue.length > 1} + {#if albumRows.length > 1}
{albumMode ? "From the album" : "Queue"} - {player.queue.length} tracks + {albumRows.length} tracks
- {#each player.queue as item, i} - {@const lis = listenersOf(item)} + {#each albumRows as row, i (row.key)} + {@const isNow = + row.queueIndex >= 0 && row.queueIndex === player.queueIndex} {/each}
@@ -1080,6 +1194,7 @@ {#each releaseSections as section (section.key)}
{section.label} + {section.count}
@@ -1090,7 +1205,7 @@ - {/if} - {#if creditsLine || albumInfo?.label || albumInfo?.genre} -
- Released - {creditsLine} - {#if albumInfo?.label || albumInfo?.genre} -
{albumInfo.label ?? ""}{albumInfo.label && albumInfo.genre ? " · " : ""}{albumInfo.genre ?? ""} +
About this record
+ {#if albumTitle} + +
+ {#if artUrl} + {/if} +
+
{albumTitle}
+ {#if recordSub}
{recordSub}
{/if} +
+
+ {/if} + {#if albumInfo?.description} + + {#if descLede} +

{descLede}

+ {/if} + {#if descRest} +

{descRest}

+ + {/if} + {/if} + {#if recordTiles.length} +
+ {#each recordTiles as tile (tile.label)} +
{tile.value}{tile.label}
+ {/each} +
+ {/if} + {#if albumInfo?.genre || albumInfo?.label || releasedOn} +
+ {#if albumInfo?.genre}{albumInfo.genre}{/if} + {#if albumInfo?.label}{albumInfo.label}{/if} + {#if releasedOn}{releasedOn}{/if}
{/if}
About the artist
{@render artistChip()} + {#if activeYears} +
Active {activeYears}
+ {/if} {#if discovery.bio}

{discovery.bio}

{/key} @@ -1782,20 +1939,31 @@ /* source card */ /* save action row */ /* tracklist */ + /* Every heading in this panel rides the blurred record, not a flat dark + page: `.bleed-fade` is only rgba(24,24,24,0.15) at the top and does not + reach solid until 78% down. `--text-muted` and #5c5c5c were chosen + against --bg-base and simply vanish into a bright cover. Each heading + carries its own ground instead — a dark plate fading out to the right, + so the label end is always backed and the row end still shows the + artwork through. */ .lab { font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; - color: var(--text-muted); + color: rgba(255, 255, 255, 0.74); font-weight: 600; display: flex; align-items: center; justify-content: space-between; margin-bottom: 7px; + margin-inline: -8px; + padding: 4px 8px; + border-radius: 5px; + background: linear-gradient(90deg, rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.14)); } .lab .cnt { letter-spacing: 0; - color: #5c5c5c; + color: rgba(255, 255, 255, 0.5); } /* Section spacing belongs to the label, not to whatever happens to sit above it — an artist with no social links used to leave the releases @@ -2089,24 +2257,6 @@ min-width: 0; } - /* The album page's credits block, verbatim, so a record reads the same in - both places. */ - .credits { - margin-top: 18px; - color: var(--text-muted); - font-size: 11.5px; - line-height: 1.7; - } - .credits .clab { - display: block; - font-size: 10px; - font-weight: 800; - letter-spacing: 0.1em; - text-transform: uppercase; - color: #5e5e5e; - margin-bottom: 4px; - } - p.bio { margin: 8px 0 0; font-size: 12.5px; @@ -2136,7 +2286,96 @@ transform: rotate(180deg); } - /* ---- transfer detail, on hovering the header ---- */ + /* ---- About pane ---- */ + /* The record naming itself, so the copy below need not carry the identity. */ + .rechead { display: flex; gap: 11px; margin-bottom: 9px; } + .rechead .cv { + width: 62px; + height: 62px; + border-radius: 6px; + flex: none; + object-fit: cover; + box-shadow: 0 8px 18px -8px rgba(0, 0, 0, 0.8); + } + .rh-copy { min-width: 0; } + .rechead .rt { + font-size: 14px; + font-weight: 800; + color: #fff; + line-height: 1.2; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.45); + } + .rechead .rs { + font-size: 11px; + color: rgba(255, 255, 255, 0.6); + margin-top: 3px; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.45); + } + /* The description's own first sentence, promoted — see `descLede`. */ + .lede { + font-size: 13.5px; + line-height: 1.5; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + margin: 0 0 8px; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); + } + /* Auto-fit, because this pane is resizable: the row goes two-up rather than + crushing four tiles into 272px. */ + .tiles { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(74px, 1fr)); + gap: 6px; + margin: 10px 0; + } + .tile { + padding: 8px 9px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.045); + border: 1px solid rgba(255, 255, 255, 0.055); + } + .tile b { + display: block; + font-size: 14px; + font-weight: 800; + color: #fff; + line-height: 1.1; + font-variant-numeric: tabular-nums; + } + .tile i { + font-style: normal; + display: block; + margin-top: 3px; + font-size: 8.5px; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--text-muted); + font-weight: 700; + } + .pills { display: flex; flex-wrap: wrap; gap: 5px; margin: 8px 0 2px; } + .pill { + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.04em; + padding: 3px 9px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.07); + color: rgba(255, 255, 255, 0.82); + border: 1px solid rgba(255, 255, 255, 0.07); + } + .pill.accent { + background: color-mix(in srgb, var(--accent) 30%, rgba(10, 10, 12, 0.4)); + border-color: color-mix(in srgb, var(--accent) 50%, transparent); + color: #fff; + } + .srcs { + margin-top: 14px; + font-size: 9.5px; + line-height: 1.6; + color: #5e5e5e; + } + .srcs b { color: #7a7a7a; font-weight: 700; } + .headzone { position: absolute; top: 0; @@ -2144,69 +2383,6 @@ right: 0; z-index: 6; } - .xpop { - position: absolute; - top: 46px; - left: 0; - right: 0; - z-index: -1; - padding: 10px 12px 12px; - background: rgba(22, 22, 26, 0.94); - backdrop-filter: blur(16px) saturate(1.5); - -webkit-backdrop-filter: blur(16px) saturate(1.5); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45), 0 1px 0 rgba(255, 255, 255, 0.05); - opacity: 0; - transform: translateY(-8px); - pointer-events: none; - transition: opacity 150ms ease, transform 300ms cubic-bezier(0.22, 1, 0.36, 1); - } - .headzone:hover .xpop, - .headzone:focus-within .xpop { - opacity: 1; - transform: none; - pointer-events: auto; - } - .xrow { display: flex; align-items: center; gap: 9px; } - .xav { - width: 26px; - height: 26px; - border-radius: 50%; - flex: none; - display: grid; - place-items: center; - font-size: 11px; - font-weight: 700; - background: color-mix(in srgb, var(--accent) 42%, #333); - color: #fff; - } - .xwho { flex: 1; min-width: 0; font-size: 12px; font-weight: 600; } - .xwho span { - display: block; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - .xwho small { - display: block; - font-weight: 600; - font-size: 10.5px; - color: var(--text-muted); - font-variant-numeric: tabular-nums; - } - .xbar { - height: 4px; - border-radius: 2px; - background: #2b2b2b; - overflow: hidden; - margin-top: 9px; - } - .xbar i { - display: block; - height: 100%; - border-radius: 2px; - background: var(--accent-hover); - transition: width 240ms linear; - } .list { display: flex; flex-direction: column; } .row { display: flex; @@ -2298,31 +2474,53 @@ Dense rows rather than a grid: at the panel's widths a row fits a whole title where a card clamps it to two lines, and twice as many releases fit on a screen. E4 of research/nowplaying-releases/dividers.html. */ + /* The app has exactly one section heading and this is now it: 11px, + uppercase, 0.1em, weight 800, muted, with a hairline running out to the + edge — `.disc-head` on both catalog pages, and a sibling of this panel's + own `.lab`. It used to be 13px Title Case in full white on an opaque + full-bleed bar, the only heading in the app shaped that way, which is + why it read as a design of its own. + + It is no longer sticky. The bar existed only to hide rows sliding under + a pinned heading; painted where it sits it scrolls away like every other + element, which is the same call the segmented control above it got. */ .rel-sec { - position: sticky; - top: 46px; - z-index: 3; - margin: 14px -14px 4px; - padding: 10px 14px 6px; - background: linear-gradient(var(--bg-base) 74%, transparent); display: flex; - align-items: baseline; - justify-content: space-between; + align-items: center; gap: 8px; - font-size: 13px; + margin: 18px -8px 7px; + padding: 4px 8px; + border-radius: 5px; + background: linear-gradient(90deg, rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.14)); + font-size: 11px; font-weight: 800; - color: var(--text-primary); + letter-spacing: 0.1em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.74); } - .rel-sec .n { font-size: 11px; font-weight: 700; color: var(--text-muted); } - /* Deliberately quieter than the category above it, so the two never read - as the same level of heading. */ + /* 0.07 is the hairline's value on a flat page. Over a bright record it is + not a faint line, it is no line — this one has to survive the cover. */ + .rel-rule { + flex: 1; + height: 1px; + background: rgba(255, 255, 255, 0.14); + } + .rel-sec .n { letter-spacing: 0; font-weight: 700; color: rgba(255, 255, 255, 0.5); } + /* Quieter than the section above it, so the two never read as the same + level of heading. A year that only repeats its section is dropped + upstream, in `releaseSections`. + + No plate on this one: a year is a minor divider, and a third band of + dark under it made the list read as stripes. It takes the treatment the + album page already gives text standing on artwork — brighter ink and a + drop shadow (`.kind`, `.stat`, `.np-from` all do exactly this). */ .rel-year { - margin: 12px 2px 3px; + margin: 10px 2px 3px; font-size: 10px; font-weight: 800; - letter-spacing: 0.12em; - color: var(--text-muted); - opacity: 0.75; + letter-spacing: 0.1em; + color: rgba(255, 255, 255, 0.72); + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.55); font-variant-numeric: tabular-nums; } .rel-rows { display: flex; flex-direction: column; gap: 2px; } diff --git a/src/views/CatalogArtist.svelte b/src/views/CatalogArtist.svelte index f85c6f8..7c3b832 100644 --- a/src/views/CatalogArtist.svelte +++ b/src/views/CatalogArtist.svelte @@ -38,6 +38,7 @@ editionLabel, isEdition, releaseFamilies, + splitDescription, } from "../lib/editions.js"; import { listenerHeat, listenerPeak, listenerShare } from "../lib/listenerBars.js"; import { buildArtistManifest } from "../lib/preview/manifest.js"; @@ -2450,6 +2451,27 @@ return sized; } + /** Lede + body for the open record, from the shared splitter. */ + const railDesc = $derived(splitDescription(openInfo?.description)); + + /** What the expander is already showing, handed to the album page so its + * skeleton opens holding this record's artwork instead of a grey block. + * `railCover` on purpose: that exact URL is the one already decoded and in + * memory for the rail, so the seeded cover costs no request and cannot + * flash on its way in. */ + function albumSeed(album) { + if (!album?.cover || artBroken[album.cover]) return null; + return { + cover: railCover(album.cover), + title: album.title ?? null, + artist: album.artist ?? page?.artist?.name ?? null, + kind: album.record_type ?? null, + // The open expander has already fetched the real tracks; its count + // beats the card's metadata, which is missing on some rows. + tracks: openTracks.length || album.track_count || null, + }; + } + function handleCoverError(node, originalCover, title = "") { const fallback = coverLoadError( originalCover, @@ -4746,7 +4768,10 @@

@@ -4787,13 +4812,31 @@

{/if} {#if openInfo?.description} -

{openInfo.description}

- + + {#if railDesc.lede} +

{railDesc.lede}

+ {/if} + {#if railDesc.rest} +

{railDesc.rest}

+ + {/if} + {#if openInfo.genre || openInfo.label} +
+ {#if openInfo.genre}{openInfo.genre}{/if} + {#if openInfo.label}{openInfo.label}{/if} +
+ {/if} {/if} {#if openFamily && openFamily.editions.length > 1}
@@ -7904,6 +7947,39 @@ /* Clamped description dissolves at its bottom edge; the quiet text button below expands it, its chevron rotating. */ + /* The promoted first sentence. Only a little larger than the body: the rail + is 250px wide and the title above it is already 17px, so a bigger lede + would compete with the record's own name. */ + .rail-lede { + margin: 0; + font-size: 14px; + font-weight: 600; + line-height: 1.5; + color: rgba(255, 255, 255, 0.9); + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); + } + /* Genre and label only — the date is already in `.rail-meta` directly + above, and repeating it cost a second wrapped row in a 210px column. */ + .rail-pills { + display: flex; + flex-wrap: wrap; + gap: 5px; + } + .gpill { + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.04em; + padding: 3px 9px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.07); + color: rgba(255, 255, 255, 0.82); + border: 1px solid rgba(255, 255, 255, 0.07); + } + .gpill.accent { + background: color-mix(in srgb, var(--accent) 30%, rgba(10, 10, 12, 0.4)); + border-color: color-mix(in srgb, var(--accent) 50%, transparent); + color: #fff; + } .rail-desc { margin: 0; font-size: 12.5px;