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:
1 parent
5786269592
commit
520e2ad4e4
8 files changed
+763
-197
No files matched your search
@@ -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> {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user