follow: the artist page's heart becomes a real thing, stored and celebrated

Following an artist now writes a row: followed_artists (artist_id, name,
portrait, followed_at) beside the playlists in the library db, reached
through follow_artist / unfollow_artist / followed_artists, each emitting
soulseek:follows-changed. Re-following refreshes the name and portrait —
identity improves as pages are visited and a later portrait is usually
the better one — but keeps the original followed_at, so "following since"
stays honest. Name and portrait are stored on purpose rather than looked
up: the home page's "new releases by artists you follow" can then draw a
card from the row alone, with no catalog request per artist.

The store is the UI truth and the chip never keeps its own copy. It is
optimistic — the chip flips at the click and reverts if the write fails,
rather than leaving the screen telling a lie.

The chip itself (FB4) leads the stat row and wears the accent as ground
with dark text, the Play pill's own trick, so its on-state is legible
across the room. The lead slot is a fixed 14x14 in which the +, the
equalizer bars and the check swap places, so nothing about the chip's box
moves except the word — and the word morphs between its measured widths
instead of snapping. Clicking runs the Encore: the + collapses, three
bars dance three beats with a note rising per beat, they bow, and the
check springs in as "Following" grows into place. The db write and the
toast fire at the CLICK; the ceremony is presentation only, so
navigating away mid-show still follows. Unfollow is an undo — the shrink
morphs, nothing celebrates, and the toast says so.

Every effect is template-owned with scoped keyframes. The first build
spawned the glow and the notes imperatively and referenced -global-
keyframes, which silently did not resolve: the glow ran no animation and
sat at full opacity, the notes held opacity 0 and never appeared. The
bars worked throughout for exactly the reason the others did not.

The row around it is shorter too, at Kyle's call: the library and
listening chips (progress meter, the disk popover, format, hours, last
played, top track, genre) come out, leaving the identity trio and the
socials. With nothing left to shed, the socials' scroll-driven fade and
their layout drop at the park boundary come out with them, and the
divider's clear space moves from the follow heart by name to whichever
chip now closes the run. The JS that fed the removed chips stays — it is
cheap, local, and the numbers still have a home coming.
This commit is contained in:
KeelrLeek committed 2026-08-04 14:43:27 -05:00
1 parent 5786269592
commit 520e2ad4e4
8 files changed
+763 -197

No files matched your search

+67
View File
@@ -58,6 +58,17 @@ pub struct PlaylistTrack {
pub art_path: Option<String>, pub art_path: Option<String>,
} }
/// One followed artist. Name and portrait are stored alongside the id so the
/// future home page can render "artists you follow" without paying a catalog
/// request per artist — the row alone is enough to draw a card.
#[derive(Debug, Clone, Serialize)]
pub struct FollowedArtist {
pub artist_id: u64,
pub name: String,
pub portrait: Option<String>,
pub followed_at: i64,
}
/// Cumulative listening stats for one artist (drives the artist-page chips). /// Cumulative listening stats for one artist (drives the artist-page chips).
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct PlayStats { pub struct PlayStats {
@@ -161,6 +172,12 @@ impl Library {
); );
CREATE INDEX IF NOT EXISTS idx_plays_artist ON plays(artist COLLATE NOCASE); CREATE INDEX IF NOT EXISTS idx_plays_artist ON plays(artist COLLATE NOCASE);
CREATE INDEX IF NOT EXISTS idx_plays_started ON plays(started_at DESC); CREATE INDEX IF NOT EXISTS idx_plays_started ON plays(started_at DESC);
CREATE TABLE IF NOT EXISTS followed_artists (
artist_id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
portrait TEXT,
followed_at INTEGER NOT NULL
);
", ",
)?; )?;
// Migrate databases created before artist portraits were added. // Migrate databases created before artist portraits were added.
@@ -612,6 +629,56 @@ impl Library {
Ok(()) Ok(())
} }
// ---- followed artists ----
/// Idempotent: re-following refreshes the stored name/portrait (identity
/// improves over time — a page visit can carry a better portrait) but
/// keeps the original followed_at, so "following since" stays honest.
pub fn follow_artist(
&self,
artist_id: u64,
name: &str,
portrait: Option<&str>,
now: i64,
) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO followed_artists (artist_id, name, portrait, followed_at)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(artist_id)
DO UPDATE SET name = ?2, portrait = COALESCE(?3, portrait)",
rusqlite::params![artist_id as i64, name, portrait, now],
)?;
Ok(())
}
pub fn unfollow_artist(&self, artist_id: u64) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"DELETE FROM followed_artists WHERE artist_id = ?1",
[artist_id as i64],
)?;
Ok(())
}
/// Newest follow first — the order a home-page rail wants.
pub fn followed_artists(&self) -> Result<Vec<FollowedArtist>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT artist_id, name, portrait, followed_at
FROM followed_artists ORDER BY followed_at DESC",
)?;
let rows = stmt.query_map([], |r| {
Ok(FollowedArtist {
artist_id: r.get::<_, i64>(0)? as u64,
name: r.get(1)?,
portrait: r.get(2)?,
followed_at: r.get(3)?,
})
})?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
// ---- playlists ---- // ---- playlists ----
pub fn create_playlist(&self, name: &str, now: i64) -> Result<i64> { pub fn create_playlist(&self, name: &str, now: i64) -> Result<i64> {
+46
View File
@@ -4158,6 +4158,52 @@ pub async fn artist_play_stats(
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
} }
// ---- followed artists ----
#[tauri::command]
pub async fn followed_artists(
lib: State<'_, LibraryState>,
) -> Result<Vec<library::FollowedArtist>, String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || l.followed_artists().map_err(|e| e.to_string()))
.await
.map_err(|e| e.to_string())?
}
#[tauri::command]
pub async fn follow_artist(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
artist_id: u64,
name: String,
portrait: Option<String>,
) -> Result<(), String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || {
l.follow_artist(artist_id, &name, portrait.as_deref(), now_secs())
})
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
let _ = app.emit("soulseek:follows-changed", ());
Ok(())
}
#[tauri::command]
pub async fn unfollow_artist(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
artist_id: u64,
) -> Result<(), String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || l.unfollow_artist(artist_id))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
let _ = app.emit("soulseek:follows-changed", ());
Ok(())
}
// ---- playlists ---- // ---- playlists ----
fn now_secs() -> i64 { fn now_secs() -> i64 {
+3
View File
@@ -77,6 +77,9 @@ pub fn run() {
commands::recent_albums, commands::recent_albums,
commands::clear_play_history, commands::clear_play_history,
commands::play_row_count, commands::play_row_count,
commands::followed_artists,
commands::follow_artist,
commands::unfollow_artist,
commands::list_playlists, commands::list_playlists,
commands::create_playlist, commands::create_playlist,
commands::delete_playlist, commands::delete_playlist,
+2
View File
@@ -19,6 +19,7 @@
import CatalogSearch from "./views/CatalogSearch.svelte"; import CatalogSearch from "./views/CatalogSearch.svelte";
import CatalogArtist from "./views/CatalogArtist.svelte"; import CatalogArtist from "./views/CatalogArtist.svelte";
import CatalogAlbum from "./views/CatalogAlbum.svelte"; import CatalogAlbum from "./views/CatalogAlbum.svelte";
import FollowToast from "./components/FollowToast.svelte";
import { import {
ui, ui,
initEvents, initEvents,
@@ -97,6 +98,7 @@
</main> </main>
<NowPlaying /> <NowPlaying />
<PlayerBar /> <PlayerBar />
<FollowToast />
<PreviewOverlay /> <PreviewOverlay />
<ArtworkViewer /> <ArtworkViewer />
<LoginModal /> <LoginModal />
+154
View File
@@ -0,0 +1,154 @@
<script>
// The follow confirmation — the approved T2: a dark glass pill above the
// player bar with the artist's round portrait riding in it. "You're now
// following {artist}" on follow; unfollow gets the same pill saying so,
// with no celebration anywhere else (that is the follow chip's rule too).
//
// Driven by followToast.seq so two follows in a row re-show rather than
// the second one landing on a toast that is already up. The element stays
// mounted through its exit so the sink-out actually plays — Svelte's
// outro would also work, but the mock's approved motion is a pair of CSS
// keyframes and this keeps them verbatim.
import Icon from "./Icon.svelte";
import { followToast } from "../lib/state.svelte.js";
const HOLD_MS = 2200;
const LEAVE_MS = 280;
let shown = $state(false);
let leaving = $state(false);
let holdTimer = 0;
let leaveTimer = 0;
$effect(() => {
const seq = followToast.seq;
if (!seq) return;
clearTimeout(holdTimer);
clearTimeout(leaveTimer);
// Retrigger cleanly: unmount for a frame so the enter animation
// replays even when the previous toast is still on screen.
shown = false;
leaving = false;
requestAnimationFrame(() => {
shown = true;
holdTimer = setTimeout(() => {
leaving = true;
leaveTimer = setTimeout(() => {
shown = false;
leaving = false;
}, LEAVE_MS);
}, HOLD_MS);
});
return () => {
clearTimeout(holdTimer);
clearTimeout(leaveTimer);
};
});
</script>
{#if shown}
<div class="layer" aria-live="polite">
<div class="toast" class:leaving>
{#if followToast.portrait}
<span class="art"><img src={followToast.portrait} alt="" draggable="false" /></span>
{:else}
<span class="check"><Icon name="check" size={15} /></span>
{/if}
{#if followToast.on}
<span>You're now following <b>{followToast.name}</b></span>
{:else}
<span>Unfollowed <b>{followToast.name}</b></span>
{/if}
</div>
</div>
{/if}
<style>
/* Above the player bar, centred on the window — the player's own height
var keeps it clear of the transport in both collapsed and expanded
states. The offset is three named parts:
var(--player-height) the bar itself (62 rest, 90 expanded)
8px the app shell's bottom gutter under the bar
26px air between the bar's top edge and the toast —
at the mock's 16px total the pill hovered 8px
off the bar and read as touching the window
bottom in a real-height window (measured 78px
up in a 977px viewport).
pointer-events none: a confirmation must never intercept a click aimed
at the lane under it. */
.layer {
position: fixed;
left: 0;
right: 0;
bottom: calc(var(--player-height, 62px) + 8px + 26px);
display: flex;
justify-content: center;
z-index: 80;
pointer-events: none;
}
.toast {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 9px 16px;
border-radius: 99px;
background: rgba(22, 23, 28, 0.94);
border: 1px solid rgba(255, 255, 255, 0.12);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.55);
font-size: 12.5px;
font-weight: 700;
color: #fff;
animation: toast-in 340ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.toast.leaving {
animation: toast-out 260ms ease both;
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(16px) scale(0.95);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes toast-out {
to {
opacity: 0;
transform: translateY(12px) scale(0.97);
}
}
.art {
display: block;
width: 24px;
height: 24px;
border-radius: 50%;
overflow: hidden;
flex: none;
margin-left: -6px;
}
.art img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.check {
display: inline-flex;
color: var(--accent);
}
b {
font-weight: 800;
}
@media (prefers-reduced-motion: reduce) {
.toast,
.toast.leaving {
animation-duration: 1ms;
}
}
</style>
+6
View File
@@ -105,6 +105,12 @@ export const recentAlbums = (limit, minSecs) =>
invoke("recent_albums", { limit: limit ?? null, minSecs: minSecs ?? null }); invoke("recent_albums", { limit: limit ?? null, minSecs: minSecs ?? null });
export const addPlaySeconds = (playId, secs) => invoke("add_play_seconds", { playId, secs }); export const addPlaySeconds = (playId, secs) => invoke("add_play_seconds", { playId, secs });
export const artistPlayStats = (artist) => invoke("artist_play_stats", { artist }); export const artistPlayStats = (artist) => invoke("artist_play_stats", { artist });
// Followed artists — the home redesign's future "new releases by artists you
// follow" reads the same list, which is why the rows carry name and portrait.
export const followedArtists = () => invoke("followed_artists");
export const followArtist = (artistId, name, portrait) =>
invoke("follow_artist", { artistId, name, portrait: portrait ?? null });
export const unfollowArtist = (artistId) => invoke("unfollow_artist", { artistId });
// The WebView blocks target="_blank"; external links open via the backend. // The WebView blocks target="_blank"; external links open via the backend.
export const openExternal = (url) => invoke("open_external", { url }); export const openExternal = (url) => invoke("open_external", { url });
export const albumArt = (path) => invoke("album_art", { path }); export const albumArt = (path) => invoke("album_art", { path });
+55
View File
@@ -83,6 +83,61 @@ function writePanels() {
} }
} }
/* ------------------------------------------------------- followed artists
* Persisted in the library db (followed_artists — id, name, portrait,
* followed_at) so the future home redesign can build "new releases by artists
* you follow" from this list alone. The UI truth is this store; the artist
* page's chip reads it and never keeps its own copy.
*/
export const follows = $state({
loaded: false,
/** artistId -> true. A plain object, not a Set: $state proxies object
* property changes, which is exactly the granularity the chip needs. */
ids: {},
});
export async function loadFollows() {
if (follows.loaded) return;
try {
const list = await api.followedArtists();
follows.ids = Object.fromEntries(list.map((f) => [f.artist_id, true]));
follows.loaded = true;
} catch {
// Backend unavailable — the chip renders unfollowed and stays usable;
// the next call retries because `loaded` never flipped.
}
}
export const isFollowed = (artistId) => artistId != null && !!follows.ids[artistId];
/** Optimistic: the chip flips immediately, the db write follows, and a write
* failure flips it back rather than leaving the UI lying. */
export async function setFollowed(artist, on) {
const id = artist?.id;
if (id == null) return;
const prev = !!follows.ids[id];
if (on) follows.ids[id] = true;
else delete follows.ids[id];
try {
if (on) await api.followArtist(id, artist.name ?? "", artist.picture ?? null);
else await api.unfollowArtist(id);
} catch {
if (prev) follows.ids[id] = true;
else delete follows.ids[id];
}
}
/** The bottom toast (approved T2: portrait pill). `seq` is the trigger — every
* announcement bumps it, so following twice in a row still re-shows. */
export const followToast = $state({ seq: 0, on: false, name: "", portrait: null });
export function announceFollow(on, artist) {
followToast.seq += 1;
followToast.on = on;
followToast.name = artist?.name ?? "";
followToast.portrait = artist?.picture ?? null;
}
export const ui = $state({ export const ui = $state({
view: "home", view: "home",
albumId: null, // selected album in Library view albumId: null, // selected album in Library view
+430 -197
View File
@@ -30,6 +30,10 @@
albumDisambiguator, albumDisambiguator,
openCatalogAlbum, openCatalogAlbum,
openAlbumFromPreviewItem, openAlbumFromPreviewItem,
loadFollows,
isFollowed,
setFollowed,
announceFollow,
} from "../lib/state.svelte.js"; } from "../lib/state.svelte.js";
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";
@@ -4316,18 +4320,134 @@
soundcloud: { icon: "brand-soundcloud", label: "SoundCloud", rgb: "208,162,132" }, soundcloud: { icon: "brand-soundcloud", label: "SoundCloud", rgb: "208,162,132" },
}; };
// TODO(follow): visual-only for now — no persistence. Resets when the /* ---- the follow chip (approved 2026-08-04: FB4, leading the row, the
// artist changes so a heart never carries over to the next page. * Encore choreography, the portrait toast) --------------------------------
let followed = $state(false); *
let followedArtistId = null; * Truth lives in the `follows` store — persisted in the library db, read
* by the future home page — and this page keeps only the VISUAL state,
* which deliberately lags it: on follow the db write and the toast fire at
* the click, but the chip walks through the ceremony (+ collapses → three
* bars dance → the check springs in with the label) and only wears its
* on-state at the check's arrival. Unfollow is an undo and skips the show.
*
* fbShown/fbStage drive the markup through Svelte; the ceremony flips them
* on timers. Only the floating notes and the glow are imperative spawns
* (they are transient siblings the template must not own), which is why
* their selectors are :global below — Svelte prunes classes it cannot see
* in the markup, and a pruned rule simply never renders (the .namebox
* lesson).
*/
let fbEl = $state(null);
let fbShown = $state(false);
let fbStage = $state("idle"); // "idle" | "bars"
let fbJustLanded = $state(false); // gates the check/label entrances to the ceremony
// $state because the template reads it (the + wears lead-out while the
// ceremony runs) — a plain let never re-renders in runes mode.
let fbCeremony = $state(false);
let fbTimers = [];
$effect(() => { $effect(() => {
const id = page?.artist.id ?? null; void loadFollows();
if (id !== followedArtistId) { const id = page?.artist?.id;
followedArtistId = id; const on = id != null && isFollowed(id);
followed = false; if (!fbCeremony) {
fbShown = on;
fbStage = "idle";
fbJustLanded = false;
} }
}); });
// A navigation mid-ceremony must not leak timers into the next artist.
$effect(() => {
void page?.artist?.id;
return () => {
fbTimers.forEach(clearTimeout);
fbTimers = [];
fbCeremony = false;
};
});
/** Follow/Following width change played, never snapped: the label's width
* is animated between its measured sizes and the chip grows with it. */
function fbMorphLabel(mutate) {
const label = fbEl?.querySelector(".fb-label");
const w0 = label ? label.getBoundingClientRect().width : null;
mutate();
if (label == null || w0 == null || reducedMotion()) return;
void tick().then(() => {
const now = fbEl?.querySelector(".fb-label");
if (!now) return;
const w1 = now.getBoundingClientRect().width;
if (Math.abs(w1 - w0) > 1) {
now.animate(
[{ width: `${w0}px` }, { width: `${w1}px` }],
{ duration: 240, easing: "cubic-bezier(0.22, 1, 0.36, 1)" },
);
}
});
}
/** The ceremony's floating notes — rendered by the template while
* fbCeremony holds, positioned from the lead slot at click time. Their
* animations end holding opacity 0 (fill: both) well before the 1800ms
* teardown unmounts them, so nothing ever pops off mid-fade. */
let fbNotes = $state([]);
function toggleFollow() {
const artist = page?.artist;
if (!artist || fbCeremony) return;
if (fbShown) {
// Unfollow: an undo. The shrink still morphs; nothing celebrates.
void setFollowed(artist, false);
announceFollow(false, artist);
fbMorphLabel(() => {
fbShown = false;
fbJustLanded = false;
});
return;
}
// Persist and toast at the CLICK — the ceremony is presentation, and a
// user who navigates away mid-show must still have followed.
void setFollowed(artist, true);
announceFollow(true, artist);
if (reducedMotion()) {
fbMorphLabel(() => (fbShown = true));
return;
}
fbCeremony = true;
// One note per beat, rising from the bars: positions taken from the
// lead slot NOW, rendered by the template for as long as the ceremony
// holds. Their flights end at 1710ms (delay 760 + 950) holding opacity
// 0, so the 1800ms teardown only ever removes what has already faded —
// the mock paid twice to learn that removing a note any earlier
// cancels its animation mid-fade.
const lead = fbEl.querySelector(".fb-lead");
const lr = lead.getBoundingClientRect();
const br = fbEl.getBoundingClientRect();
const lx = lr.left - br.left + lr.width / 2;
fbNotes = [0, 1, 2].map((i) => ({
cx: lx + (i - 1) * 4,
sway: i % 2 ? -7 : 7,
delay: 240 + i * 260,
glyph: i === 1 ? "♫" : "♪",
}));
fbTimers = [
setTimeout(() => (fbStage = "bars"), 170),
setTimeout(() => {
fbJustLanded = true;
fbMorphLabel(() => {
fbStage = "idle";
fbShown = true;
});
}, 1070),
setTimeout(() => {
fbNotes = [];
fbJustLanded = false;
fbCeremony = false;
}, 1800),
];
}
// First→latest release year of the released discography ("years active"). // First→latest release year of the released discography ("years active").
// A release within the last two calendar years reads as "present". // A release within the last two calendar years reads as "present".
const activeYears = $derived.by(() => { const activeYears = $derived.by(() => {
@@ -4632,73 +4752,79 @@
its stat is empty rather than showing a zero. --> its stat is empty rather than showing a zero. -->
{#if !page.art_preview} {#if !page.art_preview}
<div class="stat-chips"> <div class="stat-chips">
{#if releasedCount > 0} <!-- LEADS the row (Kyle's seat pick). The lead
<span class="chip"><Icon name="disc" size={13} /> {releasedCount} releases</span> slot is fixed 14x14 so the + , the bars and
{/if} the check swap inside it without the chip
{#if page.artist.fans} ever changing size for any reason except
<span class="chip"><Icon name="users" size={13} /> {fmtFans(page.artist.fans)}</span> the word itself — and that change morphs
{/if} (fbMorphLabel), never snaps. -->
{#if activeYears} <button
<span class="chip" title="Years active"><Icon name="calendar" size={13} /> {activeYears}</span> class="chip fb"
{/if} class:on={fbShown}
{#if libraryOwnedCount > 0 && releasedCount > 0} bind:this={fbEl}
<span class="chip" title="{libraryOwnedCount} of {releasedCount} releases in your library"> title={fbShown ? "Unfollow artist" : "Follow artist"}
<Icon name="library" size={13} /> {libraryOwnedCount}/{releasedCount} aria-pressed={fbShown}
<span class="chip-bar"><span onclick={toggleFollow}
class="chip-fill" >
style="width: {Math.min(100, Math.round((libraryOwnedCount / releasedCount) * 100))}%" <span class="fb-lead">
></span></span> {#if fbStage === "bars"}
</span> <span class="lead-eq" aria-hidden="true">
{/if} <b style="--delay:0ms"></b><b style="--delay:90ms"
{#if artistDisk.bytes > 0} ></b><b style="--delay:180ms"></b>
<span class="chip has-pop"> </span>
<Icon name="hard-drive" size={13} /> {fmtBytes(artistDisk.bytes)} {:else if fbShown}
{#if artistDisk.perAlbum.length} <span class="lead-glyph" class:lead-in={fbJustLanded}>
<span class="chip-pop" role="tooltip"> <Icon name="check" size={13} strokeWidth={3} />
{#each artistDisk.perAlbum as row (row.title)} </span>
<span class="pop-row"> {:else}
<span class="pop-title">{row.title}</span> <span class="lead-glyph" class:lead-out={fbCeremony}>
<span class="pop-val">{fmtBytes(row.bytes)}</span> <Icon name="plus" size={13} strokeWidth={3} />
</span>
{/each}
</span> </span>
{/if} {/if}
</span> </span>
{/if} <span class="fb-label" class:label-in={fbJustLanded}>
{#if artistDisk.quality} {fbShown ? "Following" : "Follow"}
<span class="chip" title="Dominant format of your saved tracks">
<Icon name="music" size={13} /> {artistDisk.quality}
</span> </span>
<!-- Template-owned, like the bars — NOT
spawned. The spawned version routed
through -global- keyframes and the
reference never resolved: the glow sat
at full opacity and the notes at zero.
Svelte's fine-grained updates leave
these siblings alone when the label
and lead swap at the check moment, so
the detach-restart trap does not
apply here either. -->
{#if fbCeremony}
<span class="fb-glow" aria-hidden="true"></span>
{#each fbNotes as n (n.delay)}
<span
class="fb-note"
aria-hidden="true"
style="--cx:{n.cx}px; --sway:{n.sway}px; --delay:{n.delay}ms"
>{n.glyph}</span>
{/each}
{/if}
</button>
{#if releasedCount > 0}
<span class="chip"><Icon name="disc" size={13} strokeWidth={3} /> {releasedCount} releases</span>
{/if} {/if}
{#if playStats.seconds >= 60} {#if page.artist.fans}
<span class="chip" title="Total listening time"> <span class="chip"><Icon name="users" size={13} strokeWidth={3} /> {fmtFans(page.artist.fans)}</span>
<Icon name="clock" size={13} /> {fmtPlayTime(playStats.seconds)}
</span>
{/if} {/if}
{#if playStats.last} {#if activeYears}
<span class="chip" title="Last played"> <span class="chip" title="Years active"><Icon name="calendar" size={13} strokeWidth={3} /> {activeYears}</span>
<Icon name="history" size={13} /> {fmtLastPlayed(playStats.last)}
</span>
{/if} {/if}
{#if playStats.topTitle && playStats.topCount >= 2} <!-- The library/listening chips that used to
<span class="chip" title="Your most played track"> follow (progress, disk + popover, format,
<Icon name="trophy" size={13} /> {playStats.topTitle} · {playStats.topCount}× hours, last played, top track, genre) and
</span> the follow heart were removed 2026-08-04 at
{/if} Kyle's request — the identity trio above
{#if page.top_genre} and the socials below are the row now. The
<span class="chip" title="Top genre"> JS that fed them (playStats, artistDisk,
<Icon name="tag" size={13} /> {page.top_genre} libraryOwnedCount, followed) is left in
</span> place: it is cheap, local, and the follow
{/if} feature is still planned. -->
<!-- TODO(follow): visual toggle only — persistence
and the followed-artists feature come later. -->
<button
class="chip follow"
class:on={followed}
title={followed ? "Unfollow artist" : "Follow artist"}
aria-pressed={followed}
onclick={() => (followed = !followed)}
><Icon name="heart" size={14} fill={followed} /></button>
{#if page.socials?.length} {#if page.socials?.length}
<!-- ONE flex item, not a run of siblings: <!-- ONE flex item, not a run of siblings:
the whole social block travels to the the whole social block travels to the
@@ -4716,7 +4842,12 @@
title={meta.label} title={meta.label}
style="--soc: {meta.rgb}" style="--soc: {meta.rgb}"
onclick={(e) => { e.preventDefault(); api.openExternal(link.url); }} onclick={(e) => { e.preventDefault(); api.openExternal(link.url); }}
><Icon name={meta.icon} size={16} fill={meta.solid ?? false} /></a> ><Icon
name={meta.icon}
size={16}
strokeWidth={2.6}
fill={meta.solid ?? false}
/></a>
{/each} {/each}
</span> </span>
{/if} {/if}
@@ -5656,9 +5787,10 @@
transform: scale(calc(1 - var(--p) * (1 - var(--mark-end)))); transform: scale(calc(1 - var(--p) * (1 - var(--mark-end))));
} }
.stat-chips > .social-set { /* The social block used to fade here (1 - 2.4p) and leave at the park
opacity: calc(1 - var(--p) * 2.4); boundary. It STAYS now — Kyle's call, 2026-08-04: the stat chips
} around it went instead, and the socials ride the parked row exactly
as they sit at rest. */
.preview-count { .preview-count {
opacity: calc(1 - var(--p) * 2.4); opacity: calc(1 - var(--p) * 2.4);
@@ -5866,19 +5998,10 @@
animation-range: 0px var(--travel, 999999px); animation-range: 0px var(--travel, 999999px);
} }
/* The social block's fade — over --fade-span (= travel / 2.4, its OWN /* The social block's scroll-driven fade lived here, over --fade-span
sentinel from measure()), so opacity = 1 - 2.4p with fill-both (still written by measure() — the badge below rides the same
holding 0 past p = 1/2.4 exactly where the calc clamps, and a window). Removed 2026-08-04: the socials now stay through the park,
degenerate page (999999px sentinel) stays at opacity 1 at any as they sit at rest. */
scrollTop, exactly as the pinned p keeps it today. */
.stat-chips > .social-set {
animation-name: extras-fade-ride;
animation-duration: auto;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: --artist-pane;
animation-range: 0px var(--fade-span, 999999px);
}
/* The badge: same fade window, plus a counter-scale — the parent /* The badge: same fade window, plus a counter-scale — the parent
.hero-preview now shrinks by transform, which the badge (16px at .hero-preview now shrinks by transform, which the badge (16px at
@@ -6924,29 +7047,14 @@
display: none; display: none;
} }
/* EVERY STAT CHIP survives into the banner now; only the social links go. /* THE WHOLE ROW survives into the banner now — the identity chips AND the
They used to be shed by count — `:nth-child(n + 4)` — which knows social links. The socials used to fade (per-branch ramps) and leave
nothing about where the row's lines break: on a three-line row it layout at the 0.42 park boundary; all four of those mechanisms came out
deleted the genre chip and the heart from BESIDE years-active, leaving on 2026-08-04 when the library/listening chips were removed instead —
it alone on a half-empty line. Cutting at the social block instead is a with the row down to three short chips plus the socials, there is
structural boundary, so a line either survives whole or leaves whole. nothing left that needs shedding. apply() still writes --extra-hit and
The opacity ramp lives per-branch (fallback: the live calc; data-park; they are simply unconsumed by the socials now (the badge
scroll-driven: extras-fade-ride); the hit gate stays here in base for keeps its own ride). */
both — pointer-events is not animatable, so apply() keeps writing
--extra-hit (boundary-only on the supported path). Redundant with the
layout collapse below, and kept: it needs no attribute to have landed. */
.stat-chips > .social-set {
pointer-events: var(--extra-hit, auto);
}
/* ...and past that same boundary the block leaves LAYOUT too, so its line
goes with it. Fading alone kept the line, and the row stayed two lines
tall with nothing in the lower one. `display` is not animatable, but by
0.42 the block is already at opacity 0, so nothing pops — and with the
row anchored flex-start, nothing above it moves either. */
.artist-page[data-park="1"] .stat-chips > .social-set {
display: none;
}
.chip { .chip {
display: inline-flex; display: inline-flex;
@@ -6963,106 +7071,228 @@
white-space: nowrap; white-space: nowrap;
} }
/* 0.45 was most of why these read as "thin": a 13px glyph at 45% white on
the hero's dark tail has almost no edge left to see, whatever its stroke
is. Lifted to 0.72 — still quieter than the chip's own 0.8 text, so the
icon stays subordinate to the number beside it. */
.chip :global(.icon) { .chip :global(.icon) {
color: rgba(255, 255, 255, 0.45); color: rgba(255, 255, 255, 0.72);
} }
/* Mini collection meter inside the library chip — the Steam-achievements /* The library-progress meter, the disk chip's per-album popover and the
analog: how much of the discography is saved locally. */ follow heart's rules lived here; their chips were removed 2026-08-04
.chip-bar { (Kyle's call) and Svelte treats an unused selector as deleted anyway —
width: 44px; a warning per rule, and a wall of those is where a real one hides. */
height: 4px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.16);
overflow: hidden;
}
.chip-fill { /* ---- the follow chip (FB4 · Encore · leads the row) -------------------
display: block; Chip metrics come from .chip; only the action-ness is added here. The
height: 100%; on-state is the accent as ground with dark text — the Play pill's own
border-radius: 2px; trick — which is what made FB4 the pick: ON is visible from across the
background: var(--accent); room. */
} .chip.fb {
cursor: pointer;
/* Hover popover on the disk chip: per-album storage breakdown. Pure CSS
(opens on hover/focus), anchored above the chip so it never collides
with the Popular list below. */
.chip.has-pop {
position: relative; position: relative;
} }
.chip.fb:hover {
.chip-pop {
position: absolute;
left: 0;
bottom: calc(100% + 8px);
z-index: 30;
display: flex;
flex-direction: column;
gap: 3px;
min-width: 240px;
max-width: 340px;
max-height: 300px;
overflow-y: auto;
padding: 9px 11px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
background: rgba(14, 15, 18, 0.92);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.5);
opacity: 0;
visibility: hidden;
transition: opacity 140ms ease, visibility 140ms;
pointer-events: none;
}
.chip.has-pop:hover .chip-pop,
.chip.has-pop:focus-within .chip-pop {
opacity: 1;
visibility: visible;
}
.pop-row {
display: flex;
justify-content: space-between;
gap: 14px;
font-size: 11px;
font-weight: 500;
line-height: 1.5;
}
.pop-title {
color: rgba(255, 255, 255, 0.82);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.pop-val {
color: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
/* Follow heart: a square chip-styled button closing the row. Fills with
the accent when active. Visual-only until the follow feature lands. */
.chip.follow {
width: 27px;
padding: 0;
justify-content: center;
cursor: pointer;
}
.chip.follow:hover {
border-color: color-mix(in srgb, var(--accent) 55%, transparent); border-color: color-mix(in srgb, var(--accent) 55%, transparent);
color: #fff; color: #fff;
} }
.chip.fb.on {
.chip.follow.on { background: var(--accent);
border-color: transparent;
color: #0a0a0c;
font-weight: 700;
}
.chip.fb.on:hover {
filter: brightness(1.08);
color: #0a0a0c;
}
/* The fixed slot the whole ceremony happens in: the +, the bars and the
check swap INSIDE it, so nothing about the chip's box ever moves except
the word — and the word morphs (fbMorphLabel), never snaps. */
.fb-lead {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
flex: none;
}
.lead-glyph {
display: inline-flex;
}
.lead-glyph.lead-out {
animation: lead-plus-out 180ms cubic-bezier(0.5, 0, 0.8, 0.5) both;
}
@keyframes lead-plus-out {
to {
transform: rotate(100deg) scale(0.15);
opacity: 0;
}
}
.lead-eq {
display: inline-flex;
align-items: flex-end;
gap: 2px;
height: 13px;
}
/* height: 100% is load-bearing — a bare b has no intrinsic height, and
without it the bars render 3px wide and zero tall: present, invisible
(the mock shipped exactly that bug). */
.lead-eq b {
width: 3px;
height: 100%;
border-radius: 1px;
background: var(--accent);
transform-origin: bottom;
animation: lead-eq-dance 880ms ease-in-out var(--delay) both;
}
/* Rise, three uneven beats, and a bow — the bars END low, handing the
slot to the check instead of being cut off mid-jump. */
@keyframes lead-eq-dance {
0% {
transform: scaleY(0.12);
}
12% {
transform: scaleY(1);
}
26% {
transform: scaleY(0.35);
}
40% {
transform: scaleY(0.92);
}
54% {
transform: scaleY(0.28);
}
68% {
transform: scaleY(0.82);
}
82% {
transform: scaleY(0.3);
}
100% {
transform: scaleY(0.14);
}
}
.lead-glyph.lead-in {
animation: lead-check-in 340ms cubic-bezier(0.34, 1.5, 0.6, 1) both;
}
@keyframes lead-check-in {
0% {
transform: scale(0) rotate(-45deg);
opacity: 0;
}
55% {
transform: scale(1.28) rotate(6deg);
opacity: 1;
}
100% {
transform: none;
opacity: 1;
}
}
/* The label is the chip's only variable-width part; overflow hidden lets
fbMorphLabel reveal the longer word by animating THIS box's width. */
.fb-label {
display: inline-block;
overflow: hidden;
white-space: nowrap;
}
.fb-label.label-in {
animation: label-in 260ms ease 40ms both;
}
@keyframes label-in {
from {
opacity: 0.25;
transform: translateY(3px);
}
to {
opacity: 1;
transform: none;
}
}
/* The + and the check follow the CHIP's colour, exactly as the mock's raw
SVGs did by inheriting currentColor: 0.8 white at rest, #0a0a0c on the
FB4 fill. Without this the page's own `.chip .icon` wash (0.45 white)
applied — a pale check floating on the accent, which is not the card
Kyle approved. */
.chip.fb :global(.icon) {
color: inherit;
}
/* The glow and the notes are TEMPLATE-owned, like the bars — plain scoped
classes, plain scoped keyframes. The first build spawned them
imperatively and routed through -global- keyframes whose reference
never resolved: the glow ran no animation and sat at FULL opacity (the
"random glow"), the notes held their base opacity 0 and never appeared.
The bars worked the whole time for exactly this reason — they were
template + scoped. */
.fb-glow {
position: absolute;
inset: -10px -16px;
border-radius: 99px;
z-index: -1;
pointer-events: none;
background: radial-gradient(
closest-side,
color-mix(in srgb, var(--accent) 55%, transparent),
transparent 72%
);
animation: fb-glow-pulse 1500ms ease both;
}
/* Two pulses: a swell while the bars dance, a brighter beat as the check
lands. */
@keyframes fb-glow-pulse {
0% {
opacity: 0;
}
20% {
opacity: 0.32;
}
55% {
opacity: 0.12;
}
76% {
opacity: 0.42;
}
100% {
opacity: 0;
}
}
.fb-note {
position: absolute;
left: var(--cx);
top: -4px;
color: var(--accent); color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 45%, transparent); font-size: 12px;
line-height: 1;
transform: translate(-50%, 0);
opacity: 0;
pointer-events: none;
animation: fb-note-float 950ms ease-out var(--delay) both;
}
@keyframes fb-note-float {
0% {
transform: translate(-50%, 0) rotate(0deg);
opacity: 0;
}
15% {
opacity: 0.95;
}
40% {
transform: translate(calc(-50% + var(--sway)), -14px) rotate(9deg);
}
70% {
transform: translate(calc(-50% - var(--sway)), -26px) rotate(-8deg);
opacity: 0.7;
}
100% {
transform: translate(calc(-50% + var(--sway)), -38px) rotate(6deg);
opacity: 0;
}
} }
/* Socials: ghost icons after the heart — no chip outline, just the pale /* Socials: ghost icons closing the row — no chip outline, just the pale
brand-tinted glyph. Hover blooms a soft disc in the same brand colour brand-tinted glyph. Hover blooms a soft disc in the same brand colour
(--soc is an "r,g,b" triple so the wash can be alpha'd). (--soc is an "r,g,b" triple so the wash can be alpha'd).
The whole block is one flex item so it wraps as a unit. No margin of its The whole block is one flex item so it wraps as a unit. No margin of its
@@ -7084,7 +7314,10 @@
group has wrapped and hiding the divider would hand back the width that group has wrapped and hiding the divider would hand back the width that
caused the wrap, the group would fit again, the divider would return, caused the wrap, the group would fit again, the divider would return,
and the row would flip between one line and two forever. */ and the row would flip between one line and two forever. */
.stat-chips > .chip.follow:has(+ .social-set) { /* Any chip, not the follow heart by name: the heart was removed with the
stat set (2026-08-04), so whichever chip now closes the run — years
active, today — carries the divider's clear space instead. */
.stat-chips > .chip:has(+ .social-set) {
margin-right: 17px; margin-right: 17px;
} }