From 73a677d35c7a18ab414f3a703df47c8c60bf0ec2 Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Mon, 31 Aug 2026 05:53:01 -0500 Subject: [PATCH] one listener bar, five surfaces: the search page's pill wins and the heat wash is retired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kyle asked for the popularity bars to match the search page "to keep a consistent design across the board". They were two designs, and one of them had quietly drifted into a third. THE SEARCH PAGE'S PILL IS NOW THE APP-WIDE DESIGN: a visible rgba(255,255,255,0.07) track, a solid var(--accent) fill scaled by the shared curve, the full comma-formatted count standing centred on top, 17px tall with a 4px radius. The album page, the artist page's open-in-place album panel, the discography's record sections and the artist page's Popular list all draw it. WHAT WENT AWAY IS THE HEAT WASH. Those three surfaces drove the fill's COLOUR and ALPHA from the count (--t on the row, alpha ramping 0.30-0.85 via a double color-mix, digits ramping with it), so length and colour told one story twice and every page read as a different design. Length is the only encoding now, and listenerHeat is deleted from listenerBars.js — nothing computes a temperature any more. The dark-glass CONTRACT's deliberate exclusion for these bars ("their alpha IS the data encoding") is moot and marked so. THE DISCOGRAPHY HAD DRIFTED FURTHEST and is the reason this mattered: it computed peak/heat inline with a LINEAR share, a 6% floor and abbreviated labels ("12K"), against everyone else's COUNT_GAMMA 0.42 curve, browser-measured max-content floor and full figures. It now imports listenerBars.js like the rest, so no two surfaces can disagree about how big a hit is — the reason the module exists. THE POPULAR LIST joins them: it was plain right-aligned muted text, deliberately not accent so the row's own `.now` accent stayed distinct (the ♫ rank and title still carry that). Its column widened 62px -> 96px, the pill's width, and its peak is taken over the WHOLE list rather than the visible five so the bars do not re-scale on Show more. Kept throughout: unknown counts render an em dash with NO bar (a floor-width pill behind a dash reads as a measured tiny value), and a column drops entirely when Last.fm knew nothing. Verified live against the running app — computed styles on the album and artist pages match the search bar value-for-value. 98/98 tests. --- src/lib/listenerBars.js | 21 +++--- src/views/CatalogAlbum.svelte | 66 +++++++++--------- src/views/CatalogArtist.svelte | 103 +++++++++++++--------------- src/views/CatalogDiscography.svelte | 78 ++++++++++++--------- 4 files changed, 131 insertions(+), 137 deletions(-) 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;