forked from Keelrleek/SoulStream
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:
1 parent
385b598170
commit
73a677d35c
4 files changed
+131
-137
No files matched your search
+9
-12
@@ -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.
|
||||
@@ -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`}
|
||||
>
|
||||
<span class="tcount-fill" style={`width:${countShare(t)}%`}>
|
||||
<span class="tcount-val">{fmtCount(t.listeners)}</span>
|
||||
</span>
|
||||
<i style={`transform:scaleX(${countShare(t) / 100})`}></i>
|
||||
<span class="tcount-val">{fmtCount(t.listeners)}</span>
|
||||
</span>
|
||||
{/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 {
|
||||
|
||||
@@ -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 @@
|
||||
</span>
|
||||
<span class="track-name">{track.title}</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>
|
||||
<PlaylistMenu
|
||||
compact
|
||||
@@ -5494,7 +5498,6 @@
|
||||
<div
|
||||
class="trow"
|
||||
class:has-count={countPeak > 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 @@
|
||||
</span>
|
||||
{#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-fill" style={`width:${countShare(t)}%`}>
|
||||
<span class="tcount-val">{fmtCount(t.listeners)}</span>
|
||||
</span>
|
||||
<i style={`transform:scaleX(${countShare(t) / 100})`}></i>
|
||||
<span class="tcount-val">{fmtCount(t.listeners)}</span>
|
||||
</span>
|
||||
{/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
|
||||
|
||||
@@ -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)}
|
||||
<div class="tracklist">
|
||||
<div class="tcols" class:has-count={hasCount}>
|
||||
@@ -616,18 +620,19 @@
|
||||
<span class="tdur-head"><Icon name="clock" size={13} /></span>
|
||||
</div>
|
||||
{#each rows as t (t.id)}
|
||||
{@const heat = (t.listeners || 0) / peak}
|
||||
<div class="trow" class:has-count={hasCount} style="--t:{heat.toFixed(3)}">
|
||||
<div class="trow" class:has-count={hasCount}>
|
||||
<span class="tno">{t.track_no ?? "·"}</span>
|
||||
<span class="ttitle">{t.title}</span>
|
||||
{#if hasCount}
|
||||
<span class="tcount" class:empty={!t.listeners}>
|
||||
<span
|
||||
class="tcount-fill"
|
||||
style="width:{Math.max(6, heat * 100).toFixed(1)}%"
|
||||
>
|
||||
<span class="tcount-val">{fmtCount(t.listeners)}</span>
|
||||
</span>
|
||||
<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`}
|
||||
>
|
||||
<i style="transform:scaleX({listenerShare(t.listeners, peak) / 100})"></i>
|
||||
<span class="tcount-val">{fmtCountFull(t.listeners)}</span>
|
||||
</span>
|
||||
{/if}
|
||||
<span class="tdur">{fmtDur(t.duration_secs)}</span>
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user