Files
SoulStream/src/components/Sidebar.svelte
T

1020 lines
36 KiB
Svelte

<script>
import {
app,
ui,
network,
player,
downloads,
playlistStore,
recentsStore,
refreshPlaylists,
refreshRecents,
toggleSidebar,
goToView,
openRecentAlbum,
getArt,
playlistCoverFor,
} from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import { markAppScroll, appScrollIdleFor } from "../lib/scrollbar.js";
import Icon from "./Icon.svelte";
// ── the periscope (dark glass only) ─────────────────────────────────
// The bar's backdrop-filter black-flattens over frost (see the CSS note),
// so under glass the bar blurs a CLONE of the list instead: the original
// rows are masked out of the bar band, and a cloneNode copy — filtered
// with a normal blur, which composites fine over frost — paints them
// back as fog, scroll-synced. Approved from the side-by-side test page
// (research/glass-periscope) 2026-08-26.
let glassy = $state(document.documentElement.classList.contains("glass"));
let viewEl = $state(null);
let periEl = $state(null);
$effect(() => {
const mo = new MutationObserver(() => {
glassy = document.documentElement.classList.contains("glass");
});
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
return () => mo.disconnect();
});
$effect(() => {
if (!glassy || !viewEl || !periEl) return;
const view = viewEl;
const peri = periEl;
let pending = 0;
let recloneGen = 0;
let lastRecloneMs = 4;
// Consecutive idle callbacks refused for a too-thin slice — see the
// bounded gate in queueReclone.
let thinSlices = 0;
const sync = () => {
markAppScroll();
const c = peri.firstElementChild?.firstElementChild;
if (c) c.style.transform = `translateY(${-view.scrollTop}px)`;
};
const reclone = () => {
// Blur on an OVERSCAN wrapper, 48px past the clip on every side:
// a gaussian fades toward transparent at its element's bounds,
// and with the bounds out there the falloff is clipped away
// instead of vignetting the band edges (found on the np head —
// same physics here). The clone keeps the scoping class (every
// scoped rule, collapsed mode included, styles it identically)
// and is pinned inside by margins at its measured width.
// height 142 = overscan + band + overscan: the filter rasters
// this box, never the full cloned list.
// BUILD, detached, measured — the duration feeds the next
// debounce delay (see GlassBar; the old node census existed
// only to guess it).
const t0 = performance.now();
const gen = ++recloneGen;
const wrap = document.createElement("div");
wrap.style.cssText =
"position:absolute;top:-48px;left:-48px;right:-48px;height:142px;" +
"overflow:hidden;filter:blur(17px) saturate(1.3);will-change:transform;" +
"pointer-events:none;";
const c = view.cloneNode(true);
c.style.position = "relative";
c.style.margin = "48px 0 0 48px";
c.style.width = view.clientWidth + "px";
c.style.height = "auto";
c.style.overflow = "visible";
c.querySelectorAll("canvas").forEach((n) => n.remove());
wrap.appendChild(c);
lastRecloneMs = performance.now() - t0;
// ATTACH in its own frame (see GlassBar): the style+layout
// flush of the whole clone never shares a task with its
// construction; stale builds and resumed scrolling requeue.
requestAnimationFrame(() => {
if (gen !== recloneGen) return;
if (!appScrollIdleFor(1000)) return queueReclone();
const fresh = !peri.firstElementChild;
if (fresh) {
wrap.style.opacity = "0";
wrap.style.transition = "opacity 160ms ease";
}
peri.replaceChildren(wrap);
if (fresh) requestAnimationFrame(() => (wrap.style.opacity = "1"));
sync();
});
};
// Trailing debounce; no `class` in the filter — hover flips were
// re-cloning per mouse move, and the fog doesn't read hover states.
// Delay scales with list size and the clone runs at idle (see the
// GlassBar note: big pages were janking frames with reclone tasks).
const queueReclone = () => {
clearTimeout(pending);
const delay = Math.min(600, 120 + lastRecloneMs * 3);
pending = setTimeout(() => {
// Never clone mid-scroll, never while hidden (see GlassBar).
// APP-WIDE scroll recency (scrollbar.js note), a full second
// of it, re-checked inside the idle callback with no forcing
// timeout, and the callback yields on a thin idle slice:
// this pane's own churn must not clone while the CONTENT
// pane scrolls.
if (document.hidden) return;
if (!appScrollIdleFor(1000)) return queueReclone();
if ("requestIdleCallback" in window) {
requestIdleCallback((deadline) => {
if (!appScrollIdleFor(1000)) return queueReclone();
// Bounded, same fix as GlassBar (2026-08-30): with a
// frame continuously pending, this machine's idle
// slices cap at ~13.5ms and a hard <15 never passes —
// this pane's fog died at the first content churn
// (every track change) and never rebuilt.
if (deadline && deadline.timeRemaining() < 15 && ++thinSlices < 8) {
return queueReclone();
}
thinSlices = 0;
reclone();
});
} else {
reclone();
}
}, delay);
};
const mo = new MutationObserver((records) => {
for (const r of records) {
if (r.target === view && (r.addedNodes.length || r.removedNodes.length)) {
// Invalidate any built-but-unattached clone (GlassBar note).
recloneGen++;
peri.replaceChildren();
break;
}
}
queueReclone();
});
mo.observe(view, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["src"],
});
const ro = new ResizeObserver(queueReclone);
ro.observe(view);
const onVis = () => {
if (!document.hidden) queueReclone();
};
document.addEventListener("visibilitychange", onVis);
view.addEventListener("scroll", sync, { passive: true });
reclone();
return () => {
recloneGen++;
mo.disconnect();
ro.disconnect();
document.removeEventListener("visibilitychange", onVis);
view.removeEventListener("scroll", sync);
clearTimeout(pending);
peri.replaceChildren();
};
});
// Through goToView, not a bare `ui.view = …`: the glass bar's history and
// trail are fed by recorded visits, and a destination that skips the
// recorder is one the back arrow can never return to.
function navigate(id) {
goToView(id, id !== "library" ? { albumId: null } : {});
}
async function newPlaylist(e) {
e.stopPropagation(); // the + rides the Playlists row; don't also navigate
const name = `New Playlist ${playlistStore.playlists.length + 1}`;
const id = await api.createPlaylist(name);
await refreshPlaylists();
goToView("playlist", { playlistId: id, label: name });
}
// Search and Downloads left the sidebar 2026-08-31: both live inside
// the Soulseek hub now, like the Qt app's own tabs. Their old view ids
// still route (App.svelte aliases them to the hub), so the hub entry
// lights up for them too.
const items = [
{ id: "home", label: "Home", icon: "home" },
{ id: "soulseek", label: "Soulseek", icon: "globe" },
{ id: "community", label: "Community", icon: "users" },
{ id: "library", label: "Your Library", icon: "library" },
{ id: "settings", label: "Settings", icon: "settings" },
{ id: "playlists", label: "Playlists", icon: "playlist" },
];
const HUB_ALIASES = new Set(["soulseek", "search", "downloads"]);
const itemActive = (item) =>
item.id === "soulseek" ? HUB_ALIASES.has(ui.view) : ui.view === item.id;
// ---- the Soulseek status, moved here out of the title bar ----
function statusClick() {
if (app.connection === "online") {
api.disconnect();
app.connection = "offline";
} else if (app.connection === "offline") {
app.showLogin = true;
}
}
// Two letters of the username, so the tile reads as an identity before any
// portrait exists. `app.avatarUrl` is the hook the PFP feature will fill;
// until then the <img> has no src and the initials show through.
const initials = $derived(
(app.username || "").replace(/[^a-z0-9]/gi, "").slice(0, 2).toLowerCase() || "··",
);
// ---- recently played ----
// Day buckets are computed from local calendar dates, not from elapsed
// seconds: something played at 00:30 is "Today", not "8 hours ago".
function dayKey(unixSecs) {
const d = new Date(unixSecs * 1000);
const now = new Date();
const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const t = d.getTime();
if (t >= midnight) return "Today";
if (t >= midnight - 86400000) return "Yesterday";
return "Earlier";
}
// One flat list with day markers folded in, so the markup is a single pass
// and there is no nesting to keep aligned.
const recentRows = $derived.by(() => {
const rows = [];
let day = null;
for (const a of recentsStore.albums) {
const k = dayKey(a.last_played_at);
if (k !== day) {
day = k;
rows.push({ kind: "day", label: k, key: `d:${k}` });
}
// Two id realms, so the key must carry the realm: catalog rows
// have album_id NULL, and keying them all `a:null` was a
// duplicate-key crash that silently killed this whole list the
// moment TWO streamed albums entered history (2026-08-01).
rows.push({
kind: "album",
album: a,
key: a.kind === "catalog" ? `c:${a.catalog_album_id}` : `l:${a.album_id}`,
});
}
return rows;
});
// Covers resolve asynchronously through the shared art cache; keep the
// resolved data URLs keyed by path so a re-render never re-requests.
//
// These are the smallest tiles in the app — 40px — so they take a smaller
// bound than the app-wide default rather than carrying a 512px cover to
// draw a thumbnail. A couple of kilobytes per row.
const RECENT_EDGE = 128;
let artUrls = $state({});
$effect(() => {
for (const a of recentsStore.albums) {
const p = a.art_path;
if (!p || artUrls[p] !== undefined) continue;
artUrls[p] = null;
getArt(p, RECENT_EDGE).then((u) => (artUrls[p] = u)).catch(() => {});
}
});
// A catalog row's cover is already a URL and needs no read; a library row's
// is a file path that has to go through the art cache.
function coverOf(a) {
return a.art_url ?? (a.art_path ? artUrls[a.art_path] : null);
}
// The same set of states the title bar's ring counts as "in flight".
const DL_ACTIVE = new Set(["connecting", "requested", "queued", "transferring"]);
const downloadCount = $derived(
Object.values(downloads.items).filter((d) => DL_ACTIVE.has(d.state)).length,
);
// Which record is playing, so its row can carry the equaliser. The queue
// item carries the cover path the play was recorded against — matching on
// artist/album NAME would be exactly the identity mistake the app forbids,
// so a row lights up only when its art path is the one playing.
const nowItem = $derived(player.queue?.[player.queueIndex] ?? null);
const playingArt = $derived(nowItem?.art ?? null);
const playingCatalogId = $derived(
player.catalogRef?.albumId != null ? String(player.catalogRef.albumId) : null,
);
// Anchored on ids, never on a name.
function isPlaying(a) {
if (a.kind === "catalog") {
return !!a.catalog_album_id && a.catalog_album_id === playingCatalogId;
}
return !!a.art_path && a.art_path === playingArt;
}
$effect(() => {
if (!recentsStore.loaded) refreshRecents();
});
// Pinned playlists ride between the nav and the history — quick access
// to the lists the user chose to keep close, with their covers.
const pinnedPlaylists = $derived(
playlistStore.playlists.filter((p) => p.pinned).slice(0, 6),
);
$effect(() => {
for (const pl of pinnedPlaylists) void playlistCoverFor(pl);
});
</script>
<nav class="sidebar" class:collapsed={ui.sidebarCollapsed}>
<!-- One element, outside the list, so it never fades with the content. -->
<div class="gbar">
{#if glassy}
<div class="peri" bind:this={periEl} aria-hidden="true"></div>
{/if}
<button
class="status"
data-state={app.connection}
onclick={statusClick}
title={app.connection === "online" ? "Click to disconnect" : "Click to connect"}
>
<span class="avcol">
<span class="av">
<span class="avi">
{#if app.avatarUrl}
<img class="pfp" src={app.avatarUrl} alt="" draggable="false" />
{/if}
{initials}
</span>
</span>
</span>
<span class="tx">
<span class="nm">
{#if app.connection === "online"}{app.username}
{:else if app.connection === "connecting"}Signing in
{:else}Not connected{/if}
</span>
<span class="sub">
{#if app.connection === "online"}{network.shareFiles} files shared
{:else if app.connection === "connecting"}one moment
{:else}click to sign in{/if}
</span>
</span>
</button>
</div>
<!-- right:18 puts it dead centre at 64px — (64-28)/2 = 18 — so the
collapsed position falls out of the geometry, not a second rule. -->
<button
class="gicon"
title={ui.sidebarCollapsed ? "Expand" : "Collapse"}
onclick={toggleSidebar}
>
<!-- 16: the panel-top chrome spec — every 28px disc carries a 16px
glyph (glass bar arrows, NowPlaying header, this). -->
<Icon name="chevron-left" size={16} />
</button>
<!-- ONE list. Not two views cross-fading: two views means two copies of
every icon and cover painting at once through the whole crossfade, and
any difference between the copies reads as doubling. Here nothing fades
but text, and no glyph can move — every x comes from padding, which
does not depend on the panel's width. -->
<div class="view" bind:this={viewEl}>
{#each items as item (item.id)}
<button
class="nav-item"
class:active={itemActive(item)}
class:has-count={item.id === "downloads" && downloadCount > 0}
title={ui.sidebarCollapsed ? item.label : null}
onclick={() => navigate(item.id)}
>
<span class="ic"><Icon name={item.icon} size={22} /></span>
<span class="lbl">{item.label}</span>
{#if item.id === "downloads" && downloadCount > 0}
<span class="bdg">{downloadCount > 99 ? "99+" : downloadCount}</span>
{/if}
{#if item.id === "playlists"}
<span
class="rowplus"
role="button"
tabindex="0"
title="New playlist"
onclick={newPlaylist}
onkeydown={(e) => e.key === "Enter" && newPlaylist(e)}
>
<Icon name="plus" size={13} />
</span>
{/if}
</button>
{/each}
{#each pinnedPlaylists as pl (pl.id)}
<button
class="rec"
class:on={ui.view === "playlist" && ui.playlistId === pl.id}
title="{pl.name} — pinned playlist"
onclick={() => goToView("playlist", { playlistId: pl.id, label: pl.name })}
>
{#if playlistStore.covers[pl.id]}
<img src={playlistStore.covers[pl.id]} alt="" draggable="false" />
{:else}
<span class="art-placeholder"><Icon name="playlist" size={15} /></span>
{/if}
<span class="tx">
<span class="n">
<span class="pin-mini" aria-hidden="true"><Icon name="pin" size={10} /></span>
{pl.name}
</span>
<span class="s">{pl.track_count} {pl.track_count === 1 ? "track" : "tracks"}</span>
</span>
</button>
{/each}
<div class="rule"></div>
{#each recentRows as row (row.key)}
{#if row.kind === "day"}
<div class="day"><span>{row.label}</span></div>
{:else}
<button
class="rec"
class:on={isPlaying(row.album)}
title="{row.album.title} — {row.album.artist}"
onclick={() => openRecentAlbum(row.album)}
>
{#if coverOf(row.album)}
<img src={coverOf(row.album)} alt="" draggable="false" />
{:else}
<span class="art-placeholder"><Icon name="music" size={15} /></span>
{/if}
<span class="tx">
<span class="n">
{#if isPlaying(row.album)}
<span class="eq"><i></i><i></i><i></i></span>
{/if}
{row.album.title}
</span>
<span class="s">{row.album.artist}</span>
</span>
</button>
{/if}
{/each}
</div>
</nav>
<style>
.sidebar {
grid-area: sidebar;
background: var(--bg-base);
border-radius: var(--radius);
position: relative;
overflow: hidden;
}
/* ── dark glass: frost + the periscope bar ──────────────────────────
The pane frosts (0.35 over the .app canvas tint). The bar CANNOT use
backdrop-filter here: over see-through frost the compositor flattens
the sampled backdrop against opaque black and the bar renders as a
slab (measured 2026-08-26: band 3 vs pane 40). Instead, under glass:
the original rows are masked out of the bar band, and the .peri box
inside the bar shows a scroll-synced CLONE of the list under a
normal filter blur — normal filters composite correctly over frost
(the np wash proves it daily). Numbers approved on the test page
(research/glass-periscope): frost 0.35, tint 0.28, blur 17, sat 1.3.
Solid mode: every rule below is inert, original recipe throughout. */
:global(:root.glass) .sidebar {
background: rgba(0, 0, 0, 0.35);
}
:global(:root.glass) .gbar {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
/* Direct child only — the periscope clone is inside .peri and must
never inherit this band cut (it would blank the fog). clip-path,
not mask: rectangle clips ride the compositor's fast path (see
App.svelte's band-cut note). */
:global(:root.glass) .sidebar > .view {
clip-path: inset(46px 0 0 0);
}
/* The rows ON the frost. --bg-highlight is opaque #242424, so under glass
every nav row and every pinned/recent row painted a slab on the pane —
the one Kyle photographed first. The white veil makes the same +18 step
against the frost that #242424 makes against #121212 (see the ladder in
global.css). Hover and active deliberately share one value: this sidebar
has always distinguished active by TEXT COLOUR (the note above the base
rule), and that is left exactly as it is. */
:global(:root.glass) .nav-item:hover,
:global(:root.glass) .rec:hover,
:global(:root.glass) .nav-item.active,
:global(:root.glass) .rec.on {
background: var(--glass-hover);
}
/* (.gicon keeps its 0.12 chrome veil under glass — no override needed.) */
/* The artwork well behind a recent with no cover yet. */
:global(:root.glass) .art-placeholder {
background: var(--glass-surface);
}
/* --border is opaque #2a2a2a: a 1px divider on the frost read as a solid
scratch. The hairline token is the same weight against glass. */
:global(:root.glass) .rule {
background: var(--glass-hairline);
}
/* The count badge's backdrop is the frosted pane, so its blur flattens the
sample to black exactly like the bar's did — the fill already carries
the look on its own. */
:global(:root.glass) .bdg {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.peri {
position: absolute;
inset: 0;
overflow: hidden;
z-index: 0;
pointer-events: none;
}
/* The clone's filter and geometry are inline on its overscan wrapper —
built in the reclone() above; the band mask can't reach the clone
(direct-child selector), so no cancel rule is needed here. */
.gbar .status {
position: relative;
z-index: 1;
}
/* ── the glass bar ── */
.gbar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 46px;
display: flex;
align-items: center;
padding: 0 11px;
z-index: 5;
background: rgba(20, 20, 24, 0.28);
backdrop-filter: blur(12px) saturate(1.7);
-webkit-backdrop-filter: blur(12px) saturate(1.7);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.05);
}
/* Panel-top chrome spec (2026-08-31): 28px circle, white glyph, 0.12
white-veil hover in both modes — the glass bar's .arrow language,
shared with the Now Playing header. */
.gicon {
position: absolute;
right: 18px;
top: 9px;
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 50%;
color: #fff;
z-index: 6;
transition:
transform 0.28s cubic-bezier(0.4, 0, 0.2, 1),
background 120ms;
}
.gicon:hover {
background: rgba(255, 255, 255, 0.12);
}
.sidebar.collapsed .gicon {
transform: rotate(180deg);
}
/* ── the Soulseek status ──
Not painted with --accent: the accent is crossfaded from the playing
album's art, so "online" would change colour with the music and currently
renders red. Green means connected, whatever is playing. */
.status {
--av: 26px;
display: flex;
align-items: center;
gap: 11px;
min-width: 0;
max-width: 175px;
flex: 1;
/* 11 (bar padding) + 1 = 12, the image column's left edge */
padding: 2px 10px 2px 1px;
border-radius: 9px;
text-align: left;
transition:
background 0.14s,
opacity 0.26s ease;
}
.status:hover {
background: rgba(255, 255, 255, 0.07);
}
.sidebar.collapsed .status {
opacity: 0;
pointer-events: none;
}
/* A 40px slot with its left edge at 12 — the same construction as a nav
icon — so the avatar is centred on x=32 at ANY size, in line with every
icon and cover below it. */
.avcol {
width: 40px;
flex: none;
display: grid;
place-items: center;
}
.av {
position: relative;
flex: none;
width: var(--av);
height: var(--av);
}
/* Album art is 40px at radius 4 — a ratio of exactly .1. Deriving the
radius from the size holds that ratio at any size, so the avatar reads as
the same object as a cover and a real photo drops straight in. */
.avi {
position: absolute;
inset: 0;
border-radius: calc(var(--av) * 0.1);
overflow: hidden;
display: grid;
place-items: center;
background: linear-gradient(150deg, #3c3c45, #24242a);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.09);
font-size: calc(var(--av) * 0.4);
font-weight: 700;
color: #d8d8e0;
}
.pfp {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
/* The state dot hangs off the corner, so it rides .av (unclipped) and never
.avi (which clips, for the photo). */
.av::after {
content: "";
position: absolute;
right: -2px;
bottom: -2px;
width: 9px;
height: 9px;
border-radius: 50%;
background: #7c7c85;
box-shadow: 0 0 0 2.5px #131316;
}
.status[data-state="online"] .av::after {
background: #3ecf7e;
}
.status[data-state="connecting"] .av::after {
background: #e8a33d;
animation: breathe 1.15s ease-in-out infinite;
}
@keyframes breathe {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
.status .tx {
min-width: 0;
display: flex;
flex-direction: column;
}
.status .nm {
font-size: 12.5px;
font-weight: 600;
color: #fff;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status .sub {
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-muted);
line-height: 1.2;
white-space: nowrap;
}
.status[data-state="online"] .sub {
color: #3ecf7e;
}
.status[data-state="connecting"] .sub {
color: #e8a33d;
}
/* ── the list ──
The 46px inset is the bar's height; the extra 8 stops the first row's
highlight sitting flush against it. */
.view {
position: absolute;
inset: 0;
z-index: 1;
width: 100%;
overflow-y: auto;
overflow-x: hidden;
padding: 54px 6px 12px 6px;
scrollbar-width: none;
}
.view::-webkit-scrollbar {
width: 0;
display: none;
}
/* EVERY GLYPH SHARES ONE CENTRE, x=32, AND NONE OF THEM MOVE.
row starts at 6 (view pad) + 6 (row pad) -> content at 12
cover 40px at 12 -> 12..52, centre 32
icon 22px centred in a 40px slot at 12 -> 21..43, centre 32
At 64px the panel's centre is also 32, so the rail centring falls out of
the same arithmetic — it is not a second rule. These x values come from
padding, which does not depend on the panel's width, so they are
invariant through the whole collapse. Verified at 232/200/168/136/104/80/64. */
.nav-item,
.rec {
margin-bottom: 5px;
}
.nav-item {
display: flex;
align-items: center;
gap: 11px;
width: 100%;
height: 42px;
padding: 0 6px;
border-radius: 6px;
color: var(--text-secondary);
font-weight: 600;
font-size: 14px;
white-space: nowrap;
transition:
color 0.15s,
background 0.15s;
}
.nav-item .ic {
width: 40px;
flex: none;
display: grid;
place-items: center;
position: relative;
}
/* Fixed width so the label never reflows or re-ellipsises as the panel
narrows — it fades, and what is left is clipped by the panel. 118 lands
the count and the + a clear 12-16px inside the highlight. */
.lbl {
width: 118px;
flex: none;
overflow: hidden;
text-overflow: ellipsis;
transition: opacity 0.26s ease;
}
.nav-item:hover {
color: var(--text-primary);
}
/* --bg-highlight, matching NowPlaying's .row:hover exactly. The frosted
version existed to let the album wash run underneath it; with the wash
gone it has nothing to reveal, and an opaque highlight is what the right
panel already uses. Active is distinguished by text colour, which is how
this sidebar has always done it. */
.nav-item:hover,
.rec:hover,
.nav-item.active,
.rec.on {
background: var(--bg-highlight);
}
.nav-item.active,
.rec.on {
color: var(--text-primary);
}
/* margin-left:0, never auto — auto depends on the row's width, so the count
and the + would slide across the panel while fading out. */
.bdg,
.rowplus {
margin-left: 0;
transition: opacity 0.26s ease;
}
.bdg {
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
font-size: 10px;
font-weight: 700;
display: grid;
place-items: center;
flex: none;
font-variant-numeric: tabular-nums;
background: rgba(255, 255, 255, 0.15);
color: #fff;
backdrop-filter: blur(8px) saturate(1.4);
-webkit-backdrop-filter: blur(8px) saturate(1.4);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.13);
}
/* Collapsed there is no room for a numeral, and an 18px one on the glyph
covers it. An 8px dot at (44..52) clears the glyph, which ends at 43. */
.nav-item.has-count .ic::after {
content: "";
position: absolute;
top: 5px;
right: 0;
width: 8px;
height: 8px;
border-radius: 50%;
background: #fff;
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.4);
opacity: 0;
transition: opacity 0.26s ease;
}
.sidebar.collapsed .nav-item.has-count .ic::after {
opacity: 1;
}
/* Draws nothing until you point at the row — the right weight for a
secondary action, and it stops a grey dot floating on the wash. */
.rowplus {
width: 22px;
height: 22px;
border-radius: 50%;
flex: none;
display: grid;
place-items: center;
background: none;
color: transparent;
transition:
opacity 0.26s ease,
background 0.13s,
color 0.13s,
transform 0.13s;
}
.nav-item:hover .rowplus {
color: var(--text-secondary);
}
.rowplus:hover {
background: rgba(255, 255, 255, 0.17);
color: #fff;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.2);
transform: scale(1.08);
}
.rule {
height: 1px;
background: var(--border);
margin: 12px 6px;
}
/* No "Recently played" eyebrow — the rule runs straight into the history.
Collapsed, the day word leaves a hairline so the list keeps its
structure instead of becoming an undifferentiated column. */
.day {
position: relative;
height: 28px;
display: flex;
align-items: flex-end;
padding: 0 6px 5px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.09em;
color: var(--text-muted);
white-space: nowrap;
}
.day span {
transition: opacity 0.26s ease;
}
.day::after {
content: "";
position: absolute;
left: 8px;
right: 8px;
bottom: 8px;
height: 1px;
background: rgba(255, 255, 255, 0.09);
opacity: 0;
transition: opacity 0.26s ease;
}
.sidebar.collapsed .day::after {
opacity: 1;
}
.rec {
display: flex;
align-items: center;
gap: 11px;
width: 100%;
height: 52px;
padding: 0 6px;
border-radius: 6px;
text-align: left;
transition: background 0.15s;
}
.rec img,
.art-placeholder {
width: 40px;
height: 40px;
border-radius: 4px;
object-fit: cover;
flex: none;
transition: transform var(--art-motion);
}
.art-placeholder {
display: grid;
place-items: center;
background: var(--bg-highlight);
color: var(--text-muted);
}
.rec:hover img {
transform: scale(var(--art-grow));
}
.rec .tx {
width: 157px;
flex: none;
overflow: hidden;
transition: opacity 0.26s ease;
}
.rec .n {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
gap: 6px;
}
.rec:hover .n {
color: var(--text-primary);
}
.rec.on .n {
color: var(--accent-hover);
}
.rec .s {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 1px;
}
/* Only text fades. Nothing here changes a position. */
.sidebar.collapsed .lbl,
.sidebar.collapsed .rec .tx,
.sidebar.collapsed .day span,
.sidebar.collapsed .nav-item > .bdg,
.sidebar.collapsed .rowplus {
opacity: 0;
}
/* Pinned playlist rows share the recents' whole construction; the only
addition is the tiny pin riding the name, which fades with the text. */
.pin-mini {
display: inline-flex;
color: var(--text-muted);
flex: none;
}
.eq {
display: inline-flex;
align-items: flex-end;
gap: 2px;
height: 10px;
flex: none;
}
.eq i {
width: 2.5px;
background: var(--accent-hover);
border-radius: 1px;
animation: eq 0.9s ease-in-out infinite;
}
.eq i:nth-child(2) {
animation-delay: -0.2s;
}
.eq i:nth-child(3) {
animation-delay: -0.55s;
}
@keyframes eq {
0%,
100% {
height: 35%;
}
50% {
height: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.gicon,
.lbl,
.rec .tx,
.day span,
.bdg,
.rowplus {
transition: none;
}
.eq i,
.status[data-state="connecting"] .av::after {
animation: none;
}
}
</style>