Files
SoulStream/src/components/TitleBar.svelte
T
KeelrLeek e4a2bb6075 the search panel detaches and frosts: 46px down, blur(40), and the rest of the acrylic recipe
Three rounds with Kyle's eyes on it, one rule each:

DETACH. The dropdown's top edge used to ride the exact line where the
content pane begins ("completely lined up with the main window") — and the
first fix, 38px, left the two edges within 4px, a near-miss that read
sloppier than either aligned or apart. 46px, tuned live over the album
page (38/42/46, OS captures), lands the panel ~9px below the pane line
with a 15px seam of bar under the pill.

FROST, not smoke. Plain rgba(0,0,0,0.72) ghosted the page's display-size
heading through razor-sharp; blur(18) blurred it but left it READABLE — a
blur only defeats text once its radius reaches the glyph scale, and page
headings here are ~40px tall. Now blur(40px) saturate(1.3) contrast(0.85)
over 0.55 smoke: the contrast compression is the other half of why real
acrylic is unreadable.

Why backdrop-filter is legal on THIS surface when the bars had to grow
periscopes: the filter black-flattens only the frost fraction of its
backdrop, and this panel is deliberately dark — where page content sits
behind it the content blurs into proper fog, and where bare frost shows
the flatten lands within a couple points of the plain-smoke look it
replaced. The bars failed because they wanted to SHOW the frost; this
panel wants to bury it.

NOISE + SHEEN. Over the hero's dark side the blur had nothing to re-emit
and the panel sat flat dead next to its lively half ("only half of it
seems fine"). Real acrylic never dies over black because blur is only half
its recipe — a 4.5% monochrome turbulence and a faint top sheen finish it,
so both halves read as one brushed material whatever is behind.

Interior chrome joins the glass vocabulary (hover rows, count pill,
chips, hairlines, SAVED tag, cover placeholders), and the old
keep-menus-opaque call is overridden for this menu alone —
Playlists/Playlist keep theirs.
2026-08-31 11:46:01 -05:00

1116 lines
39 KiB
Svelte

<script>
import {
app,
ui,
downloads,
catalog,
recentsStore,
catalogGo,
catalogSearchIdentityAlbums,
catalogTrackOwnership,
recentSearches,
searchNetworkFor,
openCatalogArtist,
openCatalogAlbum,
openRecentAlbum,
warmCatalogArtist,
cancelCatalogArtistWarmup,
streamSong,
streamCatalogAlbum,
getArt,
gridCover,
} from "../lib/state.svelte.js";
import { floatScroll } from "../lib/scrollbar.js";
import * as api from "../lib/api.js";
import Icon from "./Icon.svelte";
let maximized = $state(false);
let searchText = $state("");
// ---- download status ring (aggregate progress + active count) ----
const DL_ACTIVE = new Set(["connecting", "requested", "queued", "transferring"]);
const RING_C = 2 * Math.PI * 9; // circumference of the r=9 progress circle
// The ring aggregates over the whole download burst, not just the
// currently-active transfers — otherwise each finished file drops out
// of the fraction and the ring jumps backward instead of filling.
// A burst spans from the first active download (ids are monotonic, so
// members are `id >= session start`) until the queue drains.
let dlSession = $state({ min: null });
const dl = $derived.by(() => {
let active = 0;
let received = 0;
let total = 0;
for (const d of Object.values(downloads.items)) {
const isActive = DL_ACTIVE.has(d.state);
if (isActive) active += 1;
if (!d.total) continue;
if (isActive) {
received += d.received;
total += d.total;
} else if (
d.state === "complete" &&
dlSession.min != null &&
d.id >= dlSession.min
) {
received += d.total;
total += d.total;
}
}
return { active, frac: total > 0 ? Math.min(1, received / total) : 0 };
});
// When the queue drains, flash a full ring + check before hiding, then
// close out the burst.
let dlFlash = $state(false);
let dlHadActive = false;
$effect(() => {
if (dl.active > 0) {
dlHadActive = true;
dlFlash = false;
if (dlSession.min == null) {
let min = Infinity;
for (const d of Object.values(downloads.items)) {
if (DL_ACTIVE.has(d.state)) min = Math.min(min, d.id);
}
if (min < Infinity) dlSession.min = min;
}
} else if (dlHadActive) {
dlHadActive = false;
dlFlash = true;
const t = setTimeout(() => {
dlFlash = false;
dlSession.min = null;
}, 2500);
return () => clearTimeout(t);
}
});
// ---- live search suggestions ------------------------------------------
// The typeahead has three faces, all of them designed (see the prototype at
// research/search-redesign/PROTOTYPE-search.html):
//
// typing grouped Artists / Albums / Songs, each section under a
// hairline rule with its total; the keyboard selection
// carries a 3px accent edge so arrowing through it reads.
// focused, empty your recent searches and what you were last playing.
// no matches it does NOT vanish — a catalogue miss can still succeed
// on the Soulseek network, and it offers that.
//
// It is deliberately WIDER than the field (left/right: -70px, ~500px): at
// the field's own width real record titles truncated in every group.
let sugg = $state(null); // last catalogSearch results for the dropdown
let suggOpen = $state(false);
let focused = $state(false);
// The keyboard selection is held as the row's KEY, not its index. The
// MusicBrainz supplement lands ~600ms after the rest and re-orders the album
// group; an index would then point at a different record than the one the
// accent edge is drawn on, and Enter would open that one. `null` = the
// footer, which is what Enter does by default.
let hiKey = $state(null);
let suggSeq = 0;
let debounceTimer = null;
let mbTimer = null;
let mbExtras = $state([]);
let owned = $state({});
// Keyed by artist AND query: the extras are deduped against the album list
// they were computed for, and a different query has a different one. The
// MusicBrainz traffic behind this is cached backend-side per artist, so a
// repeat costs nothing.
const mbCache = new Map();
const MB_CACHE_MAX = 32;
const recentQueries = $derived(recentSearches(catalog.query, 4));
const jumpBackIn = $derived(recentsStore.albums.slice(0, 3));
let recentArt = $state({});
$effect(() => {
for (const a of jumpBackIn) {
const p = a.art_path;
if (!p || recentArt[p] !== undefined) continue;
recentArt[p] = null;
getArt(p)
.then((u) => (recentArt[p] = u))
.catch(() => {});
}
});
const recentCover = (a) => a.art_url ?? (a.art_path ? recentArt[a.art_path] : null);
/** Records only MusicBrainz carries are merged into Albums with no badge and
* no section of their own, exactly as on the results page — and the merged
* list is sorted newest-first, or a four-row slice would never reach them. */
const suggAlbums = $derived(
[...(sugg?.albums ?? []), ...mbExtras].sort((a, b) =>
(b.release_date ?? "").localeCompare(a.release_date ?? ""),
),
);
const suggGroups = $derived.by(() => {
if (!sugg) return [];
const groups = [];
if (sugg.artists.length) {
groups.push({
label: "Artists",
count: sugg.artists.length,
rows: sugg.artists.slice(0, 2).map((a) => ({
kind: "artist",
key: `artist:${a.id}`,
label: a.name,
sub: a.fans ? `Artist · ${fmtFans(a.fans)} fans` : "Artist",
img: a.picture,
round: true,
data: a,
})),
});
}
if (suggAlbums.length) {
groups.push({
label: "Albums",
count: suggAlbums.length,
rows: suggAlbums.slice(0, 4).map((al) => ({
kind: "album",
key: `album:${al.id}`,
label: al.title,
sub: [al.release_date?.slice(0, 4), al.artist].filter(Boolean).join(" · "),
img: al.cover,
round: false,
data: al,
})),
});
}
if (sugg.tracks.length) {
groups.push({
label: "Songs",
count: sugg.tracks.length,
rows: sugg.tracks.slice(0, 3).map((t) => ({
kind: "track",
key: `track:${t.id}`,
label: t.title,
sub: `${t.artist} · ${t.album}`,
dur: fmtDuration(t.duration_secs),
img: t.cover,
round: false,
data: t,
})),
});
}
return groups;
});
/** Flat, in visual order — what the arrow keys walk. */
const suggRows = $derived(suggGroups.flatMap((g) => g.rows));
/** Which of the three panels is on screen. */
const suggMode = $derived.by(() => {
if (!suggOpen) return null;
if (!searchText.trim()) {
return recentQueries.length || jumpBackIn.length ? "empty" : null;
}
if (!sugg) return null;
return suggRows.length ? "list" : "none";
});
const fmtFans = (n) => {
if (n == null) return null;
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`;
if (n >= 1_000) return `${Math.round(n / 1_000)}K`;
return String(n);
};
const fmtDuration = (s) =>
s == null ? "" : `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
function onSearchInput() {
clearTimeout(debounceTimer);
clearTimeout(mbTimer);
cancelCatalogArtistWarmup();
const q = searchText.trim();
if (!q) {
// An emptied field falls back to the recent panel rather than
// closing: the reader is still in the field, still searching.
suggSeq += 1;
sugg = null;
mbExtras = [];
hiKey = null;
suggOpen = focused;
return;
}
if (q.length < 2) {
suggOpen = false;
sugg = null;
mbExtras = [];
return;
}
debounceTimer = setTimeout(async () => {
const seq = ++suggSeq;
try {
const res = await api.catalogSearch(q);
// Ignore stale responses and results for superseded queries.
if (seq !== suggSeq || searchText.trim() !== q) return;
sugg = res;
mbExtras = [];
hiKey = null;
suggOpen = true;
// The first artist is overwhelmingly the next click. Warm its
// one-round-trip overview and art resolver while the user is
// still reading the dropdown; keyed caches share the work.
if (res.artists?.[0]) warmCatalogArtist(res.artists[0]);
void markOwned(res.tracks, seq);
scheduleMbSupplement(res, q, seq);
} catch {
// backend unavailable — no suggestions
}
}, 250);
}
async function markOwned(tracks, seq) {
try {
const map = await catalogTrackOwnership(tracks);
if (seq === suggSeq) owned = map;
} catch {
owned = {};
}
}
/** The MusicBrainz supplement is NOT fetched per keystroke. It waits for the
* same 600ms of the artist staying the top hit that the artist warm-up
* waits for, so live-search churn can never pace-probe MusicBrainz. */
function scheduleMbSupplement(res, query, seq) {
const seed = res?.artists?.[0];
if (seed?.id == null) return;
const key = `${seed.id}|${query}`;
mbTimer = setTimeout(() => {
if (seq !== suggSeq) return;
if (!mbCache.has(key)) {
mbCache.set(
key,
catalogSearchIdentityAlbums(seed, res.albums).catch(() => []),
);
// A long typing session would otherwise grow this without end.
while (mbCache.size > MB_CACHE_MAX) {
mbCache.delete(mbCache.keys().next().value);
}
}
mbCache.get(key).then((extras) => {
if (seq === suggSeq && extras.length) mbExtras = extras;
});
}, 600);
}
function closeSuggest() {
suggOpen = false;
hiKey = null;
}
function pickRow(row) {
closeSuggest();
cancelCatalogArtistWarmup();
if (row.kind === "artist") openCatalogArtist(row.data.id, row.data);
else if (row.kind === "album") {
// Clicking a row GOES there; the play button on the row plays it.
const al = row.data;
openCatalogAlbum(al.id, {
seed: { id: al.id, title: al.title, artist: al.artist, cover: al.cover ?? null },
});
} else playTrack(row.data);
}
function playTrack(t) {
streamSong(t.artist, t.title, {
cover: t.cover,
trackId: t.id,
albumId: t.album_id,
album: t.album || null,
expectSecs: t.duration_secs ?? null,
});
}
function playRow(row) {
closeSuggest();
cancelCatalogArtistWarmup();
if (row.kind === "album") streamCatalogAlbum(row.data);
else if (row.kind === "track") playTrack(row.data);
}
function pickQuery(q) {
closeSuggest();
searchText = q;
catalogGo(q);
}
function searchKeydown(e) {
if (!suggOpen || suggRows.length === 0) {
if (e.key === "Escape") closeSuggest();
return;
}
// A row that has since left the list reads as "no selection", so the
// arrows resume from the top rather than from a place that is gone.
const at = suggRows.findIndex((r) => r.key === hiKey);
if (e.key === "ArrowDown") {
e.preventDefault();
hiKey = suggRows[(at + 1) % suggRows.length].key;
} else if (e.key === "ArrowUp") {
e.preventDefault();
hiKey = suggRows[at <= 0 ? suggRows.length - 1 : at - 1].key;
} else if (e.key === "Enter" && at >= 0) {
e.preventDefault();
pickRow(suggRows[at]);
} else if (e.key === "Escape") {
closeSuggest();
}
}
function searchFocusOut(e) {
// Keep the panel open while focus moves within the search form.
if (!e.currentTarget.contains(e.relatedTarget)) {
focused = false;
closeSuggest();
}
}
function searchFocus() {
focused = true;
// Returning to the field re-opens what it last had to say.
if (!searchText.trim()) suggOpen = recentQueries.length > 0 || jumpBackIn.length > 0;
else if (sugg && searchText.trim().length >= 2) suggOpen = true;
}
function submitSearch(e) {
e?.preventDefault();
clearTimeout(debounceTimer);
clearTimeout(mbTimer);
suggSeq += 1; // cancel any in-flight suggestion fetch
closeSuggest();
catalogGo(searchText);
}
function networkSearch() {
closeSuggest();
searchNetworkFor(searchText);
}
async function syncMaximized() {
try {
maximized = await api.win.isMaximized();
} catch {
// not running inside Tauri (browser preview)
}
}
// onResized fires on EVERY frame of a window drag, and answering each one
// with isMaximized() is an IPC round trip to the Rust side per frame — on
// the main thread, while the compositor is already busy re-rasterizing every
// backdrop-filter in the app at the new size. Maximized state cannot change
// part-way through a drag anyway: it only changes at the end of one, or on a
// discrete button press. So answer once, after the box settles.
let resizeSettle = null;
function syncMaximizedSoon() {
clearTimeout(resizeSettle);
resizeSettle = setTimeout(syncMaximized, 150);
}
$effect(() => {
let unlisten = null;
syncMaximized();
api.win
.onResized(syncMaximizedSoon)
.then((f) => (unlisten = f))
.catch(() => {});
return () => {
clearTimeout(resizeSettle);
unlisten?.();
};
});
</script>
<header class="titlebar" data-tauri-drag-region>
<div class="brand">
<img class="logo" src="/logo.png" alt="" draggable="false" />
<span class="name">SoulStream</span>
</div>
<!-- The Soulseek status lives in the sidebar's glass bar now: it is an
account presence, and it belongs next to the library it feeds. -->
<!-- The dropdown is a CHILD of the field, not of the titlebar: it is
positioned against the field's own box (left/right: -70px). Parented to
the bar it rendered 1223px wide. -->
<form class="bar-search" onsubmit={submitSearch} onfocusout={searchFocusOut}>
<Icon name="search" size={13} />
<input
type="text"
placeholder="Search artists, albums, songs…"
bind:value={searchText}
oninput={onSearchInput}
onkeydown={searchKeydown}
onfocus={searchFocus}
spellcheck="false"
/>
{#if focused}
<span class="kb" aria-hidden="true">↵</span>
{/if}
{#if suggMode}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="suggest" use:floatScroll onmousedown={(e) => e.preventDefault()}>
{#if suggMode === "empty"}
{#if recentQueries.length}
<div class="sg-h">Recent<span class="line"></span></div>
<div class="chiprow">
{#each recentQueries as q (q)}
<button type="button" class="rc" onclick={() => pickQuery(q)}>
<Icon name="search" size={11} />{q}
</button>
{/each}
</div>
{/if}
{#if jumpBackIn.length}
<div class="sg-h">Jump back in<span class="line"></span></div>
{#each jumpBackIn as a (a.kind === "catalog" ? `c:${a.catalog_album_id}` : `l:${a.album_id}`)}
<button
type="button"
class="sg"
onclick={() => {
closeSuggest();
openRecentAlbum(a);
}}
>
{#if recentCover(a)}
<img src={recentCover(a)} alt="" draggable="false" />
{:else}
<span class="sg-img placeholder"><Icon name="disc" size={13} /></span>
{/if}
<span class="sg-text">
<span class="t">{a.title}</span>
<span class="s">{a.artist}</span>
</span>
</button>
{/each}
{/if}
{:else if suggMode === "none"}
<!-- Never a vanishing panel: the network is still an answer. -->
<div class="nomatch">
<div class="nomatch-h">No quick matches</div>
<div class="nomatch-s">
Nothing in the catalogue starts with “{searchText.trim()}”.
</div>
</div>
<button type="button" class="sg-ft" onclick={networkSearch}>
<Icon name="globe" size={13} strokeWidth={1.8} />
<span>Search the network for files</span>
<span class="kb">↵</span>
</button>
{:else}
{#each suggGroups as group (group.label)}
<div class="sg-h">
{group.label}<span class="c">{group.count}</span><span class="line"
></span>
</div>
{#each group.rows as row (row.key)}
<button
type="button"
class="sg"
class:on={row.key === hiKey}
onmouseenter={() => (hiKey = row.key)}
onclick={() => pickRow(row)}
>
{#if row.img}
<img
class:round={row.round}
src={gridCover(row.img)}
alt=""
draggable="false"
/>
{:else}
<span class="sg-img placeholder" class:round={row.round}>
<Icon name="music" size={13} />
</span>
{/if}
<span class="sg-text">
<span class="t">
{row.label}
{#if row.kind === "track" && owned[row.data.id]}
<span class="own">SAVED</span>
{/if}
</span>
<span class="s">{row.sub}</span>
</span>
{#if row.dur}
<span class="d">{row.dur}</span>
{/if}
{#if row.kind !== "artist"}
<!-- Hover swaps the duration for this. -->
<span
class="play"
role="button"
tabindex="-1"
aria-hidden="true"
onclick={(e) => {
e.stopPropagation();
playRow(row);
}}
onkeydown={() => {}}
>
<Icon name="play" size={11} />
</span>
{/if}
</button>
{/each}
{/each}
<button
type="button"
class="sg-ft"
class:on={hiKey === null}
onclick={() => submitSearch()}
>
<Icon name="search" size={13} />
<span>See all results for “{searchText.trim()}”</span>
<span class="kb">↵</span>
</button>
{/if}
</div>
{/if}
</form>
<div class="controls">
{#if dl.active > 0 || dlFlash}
<button
class="dl-ring"
title={dlFlash
? "Downloads finished"
: `${dl.active} downloading — open Downloads`}
onclick={() => (ui.view = "downloads")}
>
<svg width="26" height="26" viewBox="0 0 26 26">
<circle class="track" cx="13" cy="13" r="9" />
<circle
class="prog"
cx="13"
cy="13"
r="9"
stroke-dasharray={RING_C}
stroke-dashoffset={dlFlash ? 0 : RING_C * (1 - dl.frac)}
transform="rotate(-90 13 13)"
/>
{#if dlFlash}
<path class="tick" d="M9 13.4l2.7 2.7 5.2-5.6" />
{:else}
<text x="13" y="16.5" text-anchor="middle">
{dl.active > 9 ? "9+" : dl.active}
</text>
{/if}
</svg>
</button>
{/if}
<button class="win-btn" title="Minimize" onclick={() => api.win.minimize()}>
<Icon name="minus" size={16} />
</button>
<button
class="win-btn"
title={maximized ? "Restore" : "Maximize"}
onclick={() => api.win.toggleMaximize().then(syncMaximized)}
>
<Icon name={maximized ? "restore" : "square"} size={13} />
</button>
<button class="win-btn close" title="Close" onclick={() => api.win.close()}>
<Icon name="x" size={16} />
</button>
</div>
</header>
<style>
/* Lines the brand up with the sidebar's content column below: the bar
already sits inside the 8px window frame, so 20px more reaches the
8px sidebar padding + 12px item padding. */
.titlebar {
grid-area: titlebar;
position: relative;
/* Above the sibling content panels, so the search suggestions
dropdown paints over album grids instead of interleaving. */
z-index: 100;
display: flex;
align-items: center;
gap: 10px;
padding-left: 20px;
user-select: none;
-webkit-user-select: none;
}
/* The visible strip above the panels is the 30px bar row plus the 8px
grid gap; nudge the left cluster down so it centers optically in that
full strip. The window controls stay flush with the top edge. */
.brand {
position: relative;
top: 4px;
}
/* Decorative: let mouse events fall through to the drag region. */
.brand {
display: flex;
align-items: center;
gap: 10px;
pointer-events: none;
}
.logo {
width: 20px;
height: 20px;
border-radius: 5px;
}
.name {
font-weight: 700;
font-size: 13px;
}
/* Centered on the window regardless of the left cluster's width; the
same +4px optical nudge as the brand. Dragging still works on the
uncovered bar either side. */
.bar-search {
position: absolute;
left: 50%;
top: 7px;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 8px;
width: min(360px, 30vw);
height: 24px;
padding: 0 12px;
border-radius: 12px;
background: var(--bg-elevated);
color: var(--text-muted);
}
/* Dark glass: the pill goes translucent so the acrylic reads through it
too — its alpha COMPOSITES over the .app canvas tint (0.55), so 0.35
here nets ~0.71 black where the pill sits: darker than the canvas
around it, still glass. Solid mode above stays untouched. */
:global(:root.glass) .bar-search {
background: rgba(0, 0, 0, 0.35);
}
/* The ONE change to the field: the focus ring was `--text-muted`, which
read as a dead grey border rather than as the field being live. It is the
accent now, and a ↵ hint appears beside the caret to say what Enter does.
Every other measurement above is untouched. */
.bar-search {
outline: 1px solid transparent;
transition:
outline-color 0.18s,
color 0.18s;
}
.bar-search:focus-within {
outline: 1px solid var(--accent);
color: var(--text-secondary);
}
.bar-search input {
flex: 1;
min-width: 0;
background: none;
border: none;
outline: none;
color: var(--text-primary);
font-size: 12px;
}
.bar-search input::placeholder {
color: var(--text-muted);
}
.kb {
font-size: 9px;
font-weight: 800;
padding: 1px 5px;
border-radius: 3px;
background: var(--bg-highlight);
color: var(--text-muted);
flex: none;
}
/* ---- live suggestions dropdown ---- */
/* Wider than the field on both sides: at the field's own 360px, real record
titles ("Superman (Garage Practice) / The BBC Tracks / …") truncated in
every group. This is the one place the old -8px rule is deliberately
broken. Capped in height because the full grouped list is taller than the
window; the overflow is the app's own floating overlay bar
(`use:floatScroll`), which is invisible at rest. */
.suggest {
position: absolute;
/* Detached from the pill on purpose (Kyle, 2026-08-31): the pill's
box ends at 31px, so this leaves a 15px seam of bar showing between
field and panel instead of the two reading as one welded block —
and, the actual complaint, the panel's top edge lands ~9px BELOW
the content pane's top line instead of riding the same rule (38px
put the two edges within 4px and read as accidental alignment;
tuned live over the album page, 38/42/46, OS shots). */
top: 46px;
left: -70px;
right: -70px;
display: flex;
flex-direction: column;
padding: 6px;
border-radius: 10px;
background: var(--bg-elevated);
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.55);
z-index: 50;
max-height: 420px;
overflow-y: auto;
animation: ddin 0.17s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes ddin {
from {
opacity: 0;
transform: translateY(-6px) scale(0.985);
}
to {
opacity: 1;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.suggest {
animation: none;
}
}
/* A section header: name, its total, and a hairline running to the edge. */
.sg-h {
display: flex;
align-items: center;
gap: 7px;
font-size: 9px;
font-weight: 900;
letter-spacing: 0.13em;
color: var(--text-muted);
text-transform: uppercase;
padding: 7px 8px 4px;
}
.sg-h .c {
background: var(--bg-highlight);
border-radius: 99px;
padding: 1px 6px;
font-size: 8.5px;
letter-spacing: 0.04em;
}
.sg-h .line {
flex: 1;
height: 1px;
background: var(--border);
}
.sg {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 5px 8px;
border-radius: 6px;
position: relative;
text-align: left;
color: var(--text-primary);
}
.sg:hover,
.sg.on {
background: var(--bg-highlight);
}
/* The keyboard selection carries an accent edge, so arrowing down the list
is legible as a *selection* rather than as another hover. */
.sg.on::before {
content: "";
position: absolute;
left: 0;
top: 6px;
bottom: 6px;
width: 3px;
border-radius: 2px;
background: var(--accent);
}
.sg img,
.sg-img {
width: 32px;
height: 32px;
border-radius: 4px;
object-fit: cover;
flex: none;
background: var(--bg-highlight);
}
.sg img.round,
.sg-img.round {
border-radius: 50%;
}
.sg-img.placeholder {
display: grid;
place-items: center;
color: var(--text-muted);
}
.sg-text {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.sg .t {
font-size: 12.5px;
font-weight: 700;
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sg .s {
font-size: 10.5px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sg .d {
font-size: 10.5px;
color: var(--text-muted);
margin-left: auto;
flex: none;
}
/* Hover swaps the duration for the thing you actually want at that moment. */
.sg .play {
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--accent);
color: #000;
display: none;
place-items: center;
flex: none;
margin-left: auto;
cursor: pointer;
}
.sg:hover .play,
.sg.on .play {
display: grid;
}
.sg:hover .d,
.sg.on .d {
display: none;
}
.own {
font-size: 8px;
font-weight: 900;
letter-spacing: 0.06em;
padding: 1px 5px;
border-radius: 3px;
background: var(--bg-press);
color: var(--text-secondary);
flex: none;
}
.sg-ft {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 7px 8px;
margin-top: 4px;
border-top: 1px solid var(--border);
font-size: 11.5px;
font-weight: 800;
color: var(--text-secondary);
}
.sg-ft:hover,
.sg-ft.on {
color: #fff;
}
.sg-ft .kb {
margin-left: auto;
}
.chiprow {
display: flex;
flex-wrap: wrap;
padding: 2px 6px 6px;
}
.rc {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 9px;
border-radius: 99px;
background: var(--bg-highlight);
font-size: 11px;
font-weight: 700;
margin: 2px;
}
.rc:hover {
background: var(--bg-press);
}
.nomatch {
padding: 18px 10px;
text-align: center;
}
.nomatch-h {
font-size: 12px;
font-weight: 800;
}
.nomatch-s {
font-size: 11px;
color: var(--text-muted);
margin-top: 3px;
}
.controls {
margin-left: auto;
display: flex;
align-self: stretch;
}
/* download status ring */
.dl-ring {
align-self: center;
display: grid;
place-items: center;
width: 34px;
height: 30px;
margin-right: 4px;
border-radius: 7px;
}
.dl-ring:hover {
background: var(--bg-highlight);
}
.dl-ring .track {
fill: none;
stroke: rgba(255, 255, 255, 0.16);
stroke-width: 2.5;
}
.dl-ring .prog {
fill: none;
stroke: var(--accent-hover);
stroke-width: 2.5;
stroke-linecap: round;
transition: stroke-dashoffset 0.4s ease;
}
.dl-ring text {
fill: var(--text-primary);
font-size: 10px;
font-weight: 700;
}
.dl-ring .tick {
fill: none;
stroke: var(--accent-hover);
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.win-btn {
width: 46px;
display: grid;
place-items: center;
color: var(--text-secondary);
border-radius: 0;
}
.win-btn:hover {
background: var(--bg-highlight);
color: var(--text-primary);
}
.win-btn.close:hover {
background: #c42b1c;
color: #fff;
}
/* ── dark glass ────────────────────────────────────────────────────────
The window buttons are the one surface in this file that stands on the
CANVAS rather than on a pane, and the math is different there: the
canvas is .app's 0.55, not a pane's 0.7075, so the ground behind them
is lighter and a hover needs a stronger veil to make the same step.
0.12 is the value GlassBar's own .arrow:hover already uses for chrome
in this position. --glass-hover (0.08) is tuned for the 0.7075 pane and
would read as almost nothing up here. */
:global(:root.glass) .win-btn:hover,
:global(:root.glass) .dl-ring:hover {
background: rgba(255, 255, 255, 0.12);
}
/* The ⏎ keycap inside the search pill; the pill is already glass. */
:global(:root.glass) .kb {
background: var(--glass-hover);
}
/* The suggestions dropdown under glass (Kyle, 2026-08-31 — overriding
the earlier keep-menus-opaque call for THIS menu; Playlists/Playlist
keep theirs): dark FROSTED glass — real backdrop-filter plus a lighter
smoke. Plain smoke alone shipped first and Kyle bounced it ("full
transparent, not acrylic/blurred"): the page heading ghosted through
razor-sharp.
Why this surface may use backdrop-filter when the bars could not (the
black-flatten trap, see NowPlaying's head-periscope note): the filter
black-flattens only the FROST fraction of its backdrop, and this panel
is deliberately dark. Where page content sits behind it — the usual
case at the top of a page — the content blurs properly into fog;
where bare frost shows, the flatten just lands within a couple points
of the plain-smoke look this replaces (~0.92 black net), i.e. the
failure mode IS the previous design, not a visible slab. The bars
failed because they wanted to SHOW the frost; this panel wants to
bury it. */
/* blur(40): the first cut shipped blur(18) and Kyle could still READ the
album page's display-size heading through it — a blur only defeats
text once its radius reaches the glyph scale, and page headings here
are ~40px tall. contrast(0.85) compresses what survives (real acrylic
flattens contrast too; that is half of why you cannot read through
it). Tuned live over the artist page, 40/0.85/0.55 vs 56/0.78/0.6 —
the heavier mix read deader, not frostier.
The NOISE + SHEEN layers are the rest of the Windows acrylic recipe,
and they are what keeps the panel from reading as two materials
(Kyle, 2026-08-31: "only half of it seems fine"). The filter can only
re-emit light the page gives it: over the hero's bright side the fog
glows, over its dark side blur has nothing to show and the panel sat
flat dead — and on unpainted pages the frost fraction black-flattens
to the same flatness. Real acrylic never goes dead over black because
it composites a noise texture and tint over the blur; the fine
monochrome turbulence (4.5%) and the faint top sheen do that here, so
both halves read as one brushed material whatever is behind. */
:global(:root.glass) .suggest {
background-color: rgba(0, 0, 0, 0.55);
background-image:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)' opacity='0.045'/%3E%3C/svg%3E"),
linear-gradient(
180deg,
rgba(255, 255, 255, 0.055),
rgba(255, 255, 255, 0.015) 34%,
rgba(255, 255, 255, 0) 62%
);
backdrop-filter: blur(40px) saturate(1.3) contrast(0.85);
-webkit-backdrop-filter: blur(40px) saturate(1.3) contrast(0.85);
box-shadow:
var(--glass-ring),
0 10px 32px rgba(0, 0, 0, 0.55);
}
/* Interior chrome joins the white-veil glass vocabulary; the solid
tokens it uses otherwise are opaque and would float as mini-slabs on
the smoked sheet. */
:global(:root.glass) .sg:hover,
:global(:root.glass) .sg.on,
:global(:root.glass) .sg-h .c,
:global(:root.glass) .sg img,
:global(:root.glass) .sg-img,
:global(:root.glass) .rc {
background: var(--glass-hover);
}
:global(:root.glass) .rc:hover,
:global(:root.glass) .own {
background: var(--glass-press);
}
:global(:root.glass) .sg-h .line {
background: var(--glass-hairline);
}
:global(:root.glass) .sg-ft {
border-top-color: var(--glass-hairline);
}
</style>