diff --git a/src/lib/listenerBars.js b/src/lib/listenerBars.js index 10fa0c6..d8d1d4a 100644 --- a/src/lib/listenerBars.js +++ b/src/lib/listenerBars.js @@ -2,10 +2,11 @@ * The listener bar in a tracklist: how a Last.fm listener count becomes a bar * width, and how warm that bar burns. * - * Shared, because the album page and the artist page's open-in-place album - * panel both draw this column. A second implementation would quietly disagree - * with the other about how big a record's hit really was — the same reason - * `editions.js` was lifted out of the artist page. + * Shared, because four surfaces draw this bar — the search page's song rows, + * the album page, the artist page's open-in-place album panel and the + * discography's record sections. A second implementation would quietly + * disagree with the others about how big a record's hit really was — the + * same reason `editions.js` was lifted out of the artist page. */ /** @@ -62,11 +63,7 @@ export function listenerShare(listeners, peak) { return Math.max(2, Math.round(Math.pow(listeners / peak, COUNT_GAMMA) * 100)); } -/** - * The same curved share as a 0..1 temperature driving the fill's colour, so - * length and colour never tell two different stories about one track. - */ -export function listenerHeat(listeners, peak) { - if (!listeners || !peak) return "0.000"; - return Math.pow(listeners / peak, COUNT_GAMMA).toFixed(3); -} +// listenerHeat — the share as a 0..1 temperature driving fill colour and +// alpha — lived here until 2026-08-30, when the heat-wash bars were retired +// for the search page's solid pill on every surface and colour stopped +// carrying data. diff --git a/src/views/CatalogAlbum.svelte b/src/views/CatalogAlbum.svelte index 940bb3f..53ed647 100644 --- a/src/views/CatalogAlbum.svelte +++ b/src/views/CatalogAlbum.svelte @@ -27,7 +27,7 @@ import * as api from "../lib/api.js"; import { accentFromImage } from "../lib/color.js"; import { editionLabel, releaseFamilies, splitDescription } from "../lib/editions.js"; - import { listenerHeat, listenerPeak, listenerShare } from "../lib/listenerBars.js"; + import { listenerPeak, listenerShare } from "../lib/listenerBars.js"; import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js"; import Icon from "../components/Icon.svelte"; import PlaylistMenu from "../components/PlaylistMenu.svelte"; @@ -239,7 +239,6 @@ const countPeak = $derived(listenerPeak(tracks)); const showCount = $derived(countPeak > 0 && tracks.length >= 3); const countShare = (t) => listenerShare(t.listeners, countPeak); - const countHeat = (t) => listenerHeat(t.listeners, countPeak); /** Unknown is a dash, never a zero — same rule as the artist page. */ const fmtCount = (n) => (n == null ? "—" : n.toLocaleString()); @@ -912,7 +911,6 @@ class:now={nowPlaying("trackId", t.id)} class:flash={flashId === t.id} class:locked - style={showCount ? `--t:${countHeat(t)}` : undefined} role="button" aria-disabled={locked} tabindex={locked ? -1 : 0} @@ -935,9 +933,8 @@ ? "No Last.fm listener count for this track" : `${t.listeners.toLocaleString()} Last.fm listeners`} > - - {fmtCount(t.listeners)} - + + {fmtCount(t.listeners)} {/if} {#if locked} @@ -1774,45 +1771,44 @@ margin-left: 6px; } - /* Plays: length AND colour both carry the count, so a deep cut reads short - and cool while the record's biggest song burns the album's own accent. */ + /* Listener weight, the app-wide design: the search page's pill — a visible + track, a solid accent fill scaled by the shared curve, the number + standing centred on top (src/lib/listenerBars.js). Length is the ONLY + encoding: the old heat wash here drove colour AND alpha from the count + (--t on the row), which made every page's bars read as a different + design — retired for the one pill (Kyle, 2026-08-30). Keep these rules + in step with .lbar in CatalogSearch.svelte — one dial, four files. + align-self centres the 17px pill in the taller grid row. */ .tcount { position: relative; - display: flex; - align-items: center; - height: 21px; + height: 17px; + align-self: center; border-radius: 4px; overflow: hidden; - font-size: 12.5px; - font-variant-numeric: tabular-nums; + background: rgba(255, 255, 255, 0.07); } - /* The number lives INSIDE the fill rather than floating over it, so - `min-width: max-content` makes every bar at least as wide as the digits - standing on it. The floor is per-row and measured by the browser in the - real font — the width JS hands over is only ever the lower bound. */ - .tcount-fill { - display: flex; - align-items: center; - height: 100%; - min-width: max-content; - max-width: 100%; + .tcount i { + position: absolute; + inset: 0; border-radius: 4px; - background: color-mix( - in srgb, - color-mix(in srgb, var(--accent-hover) calc(22% + var(--t) * 78%), var(--text-muted)) - calc(30% + var(--t) * 55%), - transparent - ); + background: var(--accent); + transform-origin: left; + transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1); } /* A track Last.fm has no count for gets the dash and no paint at all — a - floor-width pill behind an em dash would read as a measured tiny value. */ - .tcount.empty .tcount-fill { - background: none; + floor-width bar behind an em dash would read as a measured tiny value. */ + .tcount.empty i { + display: none; } .tcount-val { - padding: 0 8px; - white-space: nowrap; - color: color-mix(in srgb, #fff calc(45% + var(--t) * 55%), var(--text-muted)); + position: absolute; + inset: 0; + display: grid; + place-items: center; + font-size: 10.5px; + font-weight: 800; + font-variant-numeric: tabular-nums; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.65); } .tdur { diff --git a/src/views/CatalogArtist.svelte b/src/views/CatalogArtist.svelte index cfc083c..6abfdd0 100644 --- a/src/views/CatalogArtist.svelte +++ b/src/views/CatalogArtist.svelte @@ -47,7 +47,7 @@ releaseFamilies, splitDescription, } from "../lib/editions.js"; - import { listenerHeat, listenerPeak, listenerShare } from "../lib/listenerBars.js"; + import { listenerPeak, listenerShare } from "../lib/listenerBars.js"; import { buildArtistManifest } from "../lib/preview/manifest.js"; import { openPreview } from "../lib/preview/previewSession.svelte.js"; import { traceToSvg, svgDataUrl } from "../lib/trace.js"; @@ -843,6 +843,9 @@ // one moved. let showAllPopular = $state(false); const allPopular = $derived(page?.popular_tracks ?? []); + /** The Popular pill's yardstick: the artist's most-heard track, over the + WHOLE list so the widths hold steady across Show more/less. */ + const popularPeak = $derived(listenerPeak(allPopular)); const popularTracks = $derived(showAllPopular ? allPopular : allPopular.slice(0, 5)); $effect(() => { void artistIdentity; @@ -4254,8 +4257,6 @@ const countPeak = $derived(listenerPeak(openTracks)); /** Curved share of the album's peak, as a bar width. */ const countShare = (track) => listenerShare(track.listeners, countPeak); - /** Same share as a 0..1 temperature, driving the fill's colour. */ - const countHeat = (track) => listenerHeat(track.listeners, countPeak); /** Unknown is a dash, never a zero — same rule as the Popular column. */ const fmtCount = (n) => (n == null ? "—" : n.toLocaleString()); @@ -5154,7 +5155,10 @@ {track.title} {track.album} - {fmtListeners(track.listeners)} + + + {fmtListeners(track.listeners)} + {fmtDuration(track.duration_secs)} 0} - style={countPeak > 0 ? `--t:${countHeat(t)}` : undefined} in:partIn={{ delay: Math.min(ti * 16, 180) }} class:now={nowPlaying("trackId", t.id)} class:locked @@ -5512,9 +5515,8 @@ {#if countPeak > 0} - - {fmtCount(t.listeners)} - + + {fmtCount(t.listeners)} {/if} {#if locked} @@ -7893,7 +7895,9 @@ column is the space the overlaid actions land on. */ position: relative; display: grid; - grid-template-columns: 26px 40px minmax(130px, 1fr) minmax(100px, 0.75fr) 62px 40px 24px; + /* 96px listener column — the search page's .lbar width, which the + pill here mirrors (the 62px text column predates the bars). */ + grid-template-columns: 26px 40px minmax(130px, 1fr) minmax(100px, 0.75fr) 96px 40px 24px; align-items: center; gap: 11px; min-height: 54px; @@ -8024,20 +8028,11 @@ font-size: 12px; } - /* All-time Last.fm listeners — what the list is ordered by. Muted, NOT - accent: this row already spends the accent on `.now`, and a blue column - on every row would drown the one that means "playing". Tabular figures so - the digits line up in a right-aligned column. */ - .lis { - color: var(--text-muted); - font-size: 11px; - text-align: right; - font-variant-numeric: tabular-nums; - } - - .lis.unknown { - color: var(--border); - } + /* All-time Last.fm listeners — what the list is ordered by. Drawn as the + app-wide pill (.tcount above, the search page's .lbar): this used to be + muted text so the accent stayed on `.now`, but one bar design across + every count won out (Kyle, 2026-08-30) — the ♫ rank and title still + mark the playing row. */ h2 { font-size: 15px; @@ -9203,50 +9198,44 @@ justify-content: flex-end; } - /* Plays: length AND colour both carry the count, so a deep cut reads short - and cool while the record's biggest song burns the album's own accent. - The floor keeps the quietest track legible instead of letting it sink - into the row. */ + /* Listener weight, the app-wide design: the search page's pill — a visible + track, a solid accent fill scaled by the shared curve, the number + standing centred on top (src/lib/listenerBars.js). Length is the ONLY + encoding: the old heat wash here drove colour AND alpha from the count + (--t on the row), which made every page's bars read as a different + design — retired for the one pill (Kyle, 2026-08-30). Keep these rules + in step with .lbar in CatalogSearch.svelte — one dial, four files. + align-self centres the 17px pill in the taller grid row. */ .tcount { position: relative; - display: flex; - align-items: center; - height: 21px; + height: 17px; + align-self: center; border-radius: 4px; overflow: hidden; - font-size: 12.5px; - font-variant-numeric: tabular-nums; + background: rgba(255, 255, 255, 0.07); } - - /* The number lives INSIDE the fill rather than floating over it, so - `min-width: max-content` makes every bar at least as wide as the digits - standing on it. The floor is per-row and measured by the browser in the - real font — the width JS hands over is only ever the lower bound. */ - .tcount-fill { - display: flex; - align-items: center; - height: 100%; - min-width: max-content; - max-width: 100%; + .tcount i { + position: absolute; + inset: 0; border-radius: 4px; - background: color-mix( - in srgb, - color-mix(in srgb, var(--accent-hover) calc(22% + var(--t) * 78%), var(--text-muted)) - calc(30% + var(--t) * 55%), - transparent - ); + background: var(--accent); + transform-origin: left; + transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1); } - /* A track Last.fm has no count for gets the dash and no paint at all — a - floor-width pill behind an em dash would read as a measured tiny value. */ - .tcount.empty .tcount-fill { - background: none; + floor-width bar behind an em dash would read as a measured tiny value. */ + .tcount.empty i { + display: none; } - .tcount-val { - padding: 0 8px; - white-space: nowrap; - color: color-mix(in srgb, #fff calc(45% + var(--t) * 55%), var(--text-muted)); + position: absolute; + inset: 0; + display: grid; + place-items: center; + font-size: 10.5px; + font-weight: 800; + font-variant-numeric: tabular-nums; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.65); } /* The hover reveal slides the play/save buttons over the right end of the diff --git a/src/views/CatalogDiscography.svelte b/src/views/CatalogDiscography.svelte index 529d449..064bb82 100644 --- a/src/views/CatalogDiscography.svelte +++ b/src/views/CatalogDiscography.svelte @@ -23,6 +23,7 @@ import { editionLabel } from "../lib/editions.js"; import { accentFromImage } from "../lib/color.js"; import { catalogAlbum } from "../lib/api.js"; + import { listenerPeak, listenerShare } from "../lib/listenerBars.js"; /* ---------------------------------------------------------- the releases * NOT re-filed here. `catalog.discoTabs` is the artist page's OWN computed @@ -379,6 +380,9 @@ : n >= 1000 ? Math.round(n / 1000) + "K" : String(n); + /** The listener bar prints the full figure like every other tracklist — + the abbreviated fmtCount above stays for the fans chip. */ + const fmtCountFull = (n) => (n == null ? "—" : n.toLocaleString()); const fmtDur = (s) => s == null ? "—" : Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0"); const runtimeOf = (rows) => { @@ -607,7 +611,7 @@ {/if} {#if rows?.length} - {@const peak = Math.max(1, ...rows.map((x) => x.listeners || 0))} + {@const peak = listenerPeak(rows)} {@const hasCount = rows.some((x) => x.listeners != null)}
@@ -616,18 +620,19 @@
{#each rows as t (t.id)} - {@const heat = (t.listeners || 0) / peak} -
+
{t.track_no ?? "·"} {t.title} {#if hasCount} - - - {fmtCount(t.listeners)} - + + + {fmtCountFull(t.listeners)} {/if} {fmtDur(t.duration_secs)} @@ -1009,37 +1014,44 @@ overflow: hidden; text-overflow: ellipsis; } + /* Listener weight, the app-wide design: the search page's pill — a visible + track, a solid accent fill scaled by the shared curve, the number + standing centred on top (src/lib/listenerBars.js). Length is the ONLY + encoding: the old heat wash here drove colour AND alpha from the count + (--t on the row), which made every page's bars read as a different + design — retired for the one pill (Kyle, 2026-08-30). Keep these rules + in step with .lbar in CatalogSearch.svelte — one dial, four files. + align-self centres the 17px pill in the taller grid row. */ .rec .tcount { position: relative; - display: flex; - align-items: center; - height: 21px; + height: 17px; + align-self: center; border-radius: 4px; overflow: hidden; - font-size: 12.5px; - font-variant-numeric: tabular-nums; + background: rgba(255, 255, 255, 0.07); } - .rec .tcount.empty .tcount-fill { - background: none; - } - .rec .tcount-fill { - display: flex; - align-items: center; - height: 100%; - min-width: max-content; - max-width: 100%; + .rec .tcount i { + position: absolute; + inset: 0; border-radius: 4px; - background: color-mix( - in srgb, - color-mix(in srgb, var(--accent-hover) calc(22% + var(--t) * 78%), var(--text-muted)) - calc(30% + var(--t) * 55%), - transparent - ); + background: var(--accent); + transform-origin: left; + transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1); + } + /* A track Last.fm has no count for gets the dash and no paint at all — a + floor-width bar behind an em dash would read as a measured tiny value. */ + .rec .tcount.empty i { + display: none; } .rec .tcount-val { - padding: 0 8px; - white-space: nowrap; - color: color-mix(in srgb, #fff calc(45% + var(--t) * 55%), var(--text-muted)); + position: absolute; + inset: 0; + display: grid; + place-items: center; + font-size: 10.5px; + font-weight: 800; + font-variant-numeric: tabular-nums; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.65); } .rec .tdur { text-align: right;