forked from Keelrleek/SoulStream
dark glass, the whole app: frost on every pane, and the periscope pattern for every glass bar the frost sits under - backdrop-filter black-flattens over see-through ground (measured 3-vs-40), so bars over frost blur a scroll-synced clone behind a static band cut instead; the bar moved out of the scroller (pane shell, inner .content keeping the closest-contract, the old negative-margin flow replayed by a spacer after the artist page's sticky physics named the scrollport padding sacred); painted pages declare data-painted and keep the real backdrop bar, which is how the artist hero runs under the glass again; np wash rides the scroll away and dissolves to frost like the album banner; perf measured to vsync - 142px blur wraps (27.9ms frames down to 6.9), idle scroll-gated reclones, clip-path band, content-visibility on library rows, async cover decode; mini frosts over the mini:glass channel and the rest-frame flags ride window config because the env-var lane dies on some webview2 runtimes
This commit is contained in:
1 parent
225c9139aa
commit
6ba1864498
16 files changed
+821
-117
No files matched your search
+1
-1
@@ -7,7 +7,7 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"tauri": "tauri",
|
||||
"test": "node --test tests/preview/"
|
||||
"test": "node --test \"tests/preview/*.test.mjs\""
|
||||
},
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
|
||||
+29
-48
@@ -27,39 +27,6 @@ mod ytdlp;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
// Two WebView2 presentation fixes, both delivered by env var because
|
||||
// tauri.conf's additionalBrowserArgs never reaches the browser process
|
||||
// (verified via its command line). Merge, don't clobber — dev launches
|
||||
// carry the CDP port here. Both found 2026-08-26 via the dark-glass work:
|
||||
//
|
||||
// 1. NO delegated compositing: with it, DWM composes the webview's layers
|
||||
// at rest and blends semi-transparent darks in the wrong colorspace —
|
||||
// the app rested washed-out LIGHT and looked correct only while an
|
||||
// animation forced Chromium to compose in-process.
|
||||
// 2. Force the sRGB color profile: on an HDR display the MAXIMIZED
|
||||
// window's rest path takes a different SDR-white tone mapping than
|
||||
// the animated path — covers blew out near-white at rest and snapped
|
||||
// correct on hover. One profile, one mapping, both paths agree.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
const KEY: &str = "WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS";
|
||||
let mut args = std::env::var(KEY).unwrap_or_default();
|
||||
let mut ensure = |flag: &str, key: &str| {
|
||||
if !args.contains(key) {
|
||||
if !args.is_empty() {
|
||||
args.push(' ');
|
||||
}
|
||||
args.push_str(flag);
|
||||
}
|
||||
};
|
||||
ensure(
|
||||
"--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection,DelegatedCompositing",
|
||||
"DelegatedCompositing",
|
||||
);
|
||||
ensure("--force-color-profile=srgb", "force-color-profile");
|
||||
std::env::set_var(KEY, args);
|
||||
}
|
||||
|
||||
tauri::Builder::default()
|
||||
.manage(state::AppState::default())
|
||||
.setup(|app| {
|
||||
@@ -256,23 +223,37 @@ pub fn run() {
|
||||
vocal_sep::vocal_sep_download,
|
||||
])
|
||||
.run({
|
||||
#[allow(unused_mut)]
|
||||
let mut context = tauri::generate_context!();
|
||||
// Debug builds expose CDP on 9222 for instrumentation (see HANDOFF.md).
|
||||
// wry always sets AdditionalBrowserArguments programmatically, so the
|
||||
// WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS env var is ignored; the flag has
|
||||
// to go through the window config. Explicit args replace wry's defaults,
|
||||
// so those are restated here.
|
||||
// Browser args ride the WINDOW CONFIG, nothing else: wry always sets
|
||||
// AdditionalBrowserArguments programmatically, and at least WebView2
|
||||
// 151.x then drops the WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS env var
|
||||
// entirely — flags "delivered" that way vanish silently on some
|
||||
// runtimes (they held on the home machine, gone on the work one).
|
||||
// Verify in the browser process command line (Get-CimInstance
|
||||
// Win32_Process), never by assumption. Explicit args replace wry's
|
||||
// defaults, so those are restated. Two WebView2 presentation fixes
|
||||
// live here (2026-08-26, the dark-glass work):
|
||||
//
|
||||
// 1. NO delegated compositing: with it, DWM composes the webview's
|
||||
// layers at rest and blends semi-transparent darks in the wrong
|
||||
// colorspace — the app rested washed-out LIGHT and looked correct
|
||||
// only while an animation forced Chromium to compose in-process.
|
||||
// 2. Force the sRGB color profile: on an HDR display the MAXIMIZED
|
||||
// window's rest path takes a different SDR-white tone mapping
|
||||
// than the animated path — covers blew out near-white at rest
|
||||
// and snapped correct on hover. One profile, one mapping, both
|
||||
// paths agree.
|
||||
//
|
||||
// Debug builds add CDP on 9222 for instrumentation (see HANDOFF.md).
|
||||
let base = "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection,DelegatedCompositing \
|
||||
--autoplay-policy=no-user-gesture-required \
|
||||
--force-color-profile=srgb";
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
for window in &mut context.config_mut().app.windows {
|
||||
window.additional_browser_args = Some(
|
||||
"--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection \
|
||||
--autoplay-policy=no-user-gesture-required \
|
||||
--remote-debugging-port=9222"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let args = format!("{base} --remote-debugging-port=9222");
|
||||
#[cfg(not(debug_assertions))]
|
||||
let args = base.to_string();
|
||||
for window in &mut context.config_mut().app.windows {
|
||||
window.additional_browser_args = Some(args.clone());
|
||||
}
|
||||
context
|
||||
})
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"minimizable": false,
|
||||
"alwaysOnTop": true,
|
||||
"shadow": true,
|
||||
"backgroundColor": "#181818"
|
||||
"transparent": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
+76
-20
@@ -137,14 +137,18 @@
|
||||
scrim intercepts pointer input and the dialog traps the keyboard.
|
||||
The titlebar stays live for window chrome. -->
|
||||
<Sidebar />
|
||||
<main class="content" use:floatScroll inert={previewUi.open || undefined}>
|
||||
<!-- First child of the scroll pane: the bar's sticky pin and its
|
||||
negative bleed margins are measured against THIS element's
|
||||
padding. It consumes no flow height of its own AND cancels the
|
||||
pane's top padding, so the page after it begins flush with the top
|
||||
of the pane and its artwork runs under the bar. -->
|
||||
<!-- The pane is a SHELL with the bar floating absolute over an inner
|
||||
scroller — the same bar-over-scroller relationship the sidebar's
|
||||
gbar and the np head have, which is what lets the dark-glass band
|
||||
mask sit statically on the scroller (a bar INSIDE the scroller
|
||||
would be masked with it; the per-frame page-mask this replaces
|
||||
never tracked cleanly). The inner div KEEPS the .content class:
|
||||
every page resolves its scroll pane via closest(".content"). -->
|
||||
<main class="pane-shell" class:barless={!BAR_VIEWS.has(ui.view)} inert={previewUi.open || undefined}>
|
||||
<GlassBar hidden={!BAR_VIEWS.has(ui.view)} />
|
||||
<CurrentView />
|
||||
<div class="content" use:floatScroll>
|
||||
<CurrentView />
|
||||
</div>
|
||||
</main>
|
||||
<NowPlaying />
|
||||
<PlayerBar />
|
||||
@@ -224,7 +228,17 @@
|
||||
acrylic. The panes above it all paint their own opaque surfaces and
|
||||
must stay untouched. */
|
||||
:global(:root.glass) .app {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
/* Dark glass, the content pane (TEST, album page first — 2026-08-26):
|
||||
the pane joins the frost at the shared 0.35. The top-highlight
|
||||
gradient is a solid-mode grounding for hero seams; under glass the
|
||||
hero tails are thinned instead (see CatalogAlbum), so the frost is
|
||||
flat here. GlassBar drops its backdrop under glass for the same
|
||||
measured reason as every other bar (slab over frost). */
|
||||
:global(:root.glass) .content {
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
/* Collapsed left nav: narrow the sidebar column to an icon rail. The
|
||||
@@ -250,8 +264,30 @@
|
||||
--nowplaying-width: var(--nowplaying-rail);
|
||||
}
|
||||
|
||||
.content {
|
||||
/* The shell: grid cell, corner clip, and the two named lengths — on the
|
||||
SHELL so both the bar (its child now) and every page (descendants of
|
||||
the scroller) inherit them. Purely structural; all paint and scroll
|
||||
behavior stays on the inner .content below. */
|
||||
.pane-shell {
|
||||
grid-area: content;
|
||||
position: relative;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
/* The pane's top padding, named because three other rules are tied to
|
||||
it: the glass bar's height pairs with it and each page under the bar
|
||||
restores it as its own padding-top. */
|
||||
--pane-pad: 24px;
|
||||
/* The glass bar's height, declared HERE rather than on the bar so pages
|
||||
can clear it. A page with a hero (artist, album) wants its artwork to
|
||||
run underneath the bar and only takes --pane-pad back; a page with no
|
||||
artwork behind the bar (search) has to start below it, or its first
|
||||
heading sits under the frost at rest. */
|
||||
--bar-h: 46px;
|
||||
}
|
||||
|
||||
.content {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(var(--bg-highlight) 0, var(--bg-base) 240px);
|
||||
/* The highlight wash belongs to the top of the PAGE, not the viewport:
|
||||
anchored to the pane (default `scroll`), the gradient stays put
|
||||
@@ -261,22 +297,42 @@
|
||||
away with the content, keeping the seam colors identical at every
|
||||
scroll position. */
|
||||
background-attachment: local;
|
||||
/* The pane's top padding, named because three other rules are tied to
|
||||
it: the glass bar bleeds up over it, cancels it for what follows, and
|
||||
each page under the bar restores it as its own padding-top. */
|
||||
--pane-pad: 24px;
|
||||
/* The glass bar's height, declared HERE rather than on the bar so pages
|
||||
can clear it. A page with a hero (artist, album) wants its artwork to
|
||||
run underneath the bar and only takes --pane-pad back; a page with no
|
||||
artwork behind the bar (search) has to start below it, or its first
|
||||
heading sits under the frost at rest. */
|
||||
--bar-h: 46px;
|
||||
border-radius: var(--radius);
|
||||
overflow-y: auto;
|
||||
overflow-anchor: none;
|
||||
/* Main scroll pane: no elastic rubber-band at its edges. Inner regions
|
||||
deliberately do NOT set this, so their wheel chains here. */
|
||||
overscroll-behavior: none;
|
||||
/* The ORIGINAL padding, all views: the artist page's pinned/sticky
|
||||
physics are tuned against a scrollport with this exact top
|
||||
padding — zeroing it shifted every sticky offset 24px and the
|
||||
page collapsed into itself (2026-08-26). The bar's old job of
|
||||
cancelling this padding for what follows (so hero pages start
|
||||
flush at the pane top) is reproduced by the spacer below. */
|
||||
padding: var(--pane-pad) 28px;
|
||||
}
|
||||
/* The absolute bar contributes no flow, so this zero-height spacer
|
||||
replays its old net-flow trick: on bar views, pull everything after
|
||||
it up by exactly --pane-pad — the page's box starts flush at the
|
||||
pane top, and each hero page restores its own inset, precisely the
|
||||
pre-restructure contract. Barless views keep the classic inset. */
|
||||
:global(.pane-shell:not(.barless) > .content)::before {
|
||||
content: "";
|
||||
display: block;
|
||||
height: 0;
|
||||
margin-bottom: calc(-1 * var(--pane-pad));
|
||||
}
|
||||
|
||||
/* Dark glass: the band cut, the sidebar's exact move — static, in the
|
||||
scroller's own box coordinates, with the bar safely OUTSIDE as a
|
||||
sibling. clip-path, NOT mask-image: the cut is a hard edge, and a
|
||||
rectangle clip stays on the compositor's fast path where an alpha
|
||||
mask forced the 7k-node library through ~14ms raster frames
|
||||
(measured 2026-08-26). Skipped on barless views. */
|
||||
:global(:root.glass) .pane-shell:not(.barless) > .content {
|
||||
clip-path: inset(var(--bar-h) 0 0 0);
|
||||
}
|
||||
/* Painted pages (data-painted roots) keep their content running under
|
||||
the bar for the real backdrop to sample — GlassBar's periscope effect
|
||||
lifts this clip INLINE when it toggles .painted-page (an inline write
|
||||
outranks this rule without a specificity war). */
|
||||
</style>
|
||||
+213
-36
@@ -32,6 +32,161 @@
|
||||
/** `hidden` hides the bar without unmounting it — see the note above. */
|
||||
let { children, hidden = false } = $props();
|
||||
|
||||
// ── the periscope, dark glass only — third instance of the pattern,
|
||||
// now the sidebar's EXACT shape (Sidebar.svelte holds the measured
|
||||
// story): the bar floats OUTSIDE the scroller (App.svelte's pane
|
||||
// shell), the band mask sits statically on the scroller, and this
|
||||
// clone paints the band as fog. The clone drops the scroller's own
|
||||
// background (the frost must not double) and cancels its band mask;
|
||||
// the album banner — the one full-bleed layer — stretches sideways
|
||||
// into the overscan margins (content-edge falloff, the np wash story).
|
||||
let glassy = $state(document.documentElement.classList.contains("glass"));
|
||||
let barEl = $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 || !barEl || !periEl) return;
|
||||
const view = barEl.parentElement?.querySelector(":scope > .content");
|
||||
const peri = periEl;
|
||||
if (!view) return;
|
||||
let pending = 0;
|
||||
let lastScroll = 0;
|
||||
let lastNodes = 500;
|
||||
const sync = () => {
|
||||
lastScroll = performance.now();
|
||||
const c = peri.firstElementChild?.firstElementChild;
|
||||
if (c) c.style.transform = `translateY(${-view.scrollTop}px)`;
|
||||
};
|
||||
const reclone = () => {
|
||||
// Painted pages (data-painted on the page root — the artist page)
|
||||
// ground the bar themselves: real backdrop-filter, no cut, no
|
||||
// fog. The shell class drives the CSS exemptions in App.svelte
|
||||
// and below; re-evaluated on every page swap via the observer.
|
||||
const painted = !!view.querySelector(":scope > [data-painted]");
|
||||
barEl.parentElement?.classList.toggle("painted-page", painted);
|
||||
// The band cut is a stylesheet rule; the exemption is this
|
||||
// inline write (outranks it flatly — see App.svelte's note).
|
||||
view.style.clipPath = painted ? "none" : "";
|
||||
if (painted) {
|
||||
peri.replaceChildren();
|
||||
return;
|
||||
}
|
||||
const wrap = document.createElement("div");
|
||||
// height 142 = 48 overscan + 46 band + 48 overscan: the filter
|
||||
// rasterizes THIS box, not the full cloned page — blurring
|
||||
// thousands of px to show 46 was the single biggest frame cost.
|
||||
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.style.background = "none";
|
||||
c.style.maskImage = "none";
|
||||
c.style.webkitMaskImage = "none";
|
||||
// The artist page declares a named scroll-timeline INLINE on the
|
||||
// scroller; cloned along, it re-declares the name on this never-
|
||||
// scrolling copy and every cloned ride freezes at an empty pose —
|
||||
// the band fogged VOID over the hero. Shed it; the animation-none
|
||||
// rule below drops all cloned rides to their base styles.
|
||||
c.style.removeProperty("scroll-timeline");
|
||||
// Canvases clone BLANK — dead weight in the fog; and the clone's
|
||||
// node count feeds the next debounce.
|
||||
c.querySelectorAll("canvas").forEach((n) => n.remove());
|
||||
lastNodes = c.querySelectorAll("*").length;
|
||||
const banner = c.querySelector(".banner");
|
||||
if (banner) {
|
||||
banner.style.left = "-48px";
|
||||
banner.style.right = "-48px";
|
||||
}
|
||||
wrap.appendChild(c);
|
||||
// First fog after an empty band fades in; routine reclones swap
|
||||
// invisibly in place.
|
||||
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, and no `class` in the filter: hover highlights
|
||||
// flip classes constantly and were re-cloning the whole pane per
|
||||
// flip — the fog doesn't care about hover states (the band's
|
||||
// originals are masked anyway). Content identity arrives via
|
||||
// childList; art via src.
|
||||
// Debounce scales with page size and the clone itself runs at idle:
|
||||
// the library's 7k-node page cost a 126ms main-thread task per
|
||||
// reclone (measured 2026-08-26) — streamed-in covers were janking
|
||||
// scroll by re-cloning mid-frame. Fog after a page swap arrives a
|
||||
// beat late and FADES in (see reclone) instead of popping.
|
||||
const queueReclone = () => {
|
||||
clearTimeout(pending);
|
||||
const delay = Math.min(600, 120 + lastNodes / 40);
|
||||
pending = setTimeout(() => {
|
||||
// Never clone mid-scroll: the library's 117ms clone task was
|
||||
// the last source of scroll jank. The fog's translate stays
|
||||
// correct regardless; only content freshness waits. A hidden
|
||||
// window (mini mode) does no fog work at all — the
|
||||
// visibilitychange listener below re-queues on return.
|
||||
if (document.hidden) return;
|
||||
if (performance.now() - lastScroll < 250) return queueReclone();
|
||||
if ("requestIdleCallback" in window) {
|
||||
requestIdleCallback(() => reclone(), { timeout: 800 });
|
||||
} else {
|
||||
reclone();
|
||||
}
|
||||
}, delay);
|
||||
};
|
||||
const mo = new MutationObserver((records) => {
|
||||
// A direct-child swap = the PAGE changed: drop the stale fog
|
||||
// now (frost in the band beats last page's ghost) and let the
|
||||
// fresh clone fade in when it lands.
|
||||
for (const r of records) {
|
||||
if (r.target === view && (r.addedNodes.length || r.removedNodes.length)) {
|
||||
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 () => {
|
||||
mo.disconnect();
|
||||
ro.disconnect();
|
||||
document.removeEventListener("visibilitychange", onVis);
|
||||
view.removeEventListener("scroll", sync);
|
||||
clearTimeout(pending);
|
||||
peri.replaceChildren();
|
||||
};
|
||||
});
|
||||
|
||||
const crumbs = $derived(nav.stack.length ? trail() : []);
|
||||
const back = $derived(canGoBack());
|
||||
const forward = $derived(canGoForward());
|
||||
@@ -80,7 +235,10 @@
|
||||
|
||||
<!-- data-scrollhead keeps the overlay scrollbar's thumb below this bar rather
|
||||
than running under it (see src/lib/scrollbar.js). -->
|
||||
<div class="glassbar" class:hidden data-scrollhead>
|
||||
<div class="glassbar" class:hidden data-scrollhead bind:this={barEl}>
|
||||
{#if glassy}
|
||||
<div class="peri" bind:this={periEl} aria-hidden="true"></div>
|
||||
{/if}
|
||||
<div class="hist">
|
||||
<button class="arrow" disabled={!back} title="Back" aria-label="Back" onclick={navBack}>
|
||||
<Icon name="chevron-left" size={17} />
|
||||
@@ -125,42 +283,25 @@
|
||||
|
||||
<style>
|
||||
.glassbar {
|
||||
/* --bar-h is INHERITED from the scroll pane (App.svelte), not declared
|
||||
here: pages need the same number to decide whether to clear the bar
|
||||
or run their artwork under it, and two copies would drift. Safe
|
||||
because this component is only ever mounted inside that pane — see
|
||||
the note at the top of the file.
|
||||
Sticky rather than absolute, so the bar pins to the top of the pane
|
||||
and stays there as the page scrolls, and `top` pins it flush against
|
||||
the very top of the pane over that padding.
|
||||
/* --bar-h and --pane-pad are INHERITED from the pane shell
|
||||
(App.svelte), not declared here: pages need the same numbers to
|
||||
decide whether to clear the bar or run their artwork under it, and
|
||||
two copies would drift.
|
||||
|
||||
The negative margins reproduce an absolute bleed while keeping the
|
||||
bar in normal flow, and they do TWO jobs:
|
||||
|
||||
margin-top -pane-pad : bleed up over the pane's top padding
|
||||
(and -28 out to its left/right edges)
|
||||
margin-bottom -bar-h : consume no flow height of its own, and
|
||||
cancel the pane's top padding for what follows
|
||||
|
||||
so the net flow contribution is -pane-pad, and the PAGE's box after
|
||||
it starts flush with the top of the pane — which is what lets a hero
|
||||
run underneath the bar rather than beginning below it. Each page
|
||||
under the bar restores its own content inset with
|
||||
`padding-top: var(--pane-pad)`; see the note by BAR_VIEWS in
|
||||
App.svelte.
|
||||
|
||||
This used to come from margin collapsing instead: the bar was each
|
||||
page's first child, and its negative top margin collapsed through the
|
||||
page root and pulled it up. Hoisting the bar out ended the collapse
|
||||
and dropped every artist-page hero layer 24px (the `.hero-bg` stack
|
||||
is absolutely positioned against `.artist-page`), so the geometry is
|
||||
stated here rather than emerging from where the bar happens to sit.
|
||||
|
||||
All of these are tied to the pane's own padding — change one and the
|
||||
seam opens. */
|
||||
position: sticky;
|
||||
top: calc(-1 * var(--pane-pad));
|
||||
margin: calc(-1 * var(--pane-pad)) -28px calc(-1 * var(--bar-h));
|
||||
Absolute over the scroller, not sticky inside it (2026-08-26): the
|
||||
bar used to sit IN the scroll pane, with negative margins faking
|
||||
this exact overlay while cancelling the pane's top padding. The
|
||||
dark-glass band mask must hide the scroller's top band WITHOUT
|
||||
touching the bar, which demands the bar OUTSIDE the scroller —
|
||||
the bar-over-scroller shape the sidebar's gbar and the np head
|
||||
always had. The flush-page geometry moved with it: the scroller's
|
||||
own top padding is 0 on bar views (App.svelte), and each page
|
||||
under the bar restores its inset with padding-top: var(--pane-pad)
|
||||
exactly as before. */
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
/* Above the artist page's pinned identity (30) and hero (20): the bar
|
||||
keeps the top of the pane to itself and the parked wordmark slides
|
||||
under it. */
|
||||
@@ -184,6 +325,42 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Dark glass: over the frosted pane the backdrop sample black-flattens
|
||||
into a slab (the measured story in Sidebar.svelte), so the bar drops
|
||||
its backdrop and the .peri clone fog paints the band instead — the
|
||||
app's third periscope, now the sidebar's EXACT shape: static band
|
||||
mask on the scroller (App.svelte), bar floating outside it. */
|
||||
:global(:root.glass) .glassbar {
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
/* Painted pages ground the glass themselves — the original recipe is
|
||||
physically safe there and IS the designed hero-under-glass look. */
|
||||
:global(:root.glass .pane-shell.painted-page) .glassbar {
|
||||
backdrop-filter: blur(12px) saturate(1.7);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(1.7);
|
||||
}
|
||||
.peri {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* No animations inside the fog: cloned scroll-driven rides can never
|
||||
advance (their timeline source is the real scroller, not an ancestor
|
||||
here) and would hold empty from-poses. Base styles are the sensible
|
||||
static pose for a 46px blur. */
|
||||
.peri :global(.content) :global(*) {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
/* The bar's chrome rides above the fog. */
|
||||
.glassbar > :global(:not(.peri)) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* `display: none` rather than an {#if}: the element leaves the layout and
|
||||
measures zero (scrollbar.js reads this bar's height to start the thumb
|
||||
below it, and gets 0 here — correct for a page with no bar), while the
|
||||
|
||||
@@ -327,6 +327,136 @@
|
||||
return () => (active = false);
|
||||
});
|
||||
|
||||
// ── the head periscope (dark glass only) — the sidebar's pattern ────
|
||||
// Under glass the head's backdrop-filter black-flattens the moment the
|
||||
// wash scrolls out from under it (frost backdrop = opaque black slab;
|
||||
// see Sidebar.svelte for the measurements). So under glass: the head
|
||||
// drops backdrop-filter, the scroller's originals are masked out of the
|
||||
// head band, and a cloneNode copy under a NORMAL blur paints them back
|
||||
// as fog — wash included, so the head keeps its record tint at rest and
|
||||
// the colors still seep and shift as you scroll. Canvas children clone
|
||||
// blank (the waveform leaves no fog) — accepted.
|
||||
let npGlassy = $state(document.documentElement.classList.contains("glass"));
|
||||
let npScrollEl = $state(null);
|
||||
let npPeriEl = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
const mo = new MutationObserver(() => {
|
||||
npGlassy = document.documentElement.classList.contains("glass");
|
||||
});
|
||||
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||||
return () => mo.disconnect();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!npGlassy || !npScrollEl || !npPeriEl) return;
|
||||
const view = npScrollEl;
|
||||
const peri = npPeriEl;
|
||||
let pending = 0;
|
||||
let lastScroll = 0;
|
||||
let lastNodes = 500;
|
||||
const sync = () => {
|
||||
lastScroll = performance.now();
|
||||
const c = peri.firstElementChild?.firstElementChild;
|
||||
if (c) c.style.transform = `translateY(${-view.scrollTop}px)`;
|
||||
};
|
||||
const reclone = () => {
|
||||
// The blur lives on an OVERSCAN wrapper extending 48px past the
|
||||
// clip on every side: a gaussian fades toward transparent at its
|
||||
// element's bounds (~2× the radius), and with the bounds out
|
||||
// there the falloff is clipped away instead of vignetting the
|
||||
// band edges (Kyle's catch; the wash img does the same job with
|
||||
// scale(1.08)). The clone is pinned back inside by margins at
|
||||
// its measured width, so layout and alignment are unchanged.
|
||||
// height 142 = overscan + band + overscan: the filter rasters
|
||||
// this box, never the full cloned scroller.
|
||||
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());
|
||||
lastNodes = c.querySelectorAll("*").length;
|
||||
// The overscan clears the FILTER's falloff, but the blur still
|
||||
// mixes with emptiness wherever the CONTENT ends at the clip
|
||||
// line — and the wash is the one full-bleed layer, so its edges
|
||||
// vignetted the band (Kyle's second catch). Stretch the cloned
|
||||
// wash into the overscan margins so the band edge samples wash
|
||||
// against wash. Rows keep their own padding; nothing else
|
||||
// reaches the edge.
|
||||
const bleed = c.querySelector(".scroll-bleed");
|
||||
if (bleed) {
|
||||
bleed.style.top = "-48px";
|
||||
bleed.style.left = "-48px";
|
||||
bleed.style.right = "-48px";
|
||||
}
|
||||
wrap.appendChild(c);
|
||||
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 pane 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 + lastNodes / 40);
|
||||
pending = setTimeout(() => {
|
||||
// Never clone mid-scroll, never while hidden (see GlassBar).
|
||||
if (document.hidden) return;
|
||||
if (performance.now() - lastScroll < 250) return queueReclone();
|
||||
if ("requestIdleCallback" in window) {
|
||||
requestIdleCallback(() => reclone(), { timeout: 800 });
|
||||
} else {
|
||||
reclone();
|
||||
}
|
||||
}, delay);
|
||||
};
|
||||
const mo = new MutationObserver((records) => {
|
||||
for (const r of records) {
|
||||
if (r.target === view && (r.addedNodes.length || r.removedNodes.length)) {
|
||||
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 () => {
|
||||
mo.disconnect();
|
||||
ro.disconnect();
|
||||
document.removeEventListener("visibilitychange", onVis);
|
||||
view.removeEventListener("scroll", sync);
|
||||
clearTimeout(pending);
|
||||
peri.replaceChildren();
|
||||
};
|
||||
});
|
||||
|
||||
const bufferPct = $derived(
|
||||
player.streamTotal > 0
|
||||
? Math.min(100, (player.streamReceived / player.streamTotal) * 100)
|
||||
@@ -1037,10 +1167,10 @@
|
||||
{/snippet}
|
||||
|
||||
<aside class="nowplaying" class:collapsed={!ui.nowPlaying} class:dragging>
|
||||
<!-- The record washes the whole panel, expanded or collapsed, and the glass
|
||||
bars below sit on it exactly as the app's glass bar sits on a page hero.
|
||||
Blurred hard, because it is a backdrop for text and icons — not a
|
||||
second showing of the artwork. -->
|
||||
<!-- The record washing the RAIL (the expanded view carries its own
|
||||
.scroll-bleed inside the scroller, so the wash rides the scroll away
|
||||
like the album page's hero banner). Blurred hard, because it is a
|
||||
backdrop for text and icons — not a second showing of the artwork. -->
|
||||
{#if artUrl}
|
||||
<div class="bleed" aria-hidden="true">
|
||||
<img src={artUrl} alt="" draggable="false" />
|
||||
@@ -1104,6 +1234,9 @@
|
||||
waste of the only permanently visible line it has. -->
|
||||
<div class="headzone">
|
||||
<header class="np-head glass" data-scrollhead>
|
||||
{#if npGlassy}
|
||||
<div class="peri" bind:this={npPeriEl} aria-hidden="true"></div>
|
||||
{/if}
|
||||
<button class="np-icon" title="Collapse panel" aria-label="Collapse Now Playing" onclick={toggleNowPlaying}>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</button>
|
||||
@@ -1153,7 +1286,16 @@
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div class="np-scroll" use:floatScroll>
|
||||
<div class="np-scroll" use:floatScroll bind:this={npScrollEl}>
|
||||
<!-- The record's wash, anchored to the CONTENT: it rides the scroll
|
||||
away like the album page's hero banner (Kyle, 2026-08-26). The
|
||||
pane-level .bleed above now backgrounds the RAIL alone. -->
|
||||
{#if artUrl}
|
||||
<div class="scroll-bleed" aria-hidden="true">
|
||||
<img src={artUrl} alt="" draggable="false" />
|
||||
<span class="bleed-fade"></span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if panelContentVisible}
|
||||
{#if currentItem}
|
||||
<!-- glowing cover art -->
|
||||
@@ -1578,6 +1720,12 @@
|
||||
antialiased once and the wash runs full-bleed to the very edge —
|
||||
no inset, no mask, no visible cost (the 1% dim is nothing). */
|
||||
opacity: 0.99;
|
||||
transition: opacity 0.28s ease;
|
||||
}
|
||||
/* Expanded, the pane-level wash yields to the scroller's own (rail keeps
|
||||
this one). 0 ← 0.99, never up — the flatten note above stands. */
|
||||
.nowplaying:not(.collapsed) .bleed {
|
||||
opacity: 0;
|
||||
}
|
||||
.bleed img {
|
||||
width: 100%;
|
||||
@@ -1597,6 +1745,105 @@
|
||||
);
|
||||
}
|
||||
|
||||
/* The expanded view's wash, anchored to the scroller's CONTENT so it
|
||||
rides the scroll away — the album page's hero-banner grammar. Height
|
||||
tracks the pane width (the hero is width-driven), the fade span above
|
||||
lands on solid --bg-player before the box ends, so solid mode is
|
||||
seamless against the pane ground. Same flatten discipline as .bleed. */
|
||||
.scroll-bleed {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
aspect-ratio: 3 / 5;
|
||||
z-index: -1; /* np-scroll is a stacking context (z-index: 1) */
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
opacity: 0.99;
|
||||
}
|
||||
.scroll-bleed img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: blur(17px) saturate(1.25);
|
||||
opacity: 0.85;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
/* Dark glass: the pane joins the frost. The base thins like the sidebar
|
||||
(nets ~0.71 over the .app canvas tint), and the bleed's fade thins
|
||||
ONLY in its tail — it lands on solid --bg-player, which would keep
|
||||
the lower panel opaque. The upper stops stay IDENTICAL to solid mode:
|
||||
thinning them brightened the wash behind the glass header and the
|
||||
cover read straight through it (Kyle, 2026-08-26 — "don't change the
|
||||
glass topbar", the fix belongs to the wash, not the bar). Tail 0.85
|
||||
is the mini Wash vocabulary (its fades end near 0.92). Stops stay
|
||||
inside the flattened .bleed group, so the corner-arc story above is
|
||||
untouched. */
|
||||
:global(:root.glass) .nowplaying {
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
:global(:root.glass) .bleed-fade {
|
||||
background: linear-gradient(
|
||||
rgba(24, 24, 24, 0.15) 0,
|
||||
rgba(24, 24, 24, 0.75) 38%,
|
||||
rgba(24, 24, 24, 0.85) 78%
|
||||
);
|
||||
}
|
||||
/* The wash ends the way the album page's hero banner does (Kyle,
|
||||
2026-08-26): art and fade dissolve together below the hero zone and
|
||||
the lower pane is pure frost, the same 0.35 ground the sidebar and
|
||||
playbar stand on. Solid mode needs none of this: its fade already
|
||||
lands on opaque --bg-player. Masking a flattened group is one alpha
|
||||
op on one surface, so the corner-arc story above is untouched.
|
||||
(.bleed = the rail's wash, pane-relative %; .scroll-bleed = the
|
||||
expanded wash, self-relative — it also must reach nothing at its box
|
||||
end, where the glass fade tail would otherwise seam against frost.) */
|
||||
:global(:root.glass) .bleed {
|
||||
mask-image: linear-gradient(to bottom, #000 45%, transparent 72%);
|
||||
-webkit-mask-image: linear-gradient(to bottom, #000 45%, transparent 72%);
|
||||
}
|
||||
:global(:root.glass) .scroll-bleed {
|
||||
mask-image: linear-gradient(to bottom, #000 50%, transparent 96%);
|
||||
-webkit-mask-image: linear-gradient(to bottom, #000 50%, transparent 96%);
|
||||
}
|
||||
|
||||
/* ── the head periscope, dark glass only (see the script note) ──────
|
||||
The head drops its backdrop-filter (over frost it black-flattens
|
||||
into a slab — Sidebar.svelte holds the measurements), the scroller's
|
||||
originals are masked out of the 46px band (direct-child selector:
|
||||
the clone inside .peri also wears .np-scroll and must not inherit
|
||||
it), and the clone paints the band as fog — wash included, so the
|
||||
record tint survives at rest and shifts as you scroll. */
|
||||
:global(:root.glass) .np-head {
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
/* clip-path, not mask: rectangle clips ride the compositor's fast
|
||||
path (see App.svelte's band-cut note). */
|
||||
:global(:root.glass) .full-view > .np-scroll {
|
||||
clip-path: inset(46px 0 0 0);
|
||||
}
|
||||
.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. */
|
||||
/* The head's real content rides above the fog; the hairline is already
|
||||
absolute and only needs the lift. */
|
||||
.np-head > :global(:not(.peri):not(.hairline)) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.np-head .hairline {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Both views stack in the same box and cross-fade, on the left sidebar's
|
||||
own label clock — the collapse itself is the width animation above. */
|
||||
.rail-view,
|
||||
@@ -1630,6 +1877,7 @@
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
|
||||
/* The transfer, as a hairline along the glass bar's bottom edge — the same
|
||||
object in the rail and in the expanded header, so a download reads the
|
||||
same whichever state the panel is in. */
|
||||
|
||||
@@ -1359,6 +1359,11 @@
|
||||
overflow: hidden;
|
||||
background: var(--bg-player);
|
||||
border-radius: var(--radius);
|
||||
/* Dark glass (the rule right below): this pane frosts at the same
|
||||
0.35-over-canvas net as the sidebar and the search pill. Nothing
|
||||
scrolls beneath this bar and it holds no backdrop-filter, so the
|
||||
frost is the whole story — no periscope needed here. The moment
|
||||
either changes, steal the pattern from Sidebar.svelte. */
|
||||
|
||||
/* One clock for the whole gesture. Chosen on
|
||||
research/playbar-redesign/expand-collapse.html: 350ms on
|
||||
@@ -1397,6 +1402,10 @@
|
||||
);
|
||||
}
|
||||
|
||||
:global(:root.glass) .player {
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.player > .now-playing,
|
||||
.player > .extras {
|
||||
position: absolute;
|
||||
|
||||
@@ -18,6 +18,121 @@
|
||||
import * as api from "../lib/api.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 lastScroll = 0;
|
||||
let lastNodes = 500;
|
||||
const sync = () => {
|
||||
lastScroll = performance.now();
|
||||
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.
|
||||
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());
|
||||
lastNodes = c.querySelectorAll("*").length;
|
||||
wrap.appendChild(c);
|
||||
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 + lastNodes / 40);
|
||||
pending = setTimeout(() => {
|
||||
// Never clone mid-scroll, never while hidden (see GlassBar).
|
||||
if (document.hidden) return;
|
||||
if (performance.now() - lastScroll < 250) return queueReclone();
|
||||
if ("requestIdleCallback" in window) {
|
||||
requestIdleCallback(() => reclone(), { timeout: 800 });
|
||||
} else {
|
||||
reclone();
|
||||
}
|
||||
}, delay);
|
||||
};
|
||||
const mo = new MutationObserver((records) => {
|
||||
for (const r of records) {
|
||||
if (r.target === view && (r.addedNodes.length || r.removedNodes.length)) {
|
||||
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 () => {
|
||||
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.
|
||||
@@ -160,6 +275,9 @@
|
||||
<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}
|
||||
@@ -207,7 +325,7 @@
|
||||
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">
|
||||
<div class="view" bind:this={viewEl}>
|
||||
{#each items as item (item.id)}
|
||||
<button
|
||||
class="nav-item"
|
||||
@@ -299,6 +417,47 @@
|
||||
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);
|
||||
}
|
||||
.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;
|
||||
@@ -316,6 +475,8 @@
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
|
||||
|
||||
.gicon {
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
|
||||
@@ -678,6 +678,14 @@
|
||||
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.
|
||||
|
||||
+14
-2
@@ -2,9 +2,21 @@
|
||||
// root (global.css thins the base surfaces), the OS half is the acrylic
|
||||
// backdrop behind the transparent window (window_glass command). Both are
|
||||
// idempotent, so boot and the Settings toggle share this one entry point.
|
||||
//
|
||||
// This file runs in the MAIN window only. The mini frosts itself from the
|
||||
// `mini:glass` event this emits (one setting, two windows): it toggles its
|
||||
// own root class and invokes window_glass, which acts on the CALLING window.
|
||||
// The mini must never import this — its applier would re-emit into its own
|
||||
// listener.
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { windowGlass } from "./api.js";
|
||||
|
||||
let current = false;
|
||||
export const glassOn = () => current;
|
||||
|
||||
export function applyGlass(on) {
|
||||
document.documentElement.classList.toggle("glass", !!on);
|
||||
void windowGlass(!!on).catch(() => {});
|
||||
current = !!on;
|
||||
document.documentElement.classList.toggle("glass", current);
|
||||
void windowGlass(current).catch(() => {});
|
||||
void emit("mini:glass", { on: current });
|
||||
}
|
||||
@@ -11,11 +11,14 @@
|
||||
// throttled ~5/s
|
||||
// mini:queue { rows: [{ title, dur }], index, next: { title, artUrl } }
|
||||
// on queue shape change
|
||||
// mini:glass { on } on dark-glass flips
|
||||
// (applyGlass) + hello
|
||||
// mini:cmd { op, value } mini → here
|
||||
// mini:hello {} mini → here, answered
|
||||
// with a full snapshot
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import * as api from "./api.js";
|
||||
import { glassOn } from "./glass.js";
|
||||
import {
|
||||
player,
|
||||
theme,
|
||||
@@ -154,6 +157,9 @@ export function initMiniBridge() {
|
||||
void emitTrack();
|
||||
void emitQueue();
|
||||
emitTick();
|
||||
// Frost state rides its own channel: hello answers with the current
|
||||
// value, applyGlass pushes flips live (see glass.js).
|
||||
void emit("mini:glass", { on: glassOn() });
|
||||
});
|
||||
api.onEvent("mini:cmd", (p) => {
|
||||
const { op, value } = p ?? {};
|
||||
|
||||
@@ -69,6 +69,19 @@
|
||||
background: var(--bg-player);
|
||||
user-select: none;
|
||||
}
|
||||
/* Dark glass, mini edition: unlike the main window (only the canvas
|
||||
BETWEEN panes thins), the mini is all pane — so the pane itself thins
|
||||
to the same black tint the main's .app uses (one dial, two files —
|
||||
keep the alphas in step), over the OS acrylic (window_glass).
|
||||
global.css already stops html/body painting under .glass; every
|
||||
shape's own --bg-player fill is overridden in one place here. The
|
||||
Wash art bleed stays as-is above it. */
|
||||
:global(:root.glass) .miniroot {
|
||||
background: transparent;
|
||||
}
|
||||
:global(:root.glass) .miniroot :global(.shape) {
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
:global(.m-disc) {
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
|
||||
@@ -103,6 +103,16 @@ export function initMini() {
|
||||
mini.index = p.index ?? -1;
|
||||
mini.next = p.next ?? null;
|
||||
});
|
||||
// Dark glass, the mini's half: the brain streams the one setting
|
||||
// (glass.js emits on flips, the bridge answers hello); this window
|
||||
// toggles its own root class and asks the OS for its own acrylic —
|
||||
// window_glass acts on the calling window. Don't import glass.js here:
|
||||
// its applier emits mini:glass, which this listener would loop on.
|
||||
onEvent("mini:glass", (p) => {
|
||||
const on = !!p?.on;
|
||||
document.documentElement.classList.toggle("glass", on);
|
||||
void invoke("window_glass", { on }).catch(() => {});
|
||||
});
|
||||
// Ask the brain for a full snapshot; retry briefly in case the mini won
|
||||
// the race against the bridge's listener registration.
|
||||
void emit("mini:hello", {});
|
||||
|
||||
@@ -1164,6 +1164,16 @@
|
||||
var(--bg-base) 100%
|
||||
);
|
||||
}
|
||||
/* Dark glass: the banner DISSOLVES at its foot instead of landing on a
|
||||
repainted tail — a translucent tail color can never match the frost
|
||||
beneath and drew a hard line at the box edge (Kyle, 2026-08-26). The
|
||||
mask fades the whole layer (img + scrim together) to nothing, so it
|
||||
ends on frost at frost's own value — the np wash's move. Solid mode
|
||||
keeps the gradient's opaque landing against --bg-base. */
|
||||
:global(:root.glass) .banner {
|
||||
mask-image: linear-gradient(to bottom, #000 55%, transparent 98%);
|
||||
-webkit-mask-image: linear-gradient(to bottom, #000 55%, transparent 98%);
|
||||
}
|
||||
|
||||
/* `stretch`, not `flex-end`: the copy column is sized BY the artwork, so
|
||||
its first line starts at the cover's top edge and its last row ends at
|
||||
|
||||
@@ -4584,8 +4584,14 @@
|
||||
// (defe225) when the new location lands.
|
||||
</script>
|
||||
|
||||
<!-- data-painted: this page paints its ground floor-to-ceiling (hero image
|
||||
+ the .sheet's opaque tail), so under dark glass the GlassBar keeps its
|
||||
REAL backdrop-filter here — no band cut, no periscope fog. The frost
|
||||
never shows through a painted page, and the fog was replacing the
|
||||
designed hero-under-glass continuity with a blurred imitation. -->
|
||||
<div
|
||||
class="artist-page"
|
||||
data-painted
|
||||
data-load-stage={pageLoadStage}
|
||||
data-scroll={heroScrolled ? "1" : "0"}
|
||||
data-park={extrasParked ? "1" : "0"}
|
||||
|
||||
@@ -1503,7 +1503,7 @@
|
||||
{#snippet cover(a, size)}
|
||||
{#if a.art_path}
|
||||
{#if coverUrls[a.id]}
|
||||
<img class="cover" src={coverUrls[a.id]} alt="" loading="lazy" draggable="false" />
|
||||
<img class="cover" src={coverUrls[a.id]} alt="" loading="lazy" decoding="async" draggable="false" />
|
||||
{:else}
|
||||
<div class="cover gen-pending"></div>
|
||||
{/if}
|
||||
@@ -1564,7 +1564,7 @@
|
||||
<span class="ed-badge">{r.release_date.slice(0, 4)}</span>
|
||||
{/if}
|
||||
{#if r.cover}
|
||||
<img class="cover" src={r.cover} alt="" loading="lazy" draggable="false" />
|
||||
<img class="cover" src={r.cover} alt="" loading="lazy" decoding="async" draggable="false" />
|
||||
{:else}
|
||||
<div class="cover placeholder"><Icon name="disc" size={30} /></div>
|
||||
{/if}
|
||||
@@ -2190,7 +2190,7 @@
|
||||
>
|
||||
<span class="lb-cell lb-thumb">
|
||||
{#if a.art_path && coverUrls[a.id]}
|
||||
<img src={coverUrls[a.id]} alt="" loading="lazy" draggable="false" />
|
||||
<img src={coverUrls[a.id]} alt="" loading="lazy" decoding="async" draggable="false" />
|
||||
{:else if !a.art_path}
|
||||
{@const g = genFor(a)}
|
||||
<span class="lib-gen mini" data-pat={g.pat} style="--h1:{g.hue};--h2:{g.hue2}"></span>
|
||||
@@ -2961,6 +2961,13 @@
|
||||
border-radius: 7px;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
/* 800+ rows: offscreen ones skip render entirely. The intrinsic
|
||||
hint matches the fixed 36px height exactly, so scroll geometry
|
||||
(and every offset-based jump) is unchanged — this took the
|
||||
library's mount task from ~112ms and its cold-scroll raster
|
||||
spikes down to screen-sized work (measured 2026-08-26). */
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 36px;
|
||||
}
|
||||
.lib-page[data-cols="all"] .lb-tr {
|
||||
grid-template-columns: 34px minmax(150px, 1fr) 150px 70px 46px 40px 56px 96px 58px 66px 74px;
|
||||
|
||||
Reference in new issue
Block a user