Files
SoulStream/src/views/Playlists.svelte
T
KeelrLeek 547cee6649 dark glass gets a surface ladder, and the 200-750ms stalls are dead - two sessions of uncommitted work landing together
SURFACES: panes add black, surfaces INSIDE panes add white. Every pane stands
on 0.7075 black over the acrylic (.content 0.35 over .app 0.55), so a
translucent black card nets 0.81 - darker than its own pane and inside the
~0.85 band already rejected once on the artist page. Solid mode's ladder is
measured UPWARD from #121212 (+6/+18/+24), so glass elevates with WHITE: new
--glass-surface/-hover/-press/-hairline/-well/-ring under :root.glass
(0.03/0.08/0.11/0.10/0.18), which is independently the vocabulary
.btn-ghost:hover and .ax-ghost:hover already shipped. The four solid tokens are
NEVER redefined - they are consumed inside color-mix() and gradient stops where
an alpha multiplies through and kills the call site (0.74 x 0.03 = an invisible
chip; the album row's text-overflow fade works by BEING opaque), and they paint
modal chrome that has to stay solid. 129 additive :global(:root.glass)
declarations across 18 files, all 129 verified inside a glass block by walking
the diff - solid mode byte-identical, confirmed structurally, by computed style
with the class off, and visually.

Three of them are not colour swaps: the album row's .rowbtns shim was OCCLUDING
the last ~19px of the listeners bar and nothing translucent can occlude, so
.tcount masks out just before the buttons instead; Playlist's banner scrim and
the discography's pinned ambience both landed on opaque --bg-base and now
dissolve like the album banner; and the 0.9 black hero tint on
Home/CatalogSearch/Discography paints straight onto the pane when there is no
artwork, so :first-child (the {#if} renders no element) flips it to white.
Menus, dialogs, toasts and the import surfaces stay opaque deliberately, and
the Settings copy for the mode now says so instead of claiming cards do.
Measured with a known ground substituted behind the transparent window, since
CDP never sees the acrylic: library toolbar chroma +2 as shipped, +16 with a
white fill AND its backdrop-filter dropped, +30 for the bare pane beside it.

RESIZE: Windows drops the legacy accent-policy acrylic for the whole of a live
resize and the window stays transparent through it, so the desktop punches
through sharp - measured by toggling window_glass alone, desktop icon labels
readable through the titlebar. The blur cannot be kept (documented since Win10
2004, and the modern DWM backdrop was already rejected for the
overlay-promotion blink), so armGlassResizeGuard grounds the canvas to
--bg-sidebar for the length of the gesture.

STALLS: "choppy but the FPS says 144" was 200-750ms MAIN-THREAD TASKS, not
frame pacing - idle-scheduled work fires at scroll pauses by definition. Twin
bake moved to display resolution and off the ready task (was a same-task 4K
WebGL readback plus software blur, 100-170ms per rotation AND per artist
entry); gl.finish() -> gl.flush() in buildMaster, which had been draining the
whole 4K EASU/RCAS/grade chain on the main thread; copyCanvas realloc guarded;
one app-wide scroll clock in scrollbar.js (markAppScroll/appScrollIdleFor) so
all three periscopes stop cloning mid-gesture on ANY pane rather than only
their own; bounded reclone with the querySelectorAll census deleted, the gate
widened 250->1000ms, and the build/attach split across an idle callback and a
rAF behind a generation guard; the hero rotation tick gated on scroll recency
and its payload cache sized to the working set (2->4 entries, 24->32MiB,
32->64MiB); sessionTick no longer force-rewrites every 12s while merely PAUSED.
Scroll storm went from 109-616ms long tasks to zero. Hover recalc and GC were
measured innocent - do not re-chase them.

98/98 tests throughout.
2026-08-27 17:24:20 -05:00

935 lines
31 KiB
Svelte

<script>
// The playlist shelf: every playlist as a cover card — custom art or the
// composed four-cover mosaic — with pinning, duplication, export, and the
// the import doors (playlist file, folder, pasted tracklist, YouTube set).
import {
playlistStore,
refreshPlaylists,
playlistCoverFor,
goToView,
playAlbum,
} from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import { setlistScan, takeScanDraft } from "../lib/setlistScan.svelte.js";
import { fade } from "svelte/transition";
import Icon from "./../components/Icon.svelte";
import PlaylistImportModal from "./../components/PlaylistImportModal.svelte";
let importOpen = $state(false);
let importMode = $state("file"); // "file" | "paste"
let importDraft = $state(null);
let menuFor = $state(null); // playlist id whose ⋯ menu is open
let exportFor = $state(null); // playlist id whose Export flyout is open
let confirmDelete = $state(null); // playlist id armed for deletion
let creating = $state(false);
let newMenuOpen = $state(false);
let probing = $state(false);
$effect(() => {
if (!playlistStore.loaded) refreshPlaylists();
});
// Covers resolve lazily through the store cache; re-render never refetches.
$effect(() => {
for (const pl of playlistStore.playlists) void playlistCoverFor(pl);
});
const pinned = $derived(playlistStore.playlists.filter((p) => p.pinned));
const rest = $derived(playlistStore.playlists.filter((p) => !p.pinned));
// The stat row's numbers — derived from what the store already carries.
const totals = $derived.by(() => {
let tracks = 0;
let secs = 0;
for (const p of playlistStore.playlists) {
tracks += p.track_count ?? 0;
secs += p.duration_secs ?? 0;
}
return { tracks, secs };
});
// The ambience wash borrows the first pinned playlist's cover, else the
// first cover that has resolved. Covers arrive lazily, so this settles a
// beat after the cards do — the keyed fade below makes that a dissolve.
const ambUrl = $derived.by(() => {
const pick =
playlistStore.playlists.find((p) => p.pinned && playlistStore.covers[p.id]) ??
playlistStore.playlists.find((p) => playlistStore.covers[p.id]);
return pick ? playlistStore.covers[pick.id] : null;
});
async function newPlaylist() {
if (creating) return;
creating = true;
try {
const name = `New Playlist ${playlistStore.playlists.length + 1}`;
const id = await api.createPlaylist(name);
await refreshPlaylists();
goToView("playlist", { playlistId: id, label: name });
} finally {
creating = false;
}
}
async function importFromFile() {
newMenuOpen = false;
if (probing) return;
probing = true;
try {
const draft = await api.playlistImportProbe();
if (draft) {
importDraft = draft;
importMode = "file";
importOpen = true;
}
} catch (e) {
console.error("playlist import probe failed", e);
} finally {
probing = false;
}
}
async function importFromFolder() {
newMenuOpen = false;
if (probing) return;
probing = true;
try {
const draft = await api.playlistImportFolder();
if (draft) {
importDraft = draft;
importMode = "file";
importOpen = true;
}
} catch (e) {
console.error("folder import failed", e);
} finally {
probing = false;
}
}
function importFromText() {
newMenuOpen = false;
importDraft = null;
importMode = "paste";
importOpen = true;
}
// A finished background listen: the chip's "review" click lands here —
// consume the waiting draft into the ordinary review modal.
$effect(() => {
if (setlistScan.wantsReview && setlistScan.draft && !importOpen) {
importDraft = takeScanDraft();
importMode = "file";
importOpen = true;
}
});
function importFromYoutube() {
newMenuOpen = false;
importDraft = null;
importMode = "youtube";
importOpen = true;
}
function importFromYoutubePlaylist() {
newMenuOpen = false;
importDraft = null;
importMode = "ytplaylist";
importOpen = true;
}
function open(pl) {
goToView("playlist", { playlistId: pl.id, label: pl.name });
}
// Hover-play, straight from the card — the Library's own affordance.
// Generation-guarded: a slower fetch from an EARLIER click must not
// override the playlist clicked after it.
let playCardGen = 0;
async function playCard(pl) {
const gen = ++playCardGen;
const rows = await api.playlistItems(pl.id);
if (gen !== playCardGen) return;
// Present local files plus stream rows — streams queue as pending
// items that resolve when reached, same as the playlist page's Play.
const playable = rows.filter((r) => (r.path && !r.missing) || r.kind === "stream");
if (!playable.length) {
open(pl); // nothing playable: the page explains why
return;
}
// Set-recording slices: each is bounded by the NEXT stamped row in
// the full order (the playlist page derives the same map).
const segmentEnds = new Map();
for (let i = 0; i < rows.length; i++) {
const r = rows[i];
if (r.kind !== "segment" || r.start_ms == null) continue;
const next = rows.slice(i + 1).find((n) => n.start_ms != null);
segmentEnds.set(r.id, next ? next.start_ms : null);
}
playAlbum(
playable.map((r) => {
const local = r.path && !r.missing;
const tid = Number(r.track_id);
const endMs = segmentEnds.get(r.id);
const segment =
r.kind === "segment" && local && r.start_ms != null
? { start: r.start_ms / 1000, end: endMs != null ? endMs / 1000 : null }
: null;
return {
path: local ? r.path : null,
needsResolve: !local,
title: r.title,
artist: r.artist,
album: r.album,
duration_secs:
r.duration_secs ??
(segment && segment.end != null
? Math.round(segment.end - segment.start)
: null),
art_path: local ? r.art_path : null,
artUrl: local ? null : (r.art_url ?? null),
trackId: Number.isFinite(tid) ? tid : null,
plItemId: r.id,
segment,
};
}),
pl.name,
0,
);
}
function toggleMenu(e, pl) {
e.stopPropagation();
confirmDelete = null;
menuFor = menuFor === pl.id ? null : pl.id;
}
async function togglePin(pl) {
menuFor = null;
await api.setPlaylistPinned(pl.id, !pl.pinned);
await refreshPlaylists();
}
async function duplicate(pl) {
menuFor = null;
const id = await api.duplicatePlaylist(pl.id, `${pl.name} copy`);
await refreshPlaylists();
goToView("playlist", { playlistId: id, label: `${pl.name} copy` });
}
async function exportAs(pl, format) {
menuFor = null;
exportFor = null;
try {
await api.playlistExport(pl.id, format);
} catch (e) {
console.error("playlist export failed", e);
}
}
async function remove(pl) {
if (confirmDelete !== pl.id) {
confirmDelete = pl.id;
return;
}
menuFor = null;
confirmDelete = null;
await api.deletePlaylist(pl.id);
await refreshPlaylists();
}
function onImported(id, name) {
importOpen = false;
importDraft = null;
refreshPlaylists();
goToView("playlist", { playlistId: id, label: name });
}
function fmtDuration(totalSecs) {
if (!totalSecs) return "";
const h = Math.floor(totalSecs / 3600);
const m = Math.round((totalSecs % 3600) / 60);
return h > 0 ? `${h} hr ${m} min` : `${m} min`;
}
// Close any open menu on a page click.
function pageClick() {
menuFor = null;
newMenuOpen = false;
confirmDelete = null;
}
</script>
<svelte:window onclick={pageClick} />
<div class="page">
{#if ambUrl}
<!-- The Library's ambience recipe: a blurred cover pinned behind the
glass, faded out before the cards. Keyed so a cover change
CROSSFADES — background-image is not an animatable property, so
the outgoing wash survives as its own element while the incoming
one fades up over it. -->
{#key ambUrl}
<div class="ambience" style="background-image:url({ambUrl})" transition:fade={{ duration: 600 }}></div>
{/key}
<div class="ambience-glow"></div>
{/if}
<!-- The Library's own header shape: h1 + tools on one line, the stat row
under them. An empty shelf keeps the same header — the Import door
matters most at the moment there is nothing here. -->
<header class="hero">
<div class="hero-top">
<h1>Playlists</h1>
<div class="tools">
<div class="menu-wrap">
<button
class="tool"
onclick={(e) => {
e.stopPropagation();
menuFor = null;
newMenuOpen = !newMenuOpen;
}}
>
<Icon name="download" size={15} /> Import
<Icon name="chevron-down" size={13} />
</button>
{#if newMenuOpen}
<div class="menu" role="menu">
<button class="mi" onclick={importFromFile} disabled={probing}>
<Icon name="file-text" size={15} />
Playlist file…
<span class="hint">M3U · XSPF · PLS · CSV · JSON</span>
</button>
<button class="mi" onclick={importFromFolder} disabled={probing}>
<Icon name="folder-open" size={15} />
Folder of audio files…
<span class="hint">tag or natural order</span>
</button>
<button class="mi" onclick={importFromText}>
<Icon name="pencil" size={15} />
Paste a tracklist…
<span class="hint">plain lines or timestamped setlist</span>
</button>
<button class="mi" onclick={importFromYoutube}>
<Icon name="link" size={15} />
YouTube DJ set…
<span class="hint">tracks from chapters, description or comments</span>
</button>
<button class="mi" onclick={importFromYoutubePlaylist}>
<Icon name="playlist" size={15} />
Playlist from a link…
<span class="hint">YouTube · Spotify · Apple Music · TIDAL · Deezer</span>
</button>
</div>
{/if}
</div>
<button class="tool primary" onclick={newPlaylist} disabled={creating}>
<Icon name="plus" size={15} /> New playlist
</button>
</div>
</div>
{#if playlistStore.playlists.length}
<div class="stats">
<span class="stat">
<b>{playlistStore.playlists.length}</b>
<i>{playlistStore.playlists.length === 1 ? "playlist" : "playlists"}</i>
</span>
<span class="stat-div"></span>
<span class="stat"><b>{totals.tracks.toLocaleString()}</b><i>{totals.tracks === 1 ? "track" : "tracks"}</i></span>
{#if totals.secs}
<span class="stat-div"></span>
<span class="stat"><b>{fmtDuration(totals.secs)}</b><i>of music</i></span>
{/if}
{#if pinned.length}
<span class="stat-div"></span>
<span class="stat"><b>{pinned.length}</b><i>pinned</i></span>
{/if}
</div>
{/if}
</header>
{#if playlistStore.playlists.length === 0}
<div class="empty">
<div class="empty-icon"><Icon name="playlist" size={44} /></div>
<p class="empty-title">No playlists yet</p>
<p class="empty-sub">
Create one, or import an M3U/XSPF file or a pasted tracklist.
</p>
</div>
{:else}
{#if pinned.length}
<div class="lab"><Icon name="pin" size={12} /> Pinned</div>
<div class="grid">
{#each pinned as pl (pl.id)}
{@render card(pl)}
{/each}
</div>
{#if rest.length}
<div class="lab all">All playlists</div>
{/if}
{/if}
<div class="grid">
{#each rest as pl (pl.id)}
{@render card(pl)}
{/each}
</div>
{/if}
</div>
{#snippet card(pl)}
<div class="card-wrap">
<button class="card" type="button" onclick={() => open(pl)}>
<span class="artwrap">
{#if playlistStore.covers[pl.id]}
<img class="art" src={playlistStore.covers[pl.id]} alt="" draggable="false" />
{:else}
<div class="art fallback">
<Icon name="playlist" size={38} />
</div>
{/if}
{#if pl.track_count > pl.unresolved_count}
<span
class="hoverplay"
role="button"
tabindex="0"
title="Play"
onclick={(e) => {
e.stopPropagation();
playCard(pl);
}}
onkeydown={(e) => e.key === "Enter" && (e.stopPropagation(), playCard(pl))}
><Icon name="play" size={13} /></span>
{/if}
</span>
<div class="card-title" title={pl.name}>
{pl.name}
</div>
<div class="card-sub">
{pl.track_count} {pl.track_count === 1 ? "track" : "tracks"}
{#if pl.duration_secs}&thinsp;·&thinsp;{fmtDuration(pl.duration_secs)}{/if}
</div>
{#if pl.kind === "setlist" || pl.unresolved_count > 0}
<div class="badges">
{#if pl.kind === "setlist"}<span class="badge set">Setlist</span>{/if}
{#if pl.unresolved_count > 0}
<span class="badge un">{pl.unresolved_count} unresolved</span>
{/if}
</div>
{/if}
</button>
{#if pl.pinned}
<span class="pinned-mark" title="Pinned"><Icon name="pin" size={12} /></span>
{/if}
<button class="more" title="More" onclick={(e) => toggleMenu(e, pl)}>
<Icon name="more-horizontal" size={17} />
</button>
{#if menuFor === pl.id}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<div class="menu card-menu" role="menu" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<button class="mi" onclick={() => togglePin(pl)}>
<Icon name="pin" size={15} /> {pl.pinned ? "Unpin" : "Pin"}
</button>
<button class="mi" onclick={() => duplicate(pl)}>
<Icon name="layers" size={15} /> Duplicate
</button>
<div class="rule"></div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="sub-wrap"
onmouseenter={() => (exportFor = pl.id)}
onmouseleave={() => (exportFor = null)}
>
<button
class="mi"
onclick={() => (exportFor = exportFor === pl.id ? null : pl.id)}
aria-haspopup="menu"
aria-expanded={exportFor === pl.id}
>
<Icon name="export" size={15} /> Export
<span class="sub-arrow"><Icon name="chevron-right" size={13} /></span>
</button>
{#if exportFor === pl.id}
<div class="menu sub" role="menu">
<button class="mi" onclick={() => exportAs(pl, "m3u8")}>M3U8</button>
<button class="mi" onclick={() => exportAs(pl, "xspf")}>XSPF</button>
<button class="mi" onclick={() => exportAs(pl, "pls")}>PLS</button>
<button class="mi" onclick={() => exportAs(pl, "csv")}>CSV</button>
<button class="mi" onclick={() => exportAs(pl, "json")}>Backup (JSON)</button>
</div>
{/if}
</div>
<div class="rule"></div>
<button class="mi danger" onclick={() => remove(pl)}>
<Icon name="trash" size={15} />
{confirmDelete === pl.id ? "Really delete?" : "Delete"}
</button>
</div>
{/if}
</div>
{/snippet}
<PlaylistImportModal
bind:open={importOpen}
mode={importMode}
draft={importDraft}
onimported={onImported}
/>
<style>
/* The Library page's own frame: the blurred ambience pins behind the
glass bar, so the page starts at the very top of the pane and carries
its own inset instead of taking padding-top from the BAR_VIEWS rule
(the Library exception, documented at the set in App.svelte). The
isolation keeps the z-indexed washes inside this page's own stacking
context — without it they escape past the transparent pane and paint
behind the app's opaque black. */
.page {
position: relative;
isolation: isolate;
--pad-top: calc(var(--bar-h, 46px) + 14px);
padding: var(--pad-top) 0 40px;
}
/* ------------------------------------------------------------ ambience
The Library's recipe at this page's colour: blurred cover, faded out
before the cards; z-index 0 lies OVER the in-flow header (that is the
approved look — the h1 carries a text-shadow for it), pointer-events
none so nothing under it goes dead. */
.ambience {
position: absolute;
left: -28px;
right: -28px;
top: calc(-1 * var(--pad-top));
height: 430px;
z-index: 0;
pointer-events: none;
background-size: cover;
background-position: center 30%;
filter: blur(90px) saturate(1.5);
opacity: 0.2;
transform: translateZ(0);
-webkit-mask-image: linear-gradient(180deg, #000 35%, transparent);
mask-image: linear-gradient(180deg, #000 35%, transparent);
}
.ambience-glow {
position: absolute;
left: -28px;
right: -28px;
top: calc(-1 * var(--pad-top));
height: 300px;
z-index: 0;
pointer-events: none;
background: radial-gradient(
640px 260px at 14% 0%,
color-mix(in srgb, var(--accent) 13%, transparent),
transparent 70%
);
}
/* ---------------------------------------------------------------- hero */
.hero {
padding-top: 4px;
}
.hero-top {
display: flex;
align-items: center;
gap: 16px;
}
h1 {
font-size: 31px;
font-weight: 800;
letter-spacing: -0.02em;
margin: 0;
text-shadow: 0 2px 18px rgba(0, 0, 0, 0.4);
}
.tools {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
/* The Library's tool button: translucent elevated glass, one height. */
.tool {
display: inline-flex;
align-items: center;
gap: 7px;
height: 32px;
padding: 0 12px;
border-radius: 8px;
background: color-mix(in srgb, var(--bg-elevated) 74%, transparent);
border: 1px solid var(--border);
color: var(--text-secondary);
font-size: 12px;
font-weight: 700;
cursor: pointer;
backdrop-filter: blur(6px);
transition: color 140ms ease, border-color 140ms ease, background 140ms ease;
}
.tool:hover:not(:disabled) {
color: var(--text-primary);
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
}
.tool:disabled {
opacity: 0.5;
cursor: default;
}
.tool.primary {
background: var(--accent);
border-color: transparent;
color: #08080a;
}
.tool.primary:hover:not(:disabled) {
background: var(--accent-hover);
color: #08080a;
}
/* The stat row — the Library's value/label idiom for the collection. */
.stats {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 16px;
margin: 15px 0 20px;
}
.stat {
display: inline-flex;
align-items: baseline;
gap: 7px;
white-space: nowrap;
}
.stat b {
font-size: 15.5px;
font-weight: 800;
letter-spacing: -0.01em;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.stat i {
font-style: normal;
font-size: 10.5px;
font-weight: 800;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--text-muted);
}
.stat-div {
width: 1px;
height: 17px;
flex: none;
background: linear-gradient(rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.03));
}
.menu-wrap {
position: relative;
}
.menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
min-width: 230px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 10px;
padding: 5px;
z-index: 40;
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.55);
}
.mi {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 9px 10px;
border-radius: 7px;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
text-align: left;
white-space: nowrap;
}
.mi:hover:not(:disabled) {
background: var(--bg-highlight);
color: var(--text-primary);
}
.mi:disabled {
opacity: 0.5;
}
.mi.danger:hover {
color: #f15e6c;
}
.mi .hint {
margin-left: auto;
font-size: 10.5px;
font-weight: 600;
color: var(--text-muted);
padding-left: 14px;
}
.sub-wrap {
position: relative;
}
.sub-arrow {
margin-left: auto;
display: inline-flex;
color: var(--text-tertiary);
}
/* Same glass card, berthed beside its row; the ::before bridge covers
the gap so the pointer can cross without closing it. Card menus sit
at the grid's right edge as often as not, so the flyout opens LEFT. */
.menu.sub {
position: absolute;
right: calc(100% + 4px);
left: auto;
top: -6px;
min-width: 130px;
}
.menu.sub::after {
content: "";
position: absolute;
right: -8px;
top: 0;
bottom: 0;
width: 8px;
}
.menu .rule {
height: 1px;
background: var(--border);
margin: 5px 8px;
}
.lab {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
margin: 4px 2px 12px;
}
.lab.all {
margin-top: 26px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
gap: 16px;
}
.card-wrap {
position: relative;
}
.card {
display: flex;
flex-direction: column;
text-align: left;
background: var(--bg-elevated);
border-radius: var(--radius);
padding: 13px;
width: 100%;
height: 100%;
transition: background 0.2s;
}
.card:hover {
background: var(--bg-highlight);
}
.artwrap {
position: relative;
display: block;
margin-bottom: 11px;
}
.art {
display: block;
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 6px;
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.35);
}
/* The Library card's own hover-play, verbatim in spirit: accent circle
riding the art's corner, revealed by the card hover. */
.hoverplay {
position: absolute;
right: 8px;
bottom: 8px;
z-index: 2;
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: var(--accent);
color: #000;
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.5);
opacity: 0;
transform: translateY(4px);
transition:
opacity 0.18s ease,
transform 0.18s ease,
background 0.18s ease;
}
.hoverplay:hover {
background: var(--accent-hover);
}
.card-wrap:hover .hoverplay,
.hoverplay:focus-visible {
opacity: 1;
transform: none;
}
.art.fallback {
display: grid;
place-items: center;
background: linear-gradient(135deg, var(--accent-soft), var(--bg-press) 72%);
color: var(--text-secondary);
}
.card-title {
font-weight: 600;
font-size: 14px;
line-height: 1.3;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.card-sub {
font-size: 12px;
color: var(--text-secondary);
margin-top: 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.badges {
display: flex;
gap: 5px;
margin-top: 7px;
flex-wrap: wrap;
}
.badge {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
padding: 2.5px 7px;
border-radius: 8px;
}
.badge.set {
background: var(--accent-soft);
color: var(--accent-hover);
}
.badge.un {
background: rgba(232, 163, 61, 0.15);
color: #e8a33d;
}
.pinned-mark {
position: absolute;
top: 20px;
left: 20px;
display: grid;
place-items: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
color: #fff;
backdrop-filter: blur(6px);
pointer-events: none;
}
.more {
position: absolute;
top: 20px;
right: 20px;
display: grid;
place-items: center;
width: 27px;
height: 27px;
border-radius: 50%;
color: #fff;
background: rgba(0, 0, 0, 0.45);
opacity: 0;
transition:
opacity 0.15s,
background 0.15s;
}
.card-wrap:hover .more,
.more:focus-visible {
opacity: 1;
}
.more:hover {
background: rgba(0, 0, 0, 0.7);
}
.card-menu {
top: 48px;
right: 14px;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 14vh;
text-align: center;
}
.empty-icon {
display: grid;
place-items: center;
width: 92px;
height: 92px;
border-radius: 24px;
background: var(--bg-elevated);
color: var(--text-muted);
margin-bottom: 18px;
}
.empty-title {
font-size: 17px;
font-weight: 700;
}
.empty-sub {
margin-top: 5px;
font-size: 13px;
color: var(--text-muted);
max-width: 380px;
}
/* ── dark glass ────────────────────────────────────────────────────────
Ladder in global.css. The shelf is nothing but cards, and every one was
an opaque #181818 tile on the frost.
Menus are deliberately NOT in here. A popover has to stay readable over
whatever it lands on, and the Settings copy for this mode now says so
out loud: cards, rows and highlights thin out; menus and dialogs stay
solid. The same rule holds for .menu in Playlist.svelte. */
:global(:root.glass) .card {
background: var(--glass-surface);
box-shadow: var(--glass-ring);
}
:global(:root.glass) .card:hover {
background: var(--glass-hover);
}
/* Same backdrop-over-frost flatten as the library's tools.
:not(.primary) is load-bearing, not tidiness: `:root.glass .tool` plus
Svelte's scope hash scores (0,4,0) against `.tool.primary`'s (0,3,0), so
without it this rule outranks the accent fill and "New playlist" turns
into a ghost button. Caught by measuring the live computed style —
rgba(255,255,255,0.03) where rgb(188,107,46) belonged. */
:global(:root.glass) .tool:not(.primary) {
background: var(--glass-surface);
border-color: var(--glass-hairline);
}
:global(:root.glass) .tool {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
:global(:root.glass) .empty-icon {
background: var(--glass-surface);
box-shadow: var(--glass-ring);
}
/* --accent-soft is already translucent and composites correctly; only the
--bg-press half of this gradient needed thinning. */
:global(:root.glass) .art.fallback {
background: linear-gradient(135deg, var(--accent-soft), rgba(255, 255, 255, 0.11) 72%);
}
</style>