Files
SoulStream/src/components/SaveMenu.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

178 lines
5.5 KiB
Svelte

<script>
import { player, saveStream } from "../lib/state.svelte.js";
import Icon from "./Icon.svelte";
// align hint: "down" (panel) or "up" (player bar). Auto-flips if needed.
let { align = "down" } = $props();
const currentItem = $derived(player.queue[player.queueIndex] ?? null);
const isStream = $derived(!!currentItem?.stream);
const streamCount = $derived(player.queue.filter((it) => it.stream).length);
const isAlbum = $derived(streamCount > 1);
let triggerEl = $state(null);
let open = $state(false);
let pos = $state({ top: 0, left: 0 });
let toast = $state("");
let toastPos = $state({ top: 0, left: 0 });
let toastTimer;
const MENU_W = 190;
function place() {
const r = triggerEl.getBoundingClientRect();
const h = 92; // two items
let left = Math.max(8, Math.min(r.right - MENU_W, window.innerWidth - MENU_W - 8));
let top = align === "up" ? r.top - 6 - h : r.bottom + 6;
if (align !== "up" && top + h > window.innerHeight - 8) top = r.top - 6 - h;
if (align === "up" && top < 8) top = r.bottom + 6;
pos = { top, left };
}
async function save(scope) {
open = false;
const res = await saveStream(scope);
if (!res.msg) return;
const r = triggerEl.getBoundingClientRect();
toastPos = {
top: align === "up" ? r.top - 34 : r.bottom + 6,
left: Math.max(8, r.right - 190),
};
toast = res.msg;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => (toast = ""), 2600);
}
function onClick(e) {
e.stopPropagation();
// Single track: one destination, save straight away. Album: choose scope.
if (!isAlbum) {
save("track");
return;
}
if (open) {
open = false;
return;
}
place();
open = true;
}
$effect(() => {
if (!open) return;
const onDoc = (e) => {
if (!e.target.closest(".save-wrap") && !e.target.closest(".save-pop")) open = false;
};
const close = () => (open = false);
document.addEventListener("click", onDoc, true);
document.addEventListener("scroll", close, true);
window.addEventListener("resize", close);
return () => {
document.removeEventListener("click", onDoc, true);
document.removeEventListener("scroll", close, true);
window.removeEventListener("resize", close);
};
});
</script>
{#if isStream}
<span class="save-wrap">
<button
bind:this={triggerEl}
class="save-icon"
class:on={open}
title="Save to library"
onclick={onClick}
>
<Icon name="download" size={18} />
</button>
</span>
{#if open}
<div class="save-pop menu" style="top:{pos.top}px; left:{pos.left}px;">
<button class="mi" onclick={() => save("track")}>
<Icon name="music" size={16} /> Save this track
</button>
<button class="mi" onclick={() => save("album")}>
<Icon name="library" size={16} /> Save whole album
</button>
</div>
{/if}
{#if toast}
<div class="save-pop toast" style="top:{toastPos.top}px; left:{toastPos.left}px;">
{toast}
</div>
{/if}
{/if}
<style>
.save-wrap { display: inline-flex; }
.save-icon {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 8px;
color: var(--text-secondary);
}
.save-icon:hover,
.save-icon.on {
color: var(--text-primary);
background: var(--bg-highlight);
}
/* Fixed positioning escapes the panel's overflow clipping and any
stacking context, so the popover always sits on top. */
.save-pop {
position: fixed;
z-index: 1000;
}
.menu {
width: 190px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 10px;
padding: 5px;
box-shadow: 0 16px 38px -12px rgba(0, 0, 0, 0.75);
}
.mi {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 9px 10px;
border-radius: 6px;
font-size: 13px;
color: var(--text-primary);
text-align: left;
}
.mi:hover { background: var(--bg-highlight); }
.mi :global(.icon) { color: var(--text-secondary); flex: 0 0 auto; }
.toast {
white-space: nowrap;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 8px;
padding: 7px 12px;
font-size: 12px;
color: var(--text-primary);
box-shadow: 0 12px 26px -10px rgba(0, 0, 0, 0.75);
}
/* ── dark glass ────────────────────────────────────────────────────────
Only the TRIGGER. .save-icon is a 34px icon button that lives on the
page — album actions, the Now Playing head — so it stands on pane frost
and its opaque --bg-highlight hover read as a slab there. The popover
and its toast below it stay solid on purpose: a menu has to be readable
over whatever it lands on (the Settings copy for this mode says so). */
:global(:root.glass) .save-icon:hover,
:global(:root.glass) .save-icon.on {
background: var(--glass-hover);
}
</style>