one listener bar, five surfaces: the search page's pill wins and the heat wash is retired

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.
This commit is contained in:
KeelrLeek committed 2026-08-31 05:53:01 -05:00
1 parent 385b598170
commit 73a677d35c
4 files changed
+131 -137

No files matched your search

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