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
+820
-116
No files matched your search
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"test": "node --test tests/preview/"
|
"test": "node --test \"tests/preview/*.test.mjs\""
|
||||||
},
|
},
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+29
-48
@@ -27,39 +27,6 @@ mod ytdlp;
|
|||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
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()
|
tauri::Builder::default()
|
||||||
.manage(state::AppState::default())
|
.manage(state::AppState::default())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
@@ -256,23 +223,37 @@ pub fn run() {
|
|||||||
vocal_sep::vocal_sep_download,
|
vocal_sep::vocal_sep_download,
|
||||||
])
|
])
|
||||||
.run({
|
.run({
|
||||||
#[allow(unused_mut)]
|
|
||||||
let mut context = tauri::generate_context!();
|
let mut context = tauri::generate_context!();
|
||||||
// Debug builds expose CDP on 9222 for instrumentation (see HANDOFF.md).
|
// Browser args ride the WINDOW CONFIG, nothing else: wry always sets
|
||||||
// wry always sets AdditionalBrowserArguments programmatically, so the
|
// AdditionalBrowserArguments programmatically, and at least WebView2
|
||||||
// WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS env var is ignored; the flag has
|
// 151.x then drops the WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS env var
|
||||||
// to go through the window config. Explicit args replace wry's defaults,
|
// entirely — flags "delivered" that way vanish silently on some
|
||||||
// so those are restated here.
|
// runtimes (they held on the home machine, gone on the work one).
|
||||||
#[cfg(debug_assertions)]
|
// Verify in the browser process command line (Get-CimInstance
|
||||||
{
|
// Win32_Process), never by assumption. Explicit args replace wry's
|
||||||
for window in &mut context.config_mut().app.windows {
|
// defaults, so those are restated. Two WebView2 presentation fixes
|
||||||
window.additional_browser_args = Some(
|
// live here (2026-08-26, the dark-glass work):
|
||||||
"--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection \
|
//
|
||||||
|
// 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 \
|
--autoplay-policy=no-user-gesture-required \
|
||||||
--remote-debugging-port=9222"
|
--force-color-profile=srgb";
|
||||||
.into(),
|
#[cfg(debug_assertions)]
|
||||||
);
|
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
|
context
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
"minimizable": false,
|
"minimizable": false,
|
||||||
"alwaysOnTop": true,
|
"alwaysOnTop": true,
|
||||||
"shadow": true,
|
"shadow": true,
|
||||||
"backgroundColor": "#181818"
|
"transparent": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
|
|||||||
+75
-19
@@ -137,14 +137,18 @@
|
|||||||
scrim intercepts pointer input and the dialog traps the keyboard.
|
scrim intercepts pointer input and the dialog traps the keyboard.
|
||||||
The titlebar stays live for window chrome. -->
|
The titlebar stays live for window chrome. -->
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main class="content" use:floatScroll inert={previewUi.open || undefined}>
|
<!-- The pane is a SHELL with the bar floating absolute over an inner
|
||||||
<!-- First child of the scroll pane: the bar's sticky pin and its
|
scroller — the same bar-over-scroller relationship the sidebar's
|
||||||
negative bleed margins are measured against THIS element's
|
gbar and the np head have, which is what lets the dark-glass band
|
||||||
padding. It consumes no flow height of its own AND cancels the
|
mask sit statically on the scroller (a bar INSIDE the scroller
|
||||||
pane's top padding, so the page after it begins flush with the top
|
would be masked with it; the per-frame page-mask this replaces
|
||||||
of the pane and its artwork runs under the bar. -->
|
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)} />
|
<GlassBar hidden={!BAR_VIEWS.has(ui.view)} />
|
||||||
|
<div class="content" use:floatScroll>
|
||||||
<CurrentView />
|
<CurrentView />
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<NowPlaying />
|
<NowPlaying />
|
||||||
<PlayerBar />
|
<PlayerBar />
|
||||||
@@ -224,7 +228,17 @@
|
|||||||
acrylic. The panes above it all paint their own opaque surfaces and
|
acrylic. The panes above it all paint their own opaque surfaces and
|
||||||
must stay untouched. */
|
must stay untouched. */
|
||||||
:global(:root.glass) .app {
|
: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
|
/* Collapsed left nav: narrow the sidebar column to an icon rail. The
|
||||||
@@ -250,8 +264,30 @@
|
|||||||
--nowplaying-width: var(--nowplaying-rail);
|
--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;
|
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);
|
background: linear-gradient(var(--bg-highlight) 0, var(--bg-base) 240px);
|
||||||
/* The highlight wash belongs to the top of the PAGE, not the viewport:
|
/* The highlight wash belongs to the top of the PAGE, not the viewport:
|
||||||
anchored to the pane (default `scroll`), the gradient stays put
|
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
|
away with the content, keeping the seam colors identical at every
|
||||||
scroll position. */
|
scroll position. */
|
||||||
background-attachment: local;
|
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-y: auto;
|
||||||
overflow-anchor: none;
|
overflow-anchor: none;
|
||||||
/* Main scroll pane: no elastic rubber-band at its edges. Inner regions
|
/* Main scroll pane: no elastic rubber-band at its edges. Inner regions
|
||||||
deliberately do NOT set this, so their wheel chains here. */
|
deliberately do NOT set this, so their wheel chains here. */
|
||||||
overscroll-behavior: none;
|
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;
|
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>
|
</style>
|
||||||
+213
-36
@@ -32,6 +32,161 @@
|
|||||||
/** `hidden` hides the bar without unmounting it — see the note above. */
|
/** `hidden` hides the bar without unmounting it — see the note above. */
|
||||||
let { children, hidden = false } = $props();
|
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 crumbs = $derived(nav.stack.length ? trail() : []);
|
||||||
const back = $derived(canGoBack());
|
const back = $derived(canGoBack());
|
||||||
const forward = $derived(canGoForward());
|
const forward = $derived(canGoForward());
|
||||||
@@ -80,7 +235,10 @@
|
|||||||
|
|
||||||
<!-- data-scrollhead keeps the overlay scrollbar's thumb below this bar rather
|
<!-- data-scrollhead keeps the overlay scrollbar's thumb below this bar rather
|
||||||
than running under it (see src/lib/scrollbar.js). -->
|
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">
|
<div class="hist">
|
||||||
<button class="arrow" disabled={!back} title="Back" aria-label="Back" onclick={navBack}>
|
<button class="arrow" disabled={!back} title="Back" aria-label="Back" onclick={navBack}>
|
||||||
<Icon name="chevron-left" size={17} />
|
<Icon name="chevron-left" size={17} />
|
||||||
@@ -125,42 +283,25 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.glassbar {
|
.glassbar {
|
||||||
/* --bar-h is INHERITED from the scroll pane (App.svelte), not declared
|
/* --bar-h and --pane-pad are INHERITED from the pane shell
|
||||||
here: pages need the same number to decide whether to clear the bar
|
(App.svelte), not declared here: pages need the same numbers to
|
||||||
or run their artwork under it, and two copies would drift. Safe
|
decide whether to clear the bar or run their artwork under it, and
|
||||||
because this component is only ever mounted inside that pane — see
|
two copies would drift.
|
||||||
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.
|
|
||||||
|
|
||||||
The negative margins reproduce an absolute bleed while keeping the
|
Absolute over the scroller, not sticky inside it (2026-08-26): the
|
||||||
bar in normal flow, and they do TWO jobs:
|
bar used to sit IN the scroll pane, with negative margins faking
|
||||||
|
this exact overlay while cancelling the pane's top padding. The
|
||||||
margin-top -pane-pad : bleed up over the pane's top padding
|
dark-glass band mask must hide the scroller's top band WITHOUT
|
||||||
(and -28 out to its left/right edges)
|
touching the bar, which demands the bar OUTSIDE the scroller —
|
||||||
margin-bottom -bar-h : consume no flow height of its own, and
|
the bar-over-scroller shape the sidebar's gbar and the np head
|
||||||
cancel the pane's top padding for what follows
|
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
|
||||||
so the net flow contribution is -pane-pad, and the PAGE's box after
|
under the bar restores its inset with padding-top: var(--pane-pad)
|
||||||
it starts flush with the top of the pane — which is what lets a hero
|
exactly as before. */
|
||||||
run underneath the bar rather than beginning below it. Each page
|
position: absolute;
|
||||||
under the bar restores its own content inset with
|
top: 0;
|
||||||
`padding-top: var(--pane-pad)`; see the note by BAR_VIEWS in
|
left: 0;
|
||||||
App.svelte.
|
right: 0;
|
||||||
|
|
||||||
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));
|
|
||||||
/* Above the artist page's pinned identity (30) and hero (20): the bar
|
/* 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
|
keeps the top of the pane to itself and the parked wordmark slides
|
||||||
under it. */
|
under it. */
|
||||||
@@ -184,6 +325,42 @@
|
|||||||
overflow: hidden;
|
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
|
/* `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
|
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
|
below it, and gets 0 here — correct for a page with no bar), while the
|
||||||
|
|||||||
@@ -327,6 +327,136 @@
|
|||||||
return () => (active = false);
|
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(
|
const bufferPct = $derived(
|
||||||
player.streamTotal > 0
|
player.streamTotal > 0
|
||||||
? Math.min(100, (player.streamReceived / player.streamTotal) * 100)
|
? Math.min(100, (player.streamReceived / player.streamTotal) * 100)
|
||||||
@@ -1037,10 +1167,10 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<aside class="nowplaying" class:collapsed={!ui.nowPlaying} class:dragging>
|
<aside class="nowplaying" class:collapsed={!ui.nowPlaying} class:dragging>
|
||||||
<!-- The record washes the whole panel, expanded or collapsed, and the glass
|
<!-- The record washing the RAIL (the expanded view carries its own
|
||||||
bars below sit on it exactly as the app's glass bar sits on a page hero.
|
.scroll-bleed inside the scroller, so the wash rides the scroll away
|
||||||
Blurred hard, because it is a backdrop for text and icons — not a
|
like the album page's hero banner). Blurred hard, because it is a
|
||||||
second showing of the artwork. -->
|
backdrop for text and icons — not a second showing of the artwork. -->
|
||||||
{#if artUrl}
|
{#if artUrl}
|
||||||
<div class="bleed" aria-hidden="true">
|
<div class="bleed" aria-hidden="true">
|
||||||
<img src={artUrl} alt="" draggable="false" />
|
<img src={artUrl} alt="" draggable="false" />
|
||||||
@@ -1104,6 +1234,9 @@
|
|||||||
waste of the only permanently visible line it has. -->
|
waste of the only permanently visible line it has. -->
|
||||||
<div class="headzone">
|
<div class="headzone">
|
||||||
<header class="np-head glass" data-scrollhead>
|
<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}>
|
<button class="np-icon" title="Collapse panel" aria-label="Collapse Now Playing" onclick={toggleNowPlaying}>
|
||||||
<Icon name="chevron-right" size={16} />
|
<Icon name="chevron-right" size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -1153,7 +1286,16 @@
|
|||||||
</header>
|
</header>
|
||||||
</div>
|
</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 panelContentVisible}
|
||||||
{#if currentItem}
|
{#if currentItem}
|
||||||
<!-- glowing cover art -->
|
<!-- glowing cover art -->
|
||||||
@@ -1578,6 +1720,12 @@
|
|||||||
antialiased once and the wash runs full-bleed to the very edge —
|
antialiased once and the wash runs full-bleed to the very edge —
|
||||||
no inset, no mask, no visible cost (the 1% dim is nothing). */
|
no inset, no mask, no visible cost (the 1% dim is nothing). */
|
||||||
opacity: 0.99;
|
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 {
|
.bleed img {
|
||||||
width: 100%;
|
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
|
/* 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. */
|
own label clock — the collapse itself is the width animation above. */
|
||||||
.rail-view,
|
.rail-view,
|
||||||
@@ -1630,6 +1877,7 @@
|
|||||||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.05);
|
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
|
/* 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
|
object in the rail and in the expanded header, so a download reads the
|
||||||
same whichever state the panel is in. */
|
same whichever state the panel is in. */
|
||||||
|
|||||||
@@ -1359,6 +1359,11 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--bg-player);
|
background: var(--bg-player);
|
||||||
border-radius: var(--radius);
|
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
|
/* One clock for the whole gesture. Chosen on
|
||||||
research/playbar-redesign/expand-collapse.html: 350ms 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 > .now-playing,
|
||||||
.player > .extras {
|
.player > .extras {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -18,6 +18,121 @@
|
|||||||
import * as api from "../lib/api.js";
|
import * as api from "../lib/api.js";
|
||||||
import Icon from "./Icon.svelte";
|
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
|
// 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
|
// trail are fed by recorded visits, and a destination that skips the
|
||||||
// recorder is one the back arrow can never return to.
|
// recorder is one the back arrow can never return to.
|
||||||
@@ -160,6 +275,9 @@
|
|||||||
<nav class="sidebar" class:collapsed={ui.sidebarCollapsed}>
|
<nav class="sidebar" class:collapsed={ui.sidebarCollapsed}>
|
||||||
<!-- One element, outside the list, so it never fades with the content. -->
|
<!-- One element, outside the list, so it never fades with the content. -->
|
||||||
<div class="gbar">
|
<div class="gbar">
|
||||||
|
{#if glassy}
|
||||||
|
<div class="peri" bind:this={periEl} aria-hidden="true"></div>
|
||||||
|
{/if}
|
||||||
<button
|
<button
|
||||||
class="status"
|
class="status"
|
||||||
data-state={app.connection}
|
data-state={app.connection}
|
||||||
@@ -207,7 +325,7 @@
|
|||||||
any difference between the copies reads as doubling. Here nothing fades
|
any difference between the copies reads as doubling. Here nothing fades
|
||||||
but text, and no glyph can move — every x comes from padding, which
|
but text, and no glyph can move — every x comes from padding, which
|
||||||
does not depend on the panel's width. -->
|
does not depend on the panel's width. -->
|
||||||
<div class="view">
|
<div class="view" bind:this={viewEl}>
|
||||||
{#each items as item (item.id)}
|
{#each items as item (item.id)}
|
||||||
<button
|
<button
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
@@ -299,6 +417,47 @@
|
|||||||
overflow: hidden;
|
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 ── */
|
/* ── the glass bar ── */
|
||||||
.gbar {
|
.gbar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -316,6 +475,8 @@
|
|||||||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.05);
|
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.gicon {
|
.gicon {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 18px;
|
right: 18px;
|
||||||
|
|||||||
@@ -678,6 +678,14 @@
|
|||||||
color: var(--text-muted);
|
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
|
/* 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
|
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.
|
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
|
// root (global.css thins the base surfaces), the OS half is the acrylic
|
||||||
// backdrop behind the transparent window (window_glass command). Both are
|
// backdrop behind the transparent window (window_glass command). Both are
|
||||||
// idempotent, so boot and the Settings toggle share this one entry point.
|
// 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";
|
import { windowGlass } from "./api.js";
|
||||||
|
|
||||||
|
let current = false;
|
||||||
|
export const glassOn = () => current;
|
||||||
|
|
||||||
export function applyGlass(on) {
|
export function applyGlass(on) {
|
||||||
document.documentElement.classList.toggle("glass", !!on);
|
current = !!on;
|
||||||
void windowGlass(!!on).catch(() => {});
|
document.documentElement.classList.toggle("glass", current);
|
||||||
|
void windowGlass(current).catch(() => {});
|
||||||
|
void emit("mini:glass", { on: current });
|
||||||
}
|
}
|
||||||
@@ -11,11 +11,14 @@
|
|||||||
// throttled ~5/s
|
// throttled ~5/s
|
||||||
// mini:queue { rows: [{ title, dur }], index, next: { title, artUrl } }
|
// mini:queue { rows: [{ title, dur }], index, next: { title, artUrl } }
|
||||||
// on queue shape change
|
// on queue shape change
|
||||||
|
// mini:glass { on } on dark-glass flips
|
||||||
|
// (applyGlass) + hello
|
||||||
// mini:cmd { op, value } mini → here
|
// mini:cmd { op, value } mini → here
|
||||||
// mini:hello {} mini → here, answered
|
// mini:hello {} mini → here, answered
|
||||||
// with a full snapshot
|
// with a full snapshot
|
||||||
import { emit } from "@tauri-apps/api/event";
|
import { emit } from "@tauri-apps/api/event";
|
||||||
import * as api from "./api.js";
|
import * as api from "./api.js";
|
||||||
|
import { glassOn } from "./glass.js";
|
||||||
import {
|
import {
|
||||||
player,
|
player,
|
||||||
theme,
|
theme,
|
||||||
@@ -154,6 +157,9 @@ export function initMiniBridge() {
|
|||||||
void emitTrack();
|
void emitTrack();
|
||||||
void emitQueue();
|
void emitQueue();
|
||||||
emitTick();
|
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) => {
|
api.onEvent("mini:cmd", (p) => {
|
||||||
const { op, value } = p ?? {};
|
const { op, value } = p ?? {};
|
||||||
|
|||||||
@@ -69,6 +69,19 @@
|
|||||||
background: var(--bg-player);
|
background: var(--bg-player);
|
||||||
user-select: none;
|
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) {
|
:global(.m-disc) {
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
|
|||||||
@@ -103,6 +103,16 @@ export function initMini() {
|
|||||||
mini.index = p.index ?? -1;
|
mini.index = p.index ?? -1;
|
||||||
mini.next = p.next ?? null;
|
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
|
// Ask the brain for a full snapshot; retry briefly in case the mini won
|
||||||
// the race against the bridge's listener registration.
|
// the race against the bridge's listener registration.
|
||||||
void emit("mini:hello", {});
|
void emit("mini:hello", {});
|
||||||
|
|||||||
@@ -1164,6 +1164,16 @@
|
|||||||
var(--bg-base) 100%
|
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
|
/* `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
|
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.
|
// (defe225) when the new location lands.
|
||||||
</script>
|
</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
|
<div
|
||||||
class="artist-page"
|
class="artist-page"
|
||||||
|
data-painted
|
||||||
data-load-stage={pageLoadStage}
|
data-load-stage={pageLoadStage}
|
||||||
data-scroll={heroScrolled ? "1" : "0"}
|
data-scroll={heroScrolled ? "1" : "0"}
|
||||||
data-park={extrasParked ? "1" : "0"}
|
data-park={extrasParked ? "1" : "0"}
|
||||||
|
|||||||
@@ -1503,7 +1503,7 @@
|
|||||||
{#snippet cover(a, size)}
|
{#snippet cover(a, size)}
|
||||||
{#if a.art_path}
|
{#if a.art_path}
|
||||||
{#if coverUrls[a.id]}
|
{#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}
|
{:else}
|
||||||
<div class="cover gen-pending"></div>
|
<div class="cover gen-pending"></div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1564,7 +1564,7 @@
|
|||||||
<span class="ed-badge">{r.release_date.slice(0, 4)}</span>
|
<span class="ed-badge">{r.release_date.slice(0, 4)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if r.cover}
|
{#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}
|
{:else}
|
||||||
<div class="cover placeholder"><Icon name="disc" size={30} /></div>
|
<div class="cover placeholder"><Icon name="disc" size={30} /></div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -2190,7 +2190,7 @@
|
|||||||
>
|
>
|
||||||
<span class="lb-cell lb-thumb">
|
<span class="lb-cell lb-thumb">
|
||||||
{#if a.art_path && coverUrls[a.id]}
|
{#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}
|
{:else if !a.art_path}
|
||||||
{@const g = genFor(a)}
|
{@const g = genFor(a)}
|
||||||
<span class="lib-gen mini" data-pat={g.pat} style="--h1:{g.hue};--h2:{g.hue2}"></span>
|
<span class="lib-gen mini" data-pat={g.pat} style="--h1:{g.hue};--h2:{g.hue2}"></span>
|
||||||
@@ -2961,6 +2961,13 @@
|
|||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
cursor: pointer;
|
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 {
|
.lib-page[data-cols="all"] .lb-tr {
|
||||||
grid-template-columns: 34px minmax(150px, 1fr) 150px 70px 46px 40px 56px 96px 58px 66px 74px;
|
grid-template-columns: 34px minmax(150px, 1fr) 150px 70px 46px 40px 56px 96px 58px 66px 74px;
|
||||||
|
|||||||
Reference in new issue
Block a user