forked from Keelrleek/SoulStream
dark glass gets a surface ladder, and the 200-750ms stalls are dead - two sessions of uncommitted work landing together
SURFACES: panes add black, surfaces INSIDE panes add white. Every pane stands on 0.7075 black over the acrylic (.content 0.35 over .app 0.55), so a translucent black card nets 0.81 - darker than its own pane and inside the ~0.85 band already rejected once on the artist page. Solid mode's ladder is measured UPWARD from #121212 (+6/+18/+24), so glass elevates with WHITE: new --glass-surface/-hover/-press/-hairline/-well/-ring under :root.glass (0.03/0.08/0.11/0.10/0.18), which is independently the vocabulary .btn-ghost:hover and .ax-ghost:hover already shipped. The four solid tokens are NEVER redefined - they are consumed inside color-mix() and gradient stops where an alpha multiplies through and kills the call site (0.74 x 0.03 = an invisible chip; the album row's text-overflow fade works by BEING opaque), and they paint modal chrome that has to stay solid. 129 additive :global(:root.glass) declarations across 18 files, all 129 verified inside a glass block by walking the diff - solid mode byte-identical, confirmed structurally, by computed style with the class off, and visually. Three of them are not colour swaps: the album row's .rowbtns shim was OCCLUDING the last ~19px of the listeners bar and nothing translucent can occlude, so .tcount masks out just before the buttons instead; Playlist's banner scrim and the discography's pinned ambience both landed on opaque --bg-base and now dissolve like the album banner; and the 0.9 black hero tint on Home/CatalogSearch/Discography paints straight onto the pane when there is no artwork, so :first-child (the {#if} renders no element) flips it to white. Menus, dialogs, toasts and the import surfaces stay opaque deliberately, and the Settings copy for the mode now says so instead of claiming cards do. Measured with a known ground substituted behind the transparent window, since CDP never sees the acrylic: library toolbar chroma +2 as shipped, +16 with a white fill AND its backdrop-filter dropped, +30 for the bare pane beside it. RESIZE: Windows drops the legacy accent-policy acrylic for the whole of a live resize and the window stays transparent through it, so the desktop punches through sharp - measured by toggling window_glass alone, desktop icon labels readable through the titlebar. The blur cannot be kept (documented since Win10 2004, and the modern DWM backdrop was already rejected for the overlay-promotion blink), so armGlassResizeGuard grounds the canvas to --bg-sidebar for the length of the gesture. STALLS: "choppy but the FPS says 144" was 200-750ms MAIN-THREAD TASKS, not frame pacing - idle-scheduled work fires at scroll pauses by definition. Twin bake moved to display resolution and off the ready task (was a same-task 4K WebGL readback plus software blur, 100-170ms per rotation AND per artist entry); gl.finish() -> gl.flush() in buildMaster, which had been draining the whole 4K EASU/RCAS/grade chain on the main thread; copyCanvas realloc guarded; one app-wide scroll clock in scrollbar.js (markAppScroll/appScrollIdleFor) so all three periscopes stop cloning mid-gesture on ANY pane rather than only their own; bounded reclone with the querySelectorAll census deleted, the gate widened 250->1000ms, and the build/attach split across an idle callback and a rAF behind a generation guard; the hero rotation tick gated on scroll recency and its payload cache sized to the working set (2->4 entries, 24->32MiB, 32->64MiB); sessionTick no longer force-rewrites every 12s while merely PAUSED. Scroll storm went from 109-616ms long tasks to zero. Hover recalc and GC were measured innocent - do not re-chase them. 98/98 tests throughout.
This commit is contained in:
1 parent
7ab678fd22
commit
547cee6649
26 files changed
+1220
-144
No files matched your search
@@ -243,10 +243,20 @@ pub fn run() {
|
||||
// than the animated path — covers blew out near-white at rest
|
||||
// and snapped correct on hover. One profile, one mapping, both
|
||||
// paths agree.
|
||||
// 3. Force the wheel scroll animator ON: this WebView2 shipped
|
||||
// wheel detents as INSTANT 120px jumps (measured: a dispatched
|
||||
// detent moved scrollTop in exactly one frame) while the
|
||||
// present pipeline ran a flawless 144Hz — scroll position
|
||||
// updating at detent cadence reads as "choppy but the FPS
|
||||
// says 144". The system animation setting is on and
|
||||
// prefers-reduced-motion is off, so the snap is WebView2's
|
||||
// embedded default, not the Chromium system heuristic; the
|
||||
// switch pins enable_scroll_animator=true in WebPreferences.
|
||||
//
|
||||
// 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 \
|
||||
--enable-smooth-scrolling \
|
||||
--force-color-profile=srgb";
|
||||
#[cfg(debug_assertions)]
|
||||
let args = format!("{base} --remote-debugging-port=9222");
|
||||
|
||||
+27
-1
@@ -37,7 +37,7 @@
|
||||
} from "./lib/state.svelte.js";
|
||||
import { floatScroll } from "./lib/scrollbar.js";
|
||||
import { initMiniBridge } from "./lib/miniBridge.svelte.js";
|
||||
import { applyGlass } from "./lib/glass.js";
|
||||
import { applyGlass, armGlassResizeGuard } from "./lib/glass.js";
|
||||
import { getSettings } from "./lib/api.js";
|
||||
|
||||
const views = {
|
||||
@@ -108,6 +108,12 @@
|
||||
void getSettings()
|
||||
.then((s) => s?.glass_mode && applyGlass(true))
|
||||
.catch(() => {});
|
||||
// Windows drops the acrylic blur for the length of a live resize and the
|
||||
// window stays transparent through it, so the desktop punches through
|
||||
// sharp. The guard grounds the canvas for the gesture — see the note on
|
||||
// armGlassResizeGuard. Armed unconditionally: the CSS gates on .glass, and
|
||||
// this way the mode can be toggled mid-drag without leaving stale state.
|
||||
armGlassResizeGuard();
|
||||
autoConnect();
|
||||
refreshLibrary();
|
||||
refreshPlaylists();
|
||||
@@ -231,6 +237,26 @@
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
/* While the window is being resized, the canvas GROUNDS itself.
|
||||
Windows drops the acrylic blur for the whole of a live resize — that is
|
||||
a documented limitation of the legacy accent-policy backdrop the OS half
|
||||
uses (see window_glass in src-tauri/src/commands.rs), not something the
|
||||
app can switch off. The window stays transparent through it, so what the
|
||||
eye gets is not "less blur" but the raw desktop, sharp: wallpaper,
|
||||
icons, other windows. Measured 2026-08-27 by toggling window_glass on
|
||||
its own — with the acrylic off you can read desktop icon labels straight
|
||||
through the titlebar strip and the sidebar.
|
||||
The blur cannot be kept, so the transparency stops being used for the
|
||||
length of the gesture. One opaque canvas is enough: every pane above it
|
||||
is translucent over THIS, so grounding here grounds all of them.
|
||||
--bg-sidebar is exactly what solid mode paints, so the drag looks like
|
||||
the app with glass simply switched off, which is what it is.
|
||||
.resizing is written by armGlassResizeGuard (src/lib/glass.js) and is
|
||||
gated on .glass here, so solid mode never sees it. */
|
||||
:global(:root.glass.resizing) .app {
|
||||
background: var(--bg-sidebar);
|
||||
}
|
||||
|
||||
/* 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
|
||||
|
||||
@@ -818,4 +818,27 @@
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── dark glass ────────────────────────────────────────────────────────
|
||||
Ladder in global.css; solid mode untouched. These sections sit low on
|
||||
the artist page, below the hero's dissolve, where the ground is plain
|
||||
pane frost. */
|
||||
:global(:root.glass) .ax-card:hover,
|
||||
:global(:root.glass) .ax-own:hover {
|
||||
background: var(--glass-hover);
|
||||
}
|
||||
:global(:root.glass) .ax-art,
|
||||
:global(:root.glass) .ax-own {
|
||||
background: var(--glass-surface);
|
||||
}
|
||||
:global(:root.glass) .ax-own {
|
||||
border-color: var(--glass-hairline);
|
||||
}
|
||||
/* The activity timeline's bars are a chart, not chrome: the resting bar is
|
||||
the "no releases" tone and the accent marks the hovered year, so the
|
||||
two have to stay clearly apart. A white veil at the surface value keeps
|
||||
that contrast against the frost the way #242424 does against #121212. */
|
||||
:global(:root.glass) .tl-bar {
|
||||
background: var(--glass-press);
|
||||
}
|
||||
</style>
|
||||
@@ -27,6 +27,7 @@
|
||||
resolver,
|
||||
} from "../lib/state.svelte.js";
|
||||
import { canGoBack, canGoForward, trail } from "../lib/navigation.svelte.js";
|
||||
import { markAppScroll, appScrollIdleFor } from "../lib/scrollbar.js";
|
||||
import Icon from "./Icon.svelte";
|
||||
|
||||
/** `hidden` hides the bar without unmounting it — see the note above. */
|
||||
@@ -58,27 +59,63 @@
|
||||
const peri = periEl;
|
||||
if (!view) return;
|
||||
let pending = 0;
|
||||
let lastScroll = 0;
|
||||
let lastNodes = 500;
|
||||
let recloneGen = 0;
|
||||
let lastRecloneMs = 4;
|
||||
// Observer mode. On a painted page the full subtree+src watch fed a
|
||||
// guaranteed no-op pipeline: the artist hero swaps its src every 20s
|
||||
// and covers stream in, and every batch armed the debounce, burned an
|
||||
// idle callback and re-ran a reclone that returned immediately. So
|
||||
// painted pages are watched with DIRECT CHILDREN ONLY — enough to see
|
||||
// the next page swap. CONTRACT this introduces: data-painted is a
|
||||
// mount-time-static attribute on a direct child of .content; a page
|
||||
// toggling it in place without remounting will not be noticed (none
|
||||
// does).
|
||||
let narrowObs = false;
|
||||
const FULL_OBS = {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["src"],
|
||||
};
|
||||
const sync = () => {
|
||||
lastScroll = performance.now();
|
||||
markAppScroll();
|
||||
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.
|
||||
// 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.
|
||||
// Runs SYNCHRONOUSLY from the observer's page-swap branch — the old
|
||||
// debounced-only determination left a 120-800ms window after
|
||||
// navigating onto the artist page where the band cut and the dropped
|
||||
// backdrop still applied over the fresh hero — and again from every
|
||||
// reclone, which stays the authority for the resize/visibility/idle
|
||||
// paths.
|
||||
const applyPainted = () => {
|
||||
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) {
|
||||
// Re-observe only on an actual mode CHANGE — disconnect drops any
|
||||
// queued records, so never cycle it per call.
|
||||
if (painted !== narrowObs) {
|
||||
narrowObs = painted;
|
||||
mo.disconnect();
|
||||
mo.observe(view, painted ? { childList: true } : FULL_OBS);
|
||||
}
|
||||
return painted;
|
||||
};
|
||||
const reclone = () => {
|
||||
if (applyPainted()) {
|
||||
peri.replaceChildren();
|
||||
return;
|
||||
}
|
||||
// BUILD, detached, measured — the duration feeds the next
|
||||
// debounce delay (it scales with subtree size exactly like the
|
||||
// old full-tree node census, which existed only to guess it).
|
||||
const t0 = performance.now();
|
||||
const gen = ++recloneGen;
|
||||
const wrap = document.createElement("div");
|
||||
// height 142 = 48 overscan + 46 band + 48 overscan: the filter
|
||||
// rasterizes THIS box, not the full cloned page — blurring
|
||||
@@ -102,26 +139,35 @@
|
||||
// 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.
|
||||
// Canvases clone BLANK — dead weight in the fog.
|
||||
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();
|
||||
lastRecloneMs = performance.now() - t0;
|
||||
// ATTACH in its own frame: replaceChildren forces selector
|
||||
// matching + style + layout of the entire clone — the expensive
|
||||
// half of the task — and must never share a task with clone
|
||||
// construction. Re-checked at attach: a stale build (page
|
||||
// swapped, newer reclone, effect torn down — the gen bump) is
|
||||
// dropped; resumed scrolling discards the wrap and requeues.
|
||||
requestAnimationFrame(() => {
|
||||
if (gen !== recloneGen) return;
|
||||
if (!appScrollIdleFor(1000)) return queueReclone();
|
||||
// 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
|
||||
@@ -135,17 +181,29 @@
|
||||
// beat late and FADES in (see reclone) instead of popping.
|
||||
const queueReclone = () => {
|
||||
clearTimeout(pending);
|
||||
const delay = Math.min(600, 120 + lastNodes / 40);
|
||||
const delay = Math.min(600, 120 + lastRecloneMs * 3);
|
||||
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.
|
||||
// APP-WIDE recency (scrollbar.js note), a full second of it:
|
||||
// 250ms sat inside natural inter-flick pauses, so the clone
|
||||
// landed exactly between wheel bursts. Re-checked inside the
|
||||
// idle callback with NO forcing timeout, and the callback
|
||||
// yields when the granted idle slice is too thin for the
|
||||
// build. A 17px-blurred band going stale for the extra beat
|
||||
// is invisible; a clone task landing mid-scroll is the
|
||||
// measured freeze-then-teleport stall.
|
||||
if (document.hidden) return;
|
||||
if (performance.now() - lastScroll < 250) return queueReclone();
|
||||
if (!appScrollIdleFor(1000)) return queueReclone();
|
||||
if ("requestIdleCallback" in window) {
|
||||
requestIdleCallback(() => reclone(), { timeout: 800 });
|
||||
requestIdleCallback((deadline) => {
|
||||
if (!appScrollIdleFor(1000)) return queueReclone();
|
||||
if (deadline && deadline.timeRemaining() < 15) return queueReclone();
|
||||
reclone();
|
||||
});
|
||||
} else {
|
||||
reclone();
|
||||
}
|
||||
@@ -153,22 +211,23 @@
|
||||
};
|
||||
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.
|
||||
// now (frost in the band beats last page's ghost), resolve
|
||||
// painted-ness in the same breath (shell class, band exemption,
|
||||
// observer mode — no debounce-wide mis-clip window), and only
|
||||
// then decide whether there is fog to build.
|
||||
for (const r of records) {
|
||||
if (r.target === view && (r.addedNodes.length || r.removedNodes.length)) {
|
||||
// Invalidate any built-but-unattached clone: attaching
|
||||
// the OLD page's fog after this clear would ghost it.
|
||||
recloneGen++;
|
||||
peri.replaceChildren();
|
||||
if (applyPainted()) return;
|
||||
break;
|
||||
}
|
||||
}
|
||||
queueReclone();
|
||||
});
|
||||
mo.observe(view, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["src"],
|
||||
});
|
||||
mo.observe(view, FULL_OBS);
|
||||
const ro = new ResizeObserver(queueReclone);
|
||||
ro.observe(view);
|
||||
const onVis = () => {
|
||||
@@ -178,6 +237,7 @@
|
||||
view.addEventListener("scroll", sync, { passive: true });
|
||||
reclone();
|
||||
return () => {
|
||||
recloneGen++;
|
||||
mo.disconnect();
|
||||
ro.disconnect();
|
||||
document.removeEventListener("visibilitychange", onVis);
|
||||
|
||||
@@ -932,4 +932,41 @@
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── dark glass ────────────────────────────────────────────────────────
|
||||
Ladder in global.css. This pane rides inside Now Playing, so its ground
|
||||
is the same 0.7075 frost every other pane surface stands on — with one
|
||||
wrinkle worth stating: while the album wash is still painting at the top
|
||||
of the panel these surfaces sit on artwork instead, and a white veil
|
||||
reads correctly on both. Solid mode is untouched. */
|
||||
:global(:root.glass) .line:hover,
|
||||
:global(:root.glass) .cand:hover {
|
||||
background: var(--glass-hover);
|
||||
}
|
||||
:global(:root.glass) .tool:hover,
|
||||
:global(:root.glass) .pbtn.ghost:hover {
|
||||
background: var(--glass-press);
|
||||
}
|
||||
:global(:root.glass) .nudge,
|
||||
:global(:root.glass) .state-glyph,
|
||||
:global(:root.glass) .pbtn.ghost,
|
||||
:global(:root.glass) .mono,
|
||||
:global(:root.glass) .c-tag.inst {
|
||||
background: var(--glass-surface);
|
||||
}
|
||||
:global(:root.glass) .paste textarea {
|
||||
background: var(--glass-well);
|
||||
border-color: var(--glass-hairline);
|
||||
}
|
||||
/* The loading shimmer: same two-tone sweep, in white. */
|
||||
:global(:root.glass) .shimmer span {
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
rgba(255, 255, 255, 0.06) 35%,
|
||||
rgba(255, 255, 255, 0.13) 50%,
|
||||
rgba(255, 255, 255, 0.06) 65%
|
||||
);
|
||||
background-size: 240% 100%;
|
||||
}
|
||||
/* Menus stay solid on purpose — see Playlists.svelte. */
|
||||
</style>
|
||||
@@ -33,7 +33,7 @@
|
||||
import LyricsPane from "./LyricsPane.svelte";
|
||||
import PlaylistMenu from "./PlaylistMenu.svelte";
|
||||
import * as api from "../lib/api.js";
|
||||
import { floatScroll } from "../lib/scrollbar.js";
|
||||
import { floatScroll, markAppScroll, appScrollIdleFor } from "../lib/scrollbar.js";
|
||||
import { splitDescription } from "../lib/editions.js";
|
||||
import { accentFromImage } from "../lib/color.js";
|
||||
import {
|
||||
@@ -353,10 +353,10 @@
|
||||
const view = npScrollEl;
|
||||
const peri = npPeriEl;
|
||||
let pending = 0;
|
||||
let lastScroll = 0;
|
||||
let lastNodes = 500;
|
||||
let recloneGen = 0;
|
||||
let lastRecloneMs = 4;
|
||||
const sync = () => {
|
||||
lastScroll = performance.now();
|
||||
markAppScroll();
|
||||
const c = peri.firstElementChild?.firstElementChild;
|
||||
if (c) c.style.transform = `translateY(${-view.scrollTop}px)`;
|
||||
};
|
||||
@@ -370,6 +370,11 @@
|
||||
// its measured width, so layout and alignment are unchanged.
|
||||
// height 142 = overscan + band + overscan: the filter rasters
|
||||
// this box, never the full cloned scroller.
|
||||
// BUILD, detached, measured — the duration feeds the next
|
||||
// debounce delay (see GlassBar; the old node census existed
|
||||
// only to guess it).
|
||||
const t0 = performance.now();
|
||||
const gen = ++recloneGen;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.style.cssText =
|
||||
"position:absolute;top:-48px;left:-48px;right:-48px;height:142px;" +
|
||||
@@ -382,7 +387,6 @@
|
||||
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
|
||||
@@ -397,14 +401,22 @@
|
||||
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();
|
||||
lastRecloneMs = performance.now() - t0;
|
||||
// ATTACH in its own frame (see GlassBar): the style+layout
|
||||
// flush of the whole clone never shares a task with its
|
||||
// construction; stale builds and resumed scrolling requeue.
|
||||
requestAnimationFrame(() => {
|
||||
if (gen !== recloneGen) return;
|
||||
if (!appScrollIdleFor(1000)) return queueReclone();
|
||||
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.
|
||||
@@ -412,13 +424,24 @@
|
||||
// GlassBar note: big pages were janking frames with reclone tasks).
|
||||
const queueReclone = () => {
|
||||
clearTimeout(pending);
|
||||
const delay = Math.min(600, 120 + lastNodes / 40);
|
||||
const delay = Math.min(600, 120 + lastRecloneMs * 3);
|
||||
pending = setTimeout(() => {
|
||||
// Never clone mid-scroll, never while hidden (see GlassBar).
|
||||
// APP-WIDE scroll recency (scrollbar.js note), a full second
|
||||
// of it, re-checked inside the idle callback with no forcing
|
||||
// timeout, and the callback yields on a thin idle slice:
|
||||
// this pane's playback churn (queue rows, lyric lines, art)
|
||||
// used to queue a full-pane clone gated only on the NP
|
||||
// scroller's own last-scroll time, so it landed exactly when
|
||||
// the user paused scrolling the CONTENT pane.
|
||||
if (document.hidden) return;
|
||||
if (performance.now() - lastScroll < 250) return queueReclone();
|
||||
if (!appScrollIdleFor(1000)) return queueReclone();
|
||||
if ("requestIdleCallback" in window) {
|
||||
requestIdleCallback(() => reclone(), { timeout: 800 });
|
||||
requestIdleCallback((deadline) => {
|
||||
if (!appScrollIdleFor(1000)) return queueReclone();
|
||||
if (deadline && deadline.timeRemaining() < 15) return queueReclone();
|
||||
reclone();
|
||||
});
|
||||
} else {
|
||||
reclone();
|
||||
}
|
||||
@@ -427,6 +450,8 @@
|
||||
const mo = new MutationObserver((records) => {
|
||||
for (const r of records) {
|
||||
if (r.target === view && (r.addedNodes.length || r.removedNodes.length)) {
|
||||
// Invalidate any built-but-unattached clone (GlassBar note).
|
||||
recloneGen++;
|
||||
peri.replaceChildren();
|
||||
break;
|
||||
}
|
||||
@@ -448,6 +473,7 @@
|
||||
view.addEventListener("scroll", sync, { passive: true });
|
||||
reclone();
|
||||
return () => {
|
||||
recloneGen++;
|
||||
mo.disconnect();
|
||||
ro.disconnect();
|
||||
document.removeEventListener("visibilitychange", onVis);
|
||||
@@ -1808,6 +1834,76 @@
|
||||
-webkit-mask-image: linear-gradient(to bottom, #000 50%, transparent 96%);
|
||||
}
|
||||
|
||||
/* ── the chrome standing ON the frost (2026-08-27) ──────────────────
|
||||
Below the wash's dissolve the panel is bare frost, and everything on
|
||||
it was still painting opaque --bg-highlight: the quality chips, every
|
||||
queue and release row, the icon buttons, the art wells. See the ladder
|
||||
in global.css for why these elevate with white rather than more black.
|
||||
Solid mode keeps every original recipe. */
|
||||
:global(:root.glass) .chip {
|
||||
background: var(--glass-surface);
|
||||
box-shadow: var(--glass-ring);
|
||||
}
|
||||
:global(:root.glass) .np-icon:hover,
|
||||
:global(:root.glass) .row:hover,
|
||||
:global(:root.glass) .np-head :global(.plm-trigger:hover) {
|
||||
background: var(--glass-hover);
|
||||
}
|
||||
/* Artwork wells: what is visible while a cover loads, or in place of one
|
||||
that never arrives. */
|
||||
:global(:root.glass) .cover,
|
||||
:global(:root.glass) .un-fallback,
|
||||
:global(:root.glass) .achip-ph,
|
||||
:global(:root.glass) .av2,
|
||||
:global(:root.glass) .rel-th,
|
||||
:global(:root.glass) .mini .art {
|
||||
background: var(--glass-surface);
|
||||
}
|
||||
/* The segmented control's thumb was `color-mix(--accent 28%,
|
||||
--bg-highlight)` — accent-tinted but fully OPAQUE, so the one piece of
|
||||
chrome that marks which tab you are on read as a solid brown slab on
|
||||
the frost. This is the artist page's own tab recipe (.disco-tab.on),
|
||||
copied deliberately: the two controls do the same job and should not
|
||||
have two vocabularies. Nets 0.34 + 0.66x0.5 = 0.67 alpha. */
|
||||
:global(:root.glass) .seg-thumb {
|
||||
background: color-mix(in srgb, var(--accent) 34%, rgba(10, 10, 12, 0.5));
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
}
|
||||
/* The COLLAPSED rail's header wears the same .glass recipe as .np-head and
|
||||
had the same flatten, but none of the periscope machinery — and it needs
|
||||
none: the rail is a fixed column (header, edge, cover, dots), nothing
|
||||
scrolls beneath this bar, so the filter had nothing to blur in the first
|
||||
place. Dropping it removes a flattened slab and a backdrop pass. */
|
||||
:global(:root.glass) .rail-bar {
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
/* Hairlines. --border is opaque #2a2a2a, which on frost reads as a drawn
|
||||
grey line rather than an edge. These five all pair it with a translucent
|
||||
fill they already had, so only the border was out of step. */
|
||||
:global(:root.glass) .upnext,
|
||||
:global(:root.glass) .achip,
|
||||
:global(:root.glass) .fan,
|
||||
:global(:root.glass) .showall,
|
||||
:global(:root.glass) .seg {
|
||||
border-color: var(--glass-hairline);
|
||||
}
|
||||
/* The queue-count badge punches a 2px ring of opaque --bg-player around
|
||||
itself to separate it from the icon underneath. On frost that ring is a
|
||||
solid dot of #181818; the same separation comes from the frost itself. */
|
||||
:global(:root.glass) .rail-badge {
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
/* NOTE, deliberately NOT changed: .segwrap keeps its backdrop-filter.
|
||||
It is a bar that has to hide the rows scrolling under it, and over
|
||||
frost a backdrop sample black-flattens (the measurement lives in
|
||||
Sidebar.svelte). The shipped answer for a bar in that position is a
|
||||
periscope clone, not a thinner fill — three of those exist already and
|
||||
a fourth is a structural change, not a paint one. Left as a known
|
||||
slab; revisit with the periscope pattern if it starts to grate. */
|
||||
|
||||
/* ── 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
|
||||
|
||||
@@ -1405,6 +1405,18 @@
|
||||
:global(:root.glass) .player {
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
/* The cover well behind the bar's artwork: opaque --bg-highlight, so a
|
||||
track with no art (or one still loading) punched a grey square through
|
||||
the frost. Ladder value — see global.css. */
|
||||
:global(:root.glass) .art.placeholder,
|
||||
:global(:root.glass) .art.cover {
|
||||
background: var(--glass-surface);
|
||||
}
|
||||
/* The expand/collapse chevron at the bar's edge. */
|
||||
:global(:root.glass) .gicon:hover {
|
||||
background: var(--glass-hover);
|
||||
}
|
||||
/* The overflow menu stays solid on purpose — see Playlists.svelte. */
|
||||
|
||||
.player > .now-playing,
|
||||
.player > .extras {
|
||||
|
||||
@@ -163,4 +163,15 @@
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 12px 26px -10px rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
|
||||
/* ── dark glass ────────────────────────────────────────────────────────
|
||||
Only the TRIGGER. .save-icon is a 34px icon button that lives on the
|
||||
page — album actions, the Now Playing head — so it stands on pane frost
|
||||
and its opaque --bg-highlight hover read as a slab there. The popover
|
||||
and its toast below it stay solid on purpose: a menu has to be readable
|
||||
over whatever it lands on (the Settings copy for this mode says so). */
|
||||
:global(:root.glass) .save-icon:hover,
|
||||
:global(:root.glass) .save-icon.on {
|
||||
background: var(--glass-hover);
|
||||
}
|
||||
</style>
|
||||
@@ -16,6 +16,7 @@
|
||||
playlistCoverFor,
|
||||
} from "../lib/state.svelte.js";
|
||||
import * as api from "../lib/api.js";
|
||||
import { markAppScroll, appScrollIdleFor } from "../lib/scrollbar.js";
|
||||
import Icon from "./Icon.svelte";
|
||||
|
||||
// ── the periscope (dark glass only) ─────────────────────────────────
|
||||
@@ -42,10 +43,10 @@
|
||||
const view = viewEl;
|
||||
const peri = periEl;
|
||||
let pending = 0;
|
||||
let lastScroll = 0;
|
||||
let lastNodes = 500;
|
||||
let recloneGen = 0;
|
||||
let lastRecloneMs = 4;
|
||||
const sync = () => {
|
||||
lastScroll = performance.now();
|
||||
markAppScroll();
|
||||
const c = peri.firstElementChild?.firstElementChild;
|
||||
if (c) c.style.transform = `translateY(${-view.scrollTop}px)`;
|
||||
};
|
||||
@@ -59,6 +60,11 @@
|
||||
// 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.
|
||||
// BUILD, detached, measured — the duration feeds the next
|
||||
// debounce delay (see GlassBar; the old node census existed
|
||||
// only to guess it).
|
||||
const t0 = performance.now();
|
||||
const gen = ++recloneGen;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.style.cssText =
|
||||
"position:absolute;top:-48px;left:-48px;right:-48px;height:142px;" +
|
||||
@@ -71,16 +77,23 @@
|
||||
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();
|
||||
lastRecloneMs = performance.now() - t0;
|
||||
// ATTACH in its own frame (see GlassBar): the style+layout
|
||||
// flush of the whole clone never shares a task with its
|
||||
// construction; stale builds and resumed scrolling requeue.
|
||||
requestAnimationFrame(() => {
|
||||
if (gen !== recloneGen) return;
|
||||
if (!appScrollIdleFor(1000)) return queueReclone();
|
||||
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.
|
||||
@@ -88,13 +101,22 @@
|
||||
// GlassBar note: big pages were janking frames with reclone tasks).
|
||||
const queueReclone = () => {
|
||||
clearTimeout(pending);
|
||||
const delay = Math.min(600, 120 + lastNodes / 40);
|
||||
const delay = Math.min(600, 120 + lastRecloneMs * 3);
|
||||
pending = setTimeout(() => {
|
||||
// Never clone mid-scroll, never while hidden (see GlassBar).
|
||||
// APP-WIDE scroll recency (scrollbar.js note), a full second
|
||||
// of it, re-checked inside the idle callback with no forcing
|
||||
// timeout, and the callback yields on a thin idle slice:
|
||||
// this pane's own churn must not clone while the CONTENT
|
||||
// pane scrolls.
|
||||
if (document.hidden) return;
|
||||
if (performance.now() - lastScroll < 250) return queueReclone();
|
||||
if (!appScrollIdleFor(1000)) return queueReclone();
|
||||
if ("requestIdleCallback" in window) {
|
||||
requestIdleCallback(() => reclone(), { timeout: 800 });
|
||||
requestIdleCallback((deadline) => {
|
||||
if (!appScrollIdleFor(1000)) return queueReclone();
|
||||
if (deadline && deadline.timeRemaining() < 15) return queueReclone();
|
||||
reclone();
|
||||
});
|
||||
} else {
|
||||
reclone();
|
||||
}
|
||||
@@ -103,6 +125,8 @@
|
||||
const mo = new MutationObserver((records) => {
|
||||
for (const r of records) {
|
||||
if (r.target === view && (r.addedNodes.length || r.removedNodes.length)) {
|
||||
// Invalidate any built-but-unattached clone (GlassBar note).
|
||||
recloneGen++;
|
||||
peri.replaceChildren();
|
||||
break;
|
||||
}
|
||||
@@ -124,6 +148,7 @@
|
||||
view.addEventListener("scroll", sync, { passive: true });
|
||||
reclone();
|
||||
return () => {
|
||||
recloneGen++;
|
||||
mo.disconnect();
|
||||
ro.disconnect();
|
||||
document.removeEventListener("visibilitychange", onVis);
|
||||
@@ -442,6 +467,38 @@
|
||||
:global(:root.glass) .sidebar > .view {
|
||||
clip-path: inset(46px 0 0 0);
|
||||
}
|
||||
/* The rows ON the frost. --bg-highlight is opaque #242424, so under glass
|
||||
every nav row and every pinned/recent row painted a slab on the pane —
|
||||
the one Kyle photographed first. The white veil makes the same +18 step
|
||||
against the frost that #242424 makes against #121212 (see the ladder in
|
||||
global.css). Hover and active deliberately share one value: this sidebar
|
||||
has always distinguished active by TEXT COLOUR (the note above the base
|
||||
rule), and that is left exactly as it is. */
|
||||
:global(:root.glass) .nav-item:hover,
|
||||
:global(:root.glass) .rec:hover,
|
||||
:global(:root.glass) .nav-item.active,
|
||||
:global(:root.glass) .rec.on {
|
||||
background: var(--glass-hover);
|
||||
}
|
||||
:global(:root.glass) .gicon:hover {
|
||||
background: var(--glass-hover);
|
||||
}
|
||||
/* The artwork well behind a recent with no cover yet. */
|
||||
:global(:root.glass) .art-placeholder {
|
||||
background: var(--glass-surface);
|
||||
}
|
||||
/* --border is opaque #2a2a2a: a 1px divider on the frost read as a solid
|
||||
scratch. The hairline token is the same weight against glass. */
|
||||
:global(:root.glass) .rule {
|
||||
background: var(--glass-hairline);
|
||||
}
|
||||
/* The count badge's backdrop is the frosted pane, so its blur flattens the
|
||||
sample to black exactly like the bar's did — the fill already carries
|
||||
the look on its own. */
|
||||
:global(:root.glass) .bdg {
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
.peri {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
@@ -1016,4 +1016,25 @@
|
||||
background: #c42b1c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── dark glass ────────────────────────────────────────────────────────
|
||||
The window buttons are the one surface in this file that stands on the
|
||||
CANVAS rather than on a pane, and the math is different there: the
|
||||
canvas is .app's 0.55, not a pane's 0.7075, so the ground behind them
|
||||
is lighter and a hover needs a stronger veil to make the same step.
|
||||
0.12 is the value GlassBar's own .arrow:hover already uses for chrome
|
||||
in this position. --glass-hover (0.08) is tuned for the 0.7075 pane and
|
||||
would read as almost nothing up here. */
|
||||
:global(:root.glass) .win-btn:hover,
|
||||
:global(:root.glass) .dl-ring:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
/* The ⏎ keycap inside the search pill; the pill is already glass. */
|
||||
:global(:root.glass) .kb {
|
||||
background: var(--glass-hover);
|
||||
}
|
||||
/* The suggestions dropdown and everything inside it stay OPAQUE on
|
||||
purpose — it is a menu, and a menu has to stay readable over whatever
|
||||
page it lands on. Same call as Playlists/Playlist, and the Settings
|
||||
copy for this mode says so out loud. */
|
||||
</style>
|
||||
@@ -20,3 +20,44 @@ export function applyGlass(on) {
|
||||
void windowGlass(current).catch(() => {});
|
||||
void emit("mini:glass", { on: current });
|
||||
}
|
||||
|
||||
/** Ground the canvas while the window is being resized.
|
||||
*
|
||||
* WHY THIS EXISTS. The OS half of glass mode is the LEGACY accent-policy
|
||||
* acrylic — `SetWindowCompositionAttribute` with ACCENT_ENABLE_ACRYLICBLURBEHIND
|
||||
* (see `window_glass` in src-tauri/src/commands.rs, and the comment there for
|
||||
* why the modern DWM system backdrop was rejected). Windows drops that blur
|
||||
* for the duration of a live resize; it has done so since Windows 10 2004 and
|
||||
* it is not something the app can switch off. The moment it drops, the window
|
||||
* is still TRANSPARENT — so the desktop behind it stops being a soft wash and
|
||||
* punches through razor-sharp, wallpaper and icons and all. Measured
|
||||
* 2026-08-27 by toggling `window_glass` alone: with the acrylic off you can
|
||||
* read desktop icon labels through the titlebar and the sidebar.
|
||||
*
|
||||
* So the fix is not to keep the blur — Windows will not let us. It is to stop
|
||||
* relying on it for the length of the gesture: the canvas goes opaque, and
|
||||
* with nothing see-through there is nothing for the missing blur to ruin. The
|
||||
* window reads as an ordinary solid dark window while you drag, and comes back
|
||||
* to glass when you let go.
|
||||
*
|
||||
* `resize` fires continuously through a drag and once for a maximize, a
|
||||
* restore or a DPI change, so all of those are covered by the same guard. The
|
||||
* class goes on unconditionally and the CSS gates it on `.glass`, which keeps
|
||||
* this correct if the mode is toggled mid-gesture.
|
||||
*/
|
||||
const RESIZE_SETTLE_MS = 180;
|
||||
export function armGlassResizeGuard() {
|
||||
if (typeof window === "undefined") return () => {};
|
||||
let timer = 0;
|
||||
const onResize = () => {
|
||||
document.documentElement.classList.add("resizing");
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => document.documentElement.classList.remove("resizing"), RESIZE_SETTLE_MS);
|
||||
};
|
||||
window.addEventListener("resize", onResize, { passive: true });
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener("resize", onResize);
|
||||
document.documentElement.classList.remove("resizing");
|
||||
};
|
||||
}
|
||||
+19
-3
@@ -351,8 +351,12 @@ export function createHeroGpuRenderer(canvas, bloomCanvas, previousCanvas, previ
|
||||
|
||||
function copyCanvas(from, to) {
|
||||
if (!from.width || !from.height) return;
|
||||
to.width = from.width;
|
||||
to.height = from.height;
|
||||
// Guarded like copyBloom below: assigning width/height reallocates
|
||||
// the backing store even when the value is unchanged — two ~10-20MB
|
||||
// reallocs per rotation for nothing. The full-surface drawImage on
|
||||
// an alpha:false context overwrites every pixel, so reuse is clean.
|
||||
if (to.width !== from.width) to.width = from.width;
|
||||
if (to.height !== from.height) to.height = from.height;
|
||||
const context = to.getContext("2d", { alpha: false });
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.imageSmoothingQuality = "high";
|
||||
@@ -451,7 +455,19 @@ export function createHeroGpuRenderer(canvas, bloomCanvas, previousCanvas, previ
|
||||
});
|
||||
gl.bindTexture(gl.TEXTURE_2D, finished.tx);
|
||||
gl.generateMipmap(gl.TEXTURE_2D);
|
||||
gl.finish();
|
||||
// flush, NEVER finish — the same reasoning resize() records
|
||||
// below: gl.finish() blocks the main thread until the GPU has
|
||||
// drained, and it stalls hardest exactly when the GPU is busy.
|
||||
// Here that meant the whole 4K EASU/RCAS/grade chain drained
|
||||
// inside the rotation task — a main-thread block in the measured
|
||||
// 100ms+ class on a 4-core iGPU, every ~20s hero rotation, worst
|
||||
// in glass mode when the compositor shares the GPU. GL command
|
||||
// ordering makes the temporaries' deletion in `finally` safe
|
||||
// without draining (deletion of referenced resources is
|
||||
// deferred), and nothing reads pixels back before the later
|
||||
// drawImage copies impose their own sync. renderMs now reports
|
||||
// enqueue time, not drain time — fine for the debug attribute.
|
||||
gl.flush();
|
||||
disposeTarget(finished, true);
|
||||
return { tx: finished.tx, width: HERO_MASTER_WIDTH, height: HERO_MASTER_HEIGHT };
|
||||
} catch (error) {
|
||||
|
||||
@@ -26,6 +26,22 @@
|
||||
const PAD = 8; // gap between the thumb's travel and the top header / bottom edge
|
||||
const HOVER_ZONE = 22; // px in from the right edge that reveals the thumb on hover
|
||||
|
||||
// One app-wide scroll-recency clock, living here because this module already
|
||||
// sees every custom-scrolled pane (and, unlike glass.js, is safe for the mini
|
||||
// to import — a separate window is a separate module graph). It exists for
|
||||
// the periscopes' "never clone mid-scroll" gates: each pane's gate used to
|
||||
// consult only its OWN scroller's last-scroll time, so DOM churn in one pane
|
||||
// (np queue/lyric updates during playback, sidebar recents) queued a
|
||||
// full-pane clone that landed the moment the user paused scrolling a
|
||||
// DIFFERENT pane — measured 2026-08-27 as 200-750ms input-blocking tasks at
|
||||
// artist-scroll pauses on a 4-core laptop. Marked by every periscope sync
|
||||
// and by onScroll below; checked by every queueReclone, and RE-checked
|
||||
// inside the idle callback, because an idle gap between wheel bursts is not
|
||||
// "done scrolling".
|
||||
let lastAppScroll = 0;
|
||||
export const markAppScroll = () => (lastAppScroll = performance.now());
|
||||
export const appScrollIdleFor = (ms) => performance.now() - lastAppScroll >= ms;
|
||||
|
||||
// Scroll-driven-animation support, gated EXACTLY like CatalogArtist's
|
||||
// scrollDriven const so the two can never disagree about which engine owns
|
||||
// per-scroll motion. Where supported, the thumb's slide is a compositor
|
||||
@@ -169,6 +185,7 @@ export function floatScroll(node) {
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
markAppScroll();
|
||||
// SDA path: the compositor owns the slide; touching scrollTop here
|
||||
// would force the style+layout pass this fix exists to remove.
|
||||
if (SDA) {
|
||||
|
||||
+40
-5
@@ -966,11 +966,18 @@ const catalogHeroArtCache = new Map();
|
||||
const catalogHeroGpuCache = new Map();
|
||||
const CATALOG_HERO_PREVIEW_CACHE_MAX = 16;
|
||||
const CATALOG_HERO_ART_CACHE_MAX = 10;
|
||||
const CATALOG_HERO_GPU_CACHE_MAX = 2;
|
||||
// Sized for the ROTATION WORKING SET, not just the current+next pair: at 2
|
||||
// entries the 20s carousel over 3+ banners missed on every tick, and the
|
||||
// 24MiB entry cap excluded every raw 4K payload (3840×2160×4 ≈ 31.6MiB —
|
||||
// the baked-4K provider fallback), so those re-paid the backend decode and
|
||||
// a fresh multi-MB raw-RGBA IPC transfer on every single revisit. Worst
|
||||
// case here is 64MiB of pinned ArrayBuffers while an artist page rotates —
|
||||
// revisit in the RAM pass if that bites on 8GB machines.
|
||||
const CATALOG_HERO_GPU_CACHE_MAX = 4;
|
||||
const CATALOG_HERO_PREVIEW_CACHE_MAX_CHARS = 12 * MIB;
|
||||
const CATALOG_HERO_ART_CACHE_MAX_CHARS = 32 * MIB;
|
||||
const CATALOG_HERO_GPU_CACHE_MAX_BYTES = 32 * MIB;
|
||||
const CATALOG_HERO_GPU_CACHE_MAX_ENTRY_BYTES = 24 * MIB;
|
||||
const CATALOG_HERO_GPU_CACHE_MAX_BYTES = 64 * MIB;
|
||||
const CATALOG_HERO_GPU_CACHE_MAX_ENTRY_BYTES = 32 * MIB;
|
||||
let catalogHeroArtEpoch = 0;
|
||||
let heroWarmMode = null;
|
||||
let heroWarmModeRequest = null;
|
||||
@@ -7252,12 +7259,36 @@ function writeSession() {
|
||||
} catch {
|
||||
// Quota, or a profile with storage disabled. The session is a
|
||||
// convenience; nothing downstream may depend on it having been written.
|
||||
// Still stamp the attempt: with the stamp only on success, a failing
|
||||
// setItem left `stale` permanently true and this whole function —
|
||||
// captureScroll's forced layout included — re-ran on every 3s tick.
|
||||
sessionWrittenAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
/** Cheap "has anything worth saving changed" probe, run on a timer rather than
|
||||
* from a reactive effect: the alternative is subscribing to a store that ticks
|
||||
* five times a second, and the only field that does is deliberately excluded. */
|
||||
let sessionWritePending = false;
|
||||
/** The tick's writes go through idle scheduling: writeSession's
|
||||
* captureScroll reads scrollTop — a forced style+layout while scroll-driven
|
||||
* animations hold style dirty (the exact read the artist page's scroll
|
||||
* machinery avoids) — so it must not land mid-scroll on a 3s clock.
|
||||
* Direct writeSession callers (unload-style flushes) are unaffected. */
|
||||
function scheduleSessionWrite() {
|
||||
if (sessionWritePending) return;
|
||||
sessionWritePending = true;
|
||||
const write = () => {
|
||||
sessionWritePending = false;
|
||||
writeSession();
|
||||
};
|
||||
if (typeof requestIdleCallback === "function") {
|
||||
requestIdleCallback(write, { timeout: 2000 });
|
||||
} else {
|
||||
setTimeout(write, 250);
|
||||
}
|
||||
}
|
||||
|
||||
function sessionTick() {
|
||||
if (sessionRestoring) return;
|
||||
const signature = [
|
||||
@@ -7265,10 +7296,14 @@ function sessionTick() {
|
||||
player.status, player.queueIndex, player.queue.length,
|
||||
player.shuffle, player.repeat, player.volume, player.muted,
|
||||
].join("|");
|
||||
const stale = player.status !== "idle" && Date.now() - sessionWrittenAt > SESSION_MAX_STALE_MS;
|
||||
// Periodic position refresh only while PLAYING: a paused track's position
|
||||
// does not move, and pausing itself flips the signature, so the paused
|
||||
// position is captured once and the 12s forced rewrite (scroll read +
|
||||
// full-queue stringify + sync localStorage) stops.
|
||||
const stale = player.status === "playing" && Date.now() - sessionWrittenAt > SESSION_MAX_STALE_MS;
|
||||
if (signature === sessionSignature && !stale) return;
|
||||
sessionSignature = signature;
|
||||
writeSession();
|
||||
scheduleSessionWrite();
|
||||
}
|
||||
|
||||
/** Re-source a track that was restored without its audio — an app restart,
|
||||
|
||||
@@ -107,6 +107,57 @@
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ── the dark-glass SURFACE LADDER (2026-08-27) ─────────────────────────────
|
||||
Everything above thins the window CANVAS. This block is about what stands
|
||||
ON the panes: cards, rows, chips, hover and active states — which were all
|
||||
still opaque and read as slabs floating on the frost (Kyle, 2026-08-27).
|
||||
|
||||
These are NEW names. The four solid tokens above are never redefined here,
|
||||
and the warning directly above is the reason: --bg-elevated/-highlight/
|
||||
-press/--border are consumed inside color-mix() and gradient stops (the
|
||||
library toolbar's `color-mix(… var(--bg-elevated) 82%, transparent)`, the
|
||||
album row's `linear-gradient(transparent, var(--bg-highlight) 14px)` text
|
||||
fade), where an alpha in the token multiplies through and destroys the call
|
||||
site — 0.74 × 0.03 = 0.022, an invisible chip. They also paint the modal
|
||||
and import-window chrome, which must stay opaque. Panes opt in one at a
|
||||
time; so do surfaces.
|
||||
|
||||
WHY WHITE. A pane is 0.35 black over the .app canvas's 0.55 — a ground of
|
||||
0.7075 black over the acrylic. Adding BLACK to something standing on that
|
||||
goes the wrong way: rgba(0,0,0,0.35) on a card inside .content nets
|
||||
0.35 + 0.7075×0.65 = 0.81, darker than its own pane and inside the ~0.85
|
||||
band already rejected once on the artist page (CatalogArtist.svelte, the
|
||||
sheet-tail note). Panes add black; surfaces inside panes add white.
|
||||
|
||||
The alphas are picked for STEP PARITY with solid mode, whose ladder is
|
||||
measured from --bg-base #121212 (value 18): elevated #181818 is +6,
|
||||
highlight #242424 is +18, press #2a2a2a is +24. Under glass the ground is
|
||||
a transmission, not a value — 0.2925 × acrylic — so a white veil at alpha
|
||||
`a` lifts it by a × (255 − ground). Solving for those same steps across a
|
||||
dark-to-bright acrylic (ground 12 → 59) gives 0.03 / 0.08 / 0.11, which is
|
||||
also, independently, the white-overlay vocabulary the newest glass chrome
|
||||
already ships (.btn-ghost:hover and .ax-ghost:hover are rgba(255,255,255,
|
||||
0.08); the bar hairlines are 0.05; Library's inset ring is 0.06).
|
||||
|
||||
--glass-well is the exception that proves the rule: a sunken INPUT still
|
||||
wants to read as a hole, so it adds a little black. Not much — a field
|
||||
inside a pane already stands on 0.7075, so the titlebar pill's 0.35 (which
|
||||
is correct there, on the 0.55 canvas) would net 0.81 here. 0.18 nets 0.76,
|
||||
a five-point sink, and the definition comes from --glass-hairline instead.
|
||||
|
||||
Usage is always additive and always paint-only:
|
||||
:global(:root.glass) .thing { background: var(--glass-surface); }
|
||||
:global(:root.glass) .thing:hover { background: var(--glass-hover); }
|
||||
Solid mode never sees any of it. */
|
||||
:root.glass {
|
||||
--glass-surface: rgba(255, 255, 255, 0.03);
|
||||
--glass-hover: rgba(255, 255, 255, 0.08);
|
||||
--glass-press: rgba(255, 255, 255, 0.11);
|
||||
--glass-hairline: rgba(255, 255, 255, 0.1);
|
||||
--glass-well: rgba(0, 0, 0, 0.18);
|
||||
--glass-ring: inset 0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
/* Floating overlay scrollbar (see src/lib/scrollbar.js). The native bar is
|
||||
hidden so it never reserves width or reflows content; a thin thumb floats
|
||||
in the container's right padding, fading in on scroll/hover, out when idle. */
|
||||
|
||||
@@ -1930,4 +1930,54 @@
|
||||
font-size: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── dark glass ────────────────────────────────────────────────────────
|
||||
Most of this page already speaks the white-veil vocabulary (.iconbtn,
|
||||
.pill.ghost, .gpill). The tracklist did not: hovering a row dropped an
|
||||
opaque #242424 band across the pane — the third of Kyle's screenshots.
|
||||
Ladder in global.css; solid mode untouched. */
|
||||
:global(:root.glass) .trow:hover,
|
||||
:global(:root.glass) .trow:focus-within,
|
||||
:global(:root.glass) .trow:has(:global(.plm-trigger[aria-expanded="true"])) {
|
||||
background: var(--glass-hover);
|
||||
}
|
||||
|
||||
/* The row-button shim is the subtle half of that change. In solid mode
|
||||
.rowbtns fades to the SAME --bg-highlight as the hover, so the cluster
|
||||
reads as "the buttons take over" while quietly occluding the last ~19px
|
||||
of the listeners bar behind them (measured: the bar's fill ends 15px
|
||||
inside .tcount, the buttons start 34px inside it). Nothing translucent
|
||||
can occlude, so under glass the bar gets out of the way instead: the
|
||||
shim stops painting and .tcount fades out just before the buttons
|
||||
begin. Offsets are from .tcount's RIGHT edge so they hold at any pane
|
||||
width — the button cluster is a fixed 96px and .tcount's right edge is
|
||||
70px from the row's, leaving the 34px start point. */
|
||||
:global(:root.glass) .rowbtns {
|
||||
background: none;
|
||||
}
|
||||
:global(:root.glass) .trow:hover .tcount,
|
||||
:global(:root.glass) .trow:focus-within .tcount {
|
||||
mask-image: linear-gradient(to right, #000 calc(100% - 48px), transparent calc(100% - 34px));
|
||||
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 48px), transparent calc(100% - 34px));
|
||||
}
|
||||
|
||||
:global(:root.glass) .pill:disabled {
|
||||
background: var(--glass-surface);
|
||||
}
|
||||
/* The artwork well, when a record has no cover at all. */
|
||||
:global(:root.glass) .cover.placeholder {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.11));
|
||||
}
|
||||
:global(:root.glass) .ed-art.placeholder {
|
||||
background: var(--glass-surface);
|
||||
}
|
||||
/* Loading skeletons, and the two hairline-bordered tags. --border is
|
||||
opaque #2a2a2a and reads as a drawn line on frost. */
|
||||
:global(:root.glass) .sk {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
:global(:root.glass) .tag-unreleased,
|
||||
:global(:root.glass) .stat-note {
|
||||
border-color: var(--glass-hairline);
|
||||
}
|
||||
</style>
|
||||
+191
-70
@@ -37,6 +37,7 @@
|
||||
setFollowed,
|
||||
announceFollow,
|
||||
} from "../lib/state.svelte.js";
|
||||
import { appScrollIdleFor } from "../lib/scrollbar.js";
|
||||
import * as api from "../lib/api.js";
|
||||
import { accentFromImage } from "../lib/color.js";
|
||||
import {
|
||||
@@ -252,7 +253,23 @@
|
||||
const im = new Image();
|
||||
im.onload = () => {
|
||||
if (!alive) return;
|
||||
const baked = bakeBlur(im, im.naturalWidth, im.naturalHeight, 8);
|
||||
// Display resolution, not naturalWidth: same reasoning as
|
||||
// twinScale — a 4K source baked at full res is a 100ms-class
|
||||
// blur for pixels CSS then throws away. Pre-scale into a
|
||||
// scratch canvas (bakeBlur's edge-strip slices read source
|
||||
// coordinates, so it must receive an already-scaled source)
|
||||
// and scale the radius to keep the displayed softness.
|
||||
const scale = Math.min(
|
||||
1,
|
||||
((heroWidth || window.innerWidth) * devicePixelRatio) / im.naturalWidth,
|
||||
);
|
||||
const sw = Math.max(1, Math.round(im.naturalWidth * scale));
|
||||
const sh = Math.max(1, Math.round(im.naturalHeight * scale));
|
||||
const scaled = document.createElement("canvas");
|
||||
scaled.width = sw;
|
||||
scaled.height = sh;
|
||||
scaled.getContext("2d").drawImage(im, 0, 0, sw, sh);
|
||||
const baked = bakeBlur(scaled, sw, sh, 8 * scale);
|
||||
canvas.width = baked.width;
|
||||
canvas.height = baked.height;
|
||||
canvas.getContext("2d").drawImage(baked, 0, 0);
|
||||
@@ -271,61 +288,89 @@
|
||||
// The seam tail's mirror needs the same snapshot on the GPU path (a
|
||||
// live canvas has no url for the tail's <img>).
|
||||
let tailMirrorBox = $state(null);
|
||||
/** The twins' backing resolution: the hero box's device pixels, never the
|
||||
* source's. Both twins are CSS-stretched to the box, so source pixels
|
||||
* beyond box×dpr are invisible — and baking the 4K source at full res
|
||||
* made snapshotGpuTwins a measured 100-170ms main-thread stall on a
|
||||
* 4-core iGPU (every rotation, every artist entry). The baked blur
|
||||
* radius scales by the same factor, so the displayed softness is
|
||||
* unchanged. */
|
||||
const twinScale = (src) =>
|
||||
Math.min(1, ((heroWidth || window.innerWidth) * devicePixelRatio) / src.width);
|
||||
let twinBakeGeneration = 0;
|
||||
function snapshotGpuTwins() {
|
||||
if (!gpuTwinBox && !tailMirrorBox) return;
|
||||
const gpu = document.querySelector(".artist-page .gpu-hero");
|
||||
const bannerSrc = gpu?.querySelector("canvas.banner.current");
|
||||
if (!bannerSrc || !bannerSrc.width) return;
|
||||
const dup = (src, cls) => {
|
||||
if (!src || !src.width) return null;
|
||||
const d = document.createElement("canvas");
|
||||
d.width = src.width;
|
||||
d.height = src.height;
|
||||
d.getContext("2d").drawImage(src, 0, 0);
|
||||
d.className = cls;
|
||||
return d;
|
||||
};
|
||||
if (gpuTwinBox) {
|
||||
// The twin's banner carries its 8px blur BAKED with clamped
|
||||
// edges (see bakeBlur) — aligned, fringe-free. Rotation
|
||||
// dissolve: same pattern as the tail below — the old baked
|
||||
// frame stays underneath while the new one fades in on the
|
||||
// banner's own curve (an instant replaceChildren here made the
|
||||
// mono overlay SNAP to the next photo while the real hero was
|
||||
// still mid-dissolve — Kyle caught it). Cap at two frames so
|
||||
// rapid re-readies can never stack.
|
||||
const bakedBanner = bakeBlur(bannerSrc, bannerSrc.width, bannerSrc.height, 8);
|
||||
bakedBanner.className = "gt-banner";
|
||||
const oldFrames = [...gpuTwinBox.children];
|
||||
if (oldFrames.length) bakedBanner.classList.add("incoming");
|
||||
gpuTwinBox.append(bakedBanner);
|
||||
while (gpuTwinBox.children.length > 2) {
|
||||
gpuTwinBox.firstChild.remove();
|
||||
// SAME-TASK BOUNDARY: only this one scaled copy reads the WebGL
|
||||
// drawing buffer (it does not survive the compositor pass — the
|
||||
// reason this function is called synchronously from gpuHeroReady).
|
||||
// Everything below works from the copy and is free to run later.
|
||||
const scale = twinScale(bannerSrc);
|
||||
const snap = document.createElement("canvas");
|
||||
snap.width = Math.max(1, Math.round(bannerSrc.width * scale));
|
||||
snap.height = Math.max(1, Math.round(bannerSrc.height * scale));
|
||||
snap.getContext("2d").drawImage(bannerSrc, 0, 0, snap.width, snap.height);
|
||||
const swapIn = (box, frame) => {
|
||||
const old = [...box.children];
|
||||
if (old.length) frame.classList.add("incoming");
|
||||
box.append(frame);
|
||||
// Cap at two frames so rapid re-readies can never stack.
|
||||
while (box.children.length > 2) {
|
||||
box.firstChild.remove();
|
||||
}
|
||||
if (oldFrames.length) {
|
||||
if (old.length) {
|
||||
setTimeout(() => {
|
||||
for (const el of oldFrames) el.remove();
|
||||
for (const el of old) el.remove();
|
||||
}, 1450);
|
||||
}
|
||||
}
|
||||
};
|
||||
// Tail mirror: the banner frame only, styled like the img variant.
|
||||
// Rotation dissolve: the old frame stays underneath while the new
|
||||
// one fades in on the banner's own curve, then the old is dropped.
|
||||
// Cap at two frames so rapid re-readies can never stack.
|
||||
if (tailMirrorBox) {
|
||||
const t = dup(bannerSrc, "tail-mirror-frame");
|
||||
if (t) {
|
||||
const old = [...tailMirrorBox.children];
|
||||
if (old.length) t.classList.add("incoming");
|
||||
tailMirrorBox.append(t);
|
||||
while (tailMirrorBox.children.length > 2) {
|
||||
tailMirrorBox.firstChild.remove();
|
||||
}
|
||||
if (old.length) {
|
||||
setTimeout(() => {
|
||||
for (const el of old) el.remove();
|
||||
}, 1450);
|
||||
const t = document.createElement("canvas");
|
||||
t.width = snap.width;
|
||||
t.height = snap.height;
|
||||
t.getContext("2d").drawImage(snap, 0, 0);
|
||||
t.className = "tail-mirror-frame";
|
||||
swapIn(tailMirrorBox, t);
|
||||
}
|
||||
// The twin's banner carries its 8px blur BAKED with clamped edges
|
||||
// (see bakeBlur) — aligned, fringe-free. Rotation dissolve: the old
|
||||
// baked frame stays underneath while the new one fades in on the
|
||||
// banner's own curve (an instant replaceChildren here made the mono
|
||||
// overlay SNAP to the next photo while the real hero was still
|
||||
// mid-dissolve — Kyle caught it). The bake runs off the ready task:
|
||||
// it needs only `snap`, and the outgoing baked frame covering the
|
||||
// box for one more beat of a 1400ms dissolve is invisible, while the
|
||||
// blur landing inside the ready task was the biggest slice of the
|
||||
// stall. The generation guard drops a pending bake when a newer
|
||||
// ready (or unmount) has superseded it.
|
||||
if (gpuTwinBox) {
|
||||
const generation = ++twinBakeGeneration;
|
||||
const bake = () => {
|
||||
if (generation !== twinBakeGeneration || !gpuTwinBox) return;
|
||||
// Same discipline as the periscope reclones: never bake
|
||||
// within 250ms of app-wide scroll, re-check INSIDE the idle
|
||||
// callback, and no forcing timeout — the outgoing baked
|
||||
// frame covering one more beat of the 1400ms dissolve is
|
||||
// invisible, while a bake forced mid-scroll is a dropped
|
||||
// frame. The generation guard bounds requeues: the next
|
||||
// ready supersedes a still-waiting bake.
|
||||
if (!appScrollIdleFor(250)) {
|
||||
setTimeout(bake, 250);
|
||||
return;
|
||||
}
|
||||
const bakedBanner = bakeBlur(snap, snap.width, snap.height, 8 * scale);
|
||||
bakedBanner.className = "gt-banner";
|
||||
swapIn(gpuTwinBox, bakedBanner);
|
||||
};
|
||||
if ("requestIdleCallback" in window) {
|
||||
requestIdleCallback(bake);
|
||||
} else {
|
||||
setTimeout(bake, 50);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -348,7 +393,14 @@
|
||||
const source = document.querySelector(".artist-page .gpu-hero canvas.banner.current");
|
||||
const baked = gpuTwinBox?.lastElementChild;
|
||||
if (!source || !source.width || !baked) return;
|
||||
if (baked.width === source.width && baked.height === source.height) return;
|
||||
// Compare against the SCALED target, not the source: the twins bake
|
||||
// at display resolution (see twinScale), so source dims would always
|
||||
// mismatch and re-bake on every resize-effect tick.
|
||||
const scale = twinScale(source);
|
||||
if (
|
||||
baked.width === Math.max(1, Math.round(source.width * scale))
|
||||
&& baked.height === Math.max(1, Math.round(source.height * scale))
|
||||
) return;
|
||||
snapshotGpuTwins();
|
||||
}
|
||||
|
||||
@@ -497,7 +549,17 @@
|
||||
const custom = page?.banner_custom;
|
||||
const visible = documentVisible;
|
||||
if (!visible || count < 2 || custom) return;
|
||||
const timer = setInterval(advanceHero, HERO_ROTATE_MS);
|
||||
const timer = setInterval(() => {
|
||||
// A rotation is pure decoration, but starting its pipeline mid-
|
||||
// gesture lands the cost mid-gesture: a cache-miss source is a
|
||||
// multi-MB raw-RGBA IPC arrival + full-res texture upload (a
|
||||
// measured ~150ms main-thread task on a 4-core iGPU) whose
|
||||
// promise resolves at an arbitrary moment relative to input.
|
||||
// Skip the tick while the app scrolled recently — the current
|
||||
// photo simply dwells one cycle longer.
|
||||
if (!appScrollIdleFor(600)) return;
|
||||
advanceHero();
|
||||
}, HERO_ROTATE_MS);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
|
||||
@@ -4586,9 +4648,12 @@
|
||||
|
||||
<!-- 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. -->
|
||||
REAL backdrop-filter here — no band cut, no periscope fog. What the
|
||||
contract actually needs is the BAR BAND: the gfade mask keeps the photo
|
||||
full under the bar at every pose, so the bar always samples paint
|
||||
(below the fade the glass page is deliberately bare pane frost now),
|
||||
and the fog was replacing the designed hero-under-glass continuity
|
||||
with a blurred imitation. -->
|
||||
<div
|
||||
class="artist-page"
|
||||
data-painted
|
||||
@@ -5759,9 +5824,9 @@
|
||||
same range) carrying the mask; the counter rides the inverse so the
|
||||
photo stays pinned. Photo-visible-fraction across the band therefore
|
||||
equals the base fade at every pixel BY CONSTRUCTION — the only
|
||||
intended difference is what the fade lands ON: the sheet's band is
|
||||
transparent under glass (the photo fades itself) and its tail is the
|
||||
frost tint, so the photo dissolves into desktop glow. Above the
|
||||
intended difference is what the fade lands ON: the sheet paints
|
||||
nothing under glass (the photo fades itself), so the photo dissolves
|
||||
straight into the pane frost, the app-wide ground. Above the
|
||||
band the mask is solid — the photo stays full under the bar at every
|
||||
pose, so the data-painted contract holds with no stand-in strip.
|
||||
Pre-measure, an unset --fog-rest resolves the mask solid via the
|
||||
@@ -5812,17 +5877,24 @@
|
||||
);
|
||||
}
|
||||
/* Under glass the sheet no longer buries the photo (the mask above
|
||||
does), so its band is fully transparent and its page below is the
|
||||
FROST TINT — the ground the fade lands on. Solid mode keeps the
|
||||
original gradient untouched. */
|
||||
:global(:root.glass) .sheet {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(18, 18, 18, 0) 0px,
|
||||
rgba(18, 18, 18, 0) var(--fog-span),
|
||||
rgba(18, 18, 18, 0.5) calc(var(--fog-span) * 1.15),
|
||||
rgba(18, 18, 18, 0.5) 100%
|
||||
);
|
||||
does) — the whole pin subtree is REMOVED from rendering: the fade
|
||||
lands directly on the pane frost (.content 0.35 over the .app canvas
|
||||
0.55), the one ground every other page sits on. The first cut gave
|
||||
the sheet a rgba(18,18,18,0.5) tail instead, and that read as two
|
||||
defects at once (Kyle, 2026-08-26): the 15%-of-span onset ramp was a
|
||||
visible line under the hero, and the stacked tint made the whole
|
||||
floor darker and grayer than the rest of the app (net ~0.85 gray vs
|
||||
the app-wide ~0.71 black — profile-rows measured the step). display
|
||||
on the PIN, not background on the sheet: a merely-unpainted sheet
|
||||
still kept its 300vh box composited and riding sheet-glide-ride (or
|
||||
the fallback p² calc) on every scrolled frame for nothing. Nothing
|
||||
reads this subtree from JS (audited exhaustively — every 'sheet' in
|
||||
the script is a comment) and the pins are height:0 marginless boxes,
|
||||
so removing the box moves no flow and shrinks no scroll range the
|
||||
sheetclip hadn't already fenced. Solid mode keeps the original
|
||||
gradient and glide untouched. */
|
||||
:global(:root.glass) .sheetpin {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* LAYER 3 — the content, over the sheet and the hero. Its one special
|
||||
@@ -6664,15 +6736,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes extras-fade-ride {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* The twins' crossfade — the ride that stands in for the retired mono
|
||||
filter rides: linear over the same 0..--travel range, so twin
|
||||
opacity ≡ p. */
|
||||
@@ -9377,4 +9440,62 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── dark glass: the surfaces standing on the frost ─────────────────────
|
||||
This page already carries the most glass work in the app, but all of it
|
||||
is about the HERO — the photo's dissolve, the sheet, the periscope band.
|
||||
Below the fade the page is bare pane frost (the note at the top of that
|
||||
section says so), and everything sitting on it was still opaque. Ladder
|
||||
in global.css; solid mode untouched.
|
||||
|
||||
No .rowbtns companion rule here, unlike CatalogAlbum: this page's rows
|
||||
have no fade shim to keep in step with the hover. */
|
||||
:global(:root.glass) .popular-row:hover,
|
||||
:global(:root.glass) .popular-row:has(:global(.plm-trigger[aria-expanded="true"])),
|
||||
:global(:root.glass) .trow:hover,
|
||||
:global(:root.glass) .trow:has(:global(.plm-trigger[aria-expanded="true"])),
|
||||
:global(:root.glass) .disco-all:hover,
|
||||
:global(:root.glass) .release:hover,
|
||||
:global(:root.glass) .release:focus-visible,
|
||||
:global(:root.glass) .release.active {
|
||||
background: var(--glass-hover);
|
||||
}
|
||||
:global(:root.glass) .pill.ghost,
|
||||
:global(:root.glass) .pill:disabled,
|
||||
:global(:root.glass) .pill:disabled:hover {
|
||||
background: var(--glass-surface);
|
||||
}
|
||||
:global(:root.glass) .pill.ghost:hover {
|
||||
background: var(--glass-hover);
|
||||
}
|
||||
:global(:root.glass) .rail-ed-art.placeholder {
|
||||
background: var(--glass-surface);
|
||||
}
|
||||
:global(:root.glass) .placeholder {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.11));
|
||||
}
|
||||
/* Hairlines: --border is opaque #2a2a2a and reads as a drawn grey line. */
|
||||
:global(:root.glass) .rail-stat-note,
|
||||
:global(:root.glass) .tag-unreleased {
|
||||
border-color: var(--glass-hairline);
|
||||
}
|
||||
/* The expander rail's legibility scrim, thinned for the same reason as the
|
||||
library's twin of it: a 0.92 tail on a now-translucent panel turns the
|
||||
rail's lower half into a near-opaque block inside a glass card. The glow
|
||||
it shades rests at 0.32 opacity, so this is still ample. */
|
||||
:global(:root.glass) .rail-shade {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
transparent 0%,
|
||||
rgba(24, 24, 24, 0.3) 55%,
|
||||
rgba(24, 24, 24, 0.55) 100%
|
||||
);
|
||||
}
|
||||
/* The expander and its pointer must carry the SAME value — the notch is a
|
||||
rotated square half-buried under the panel's edge, so any difference
|
||||
between them draws a triangle. (Same pairing as Library.svelte.) */
|
||||
:global(:root.glass) .xp-panel,
|
||||
:global(:root.glass) .notch {
|
||||
background: var(--glass-surface);
|
||||
}
|
||||
</style>
|
||||
@@ -1607,4 +1607,58 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ── dark glass ────────────────────────────────────────────────────────
|
||||
Ladder in global.css; solid mode untouched.
|
||||
|
||||
The pinned ambience shade lands on opaque --bg-base, which keeps the top
|
||||
440px of the page solid over the frost. Same answer as the album and
|
||||
playlist banners: dissolve the whole clipped layer — img and shade
|
||||
together — so it ends on frost at frost's own value. Repainting the
|
||||
landing colour thinner cannot work; a translucent tail never matches
|
||||
frost and draws a line at the box edge (Kyle, 2026-08-26). */
|
||||
:global(:root.glass) .pane-banner .clip {
|
||||
mask-image: linear-gradient(to bottom, #000 55%, transparent 98%);
|
||||
-webkit-mask-image: linear-gradient(to bottom, #000 55%, transparent 98%);
|
||||
}
|
||||
|
||||
/* The header card's tint, and ONLY when the artist has no picture — then
|
||||
.hero-tint is the first element child ({#if artist.picture}) and that
|
||||
0.9 black gradient is painting onto the pane instead of shading a photo.
|
||||
Same test and same inversion as Home and CatalogSearch. */
|
||||
:global(:root.glass) .shero .hero-tint:first-child {
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
rgba(255, 255, 255, 0.055) 28%,
|
||||
rgba(255, 255, 255, 0.02) 72%,
|
||||
rgba(255, 255, 255, 0.045)
|
||||
);
|
||||
}
|
||||
|
||||
:global(:root.glass) .rec .trow:hover,
|
||||
:global(:root.glass) .sortbtn:hover,
|
||||
:global(:root.glass) .year-rail button:hover {
|
||||
background: var(--glass-hover);
|
||||
}
|
||||
/* Unlike the album page, .rec .rowbtns has no fade shim to match — the
|
||||
cluster is absolutely positioned and simply appears — so the hover
|
||||
change needs no companion here. */
|
||||
|
||||
/* Its backdrop reaches the see-through pane, where the sample flattens to
|
||||
black (Sidebar.svelte holds the measurement). The white fill already
|
||||
carries the look. */
|
||||
:global(:root.glass) .rec .pill.ghost {
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
:global(:root.glass) .skel i {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.04),
|
||||
rgba(255, 255, 255, 0.1),
|
||||
rgba(255, 255, 255, 0.04)
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
/* Menus stay solid on purpose — see Playlists.svelte. */
|
||||
</style>
|
||||
@@ -2330,4 +2330,38 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── dark glass ────────────────────────────────────────────────────────
|
||||
Ladder in global.css. This page is mostly artwork already, so what was
|
||||
left opaque is the wells behind art that has not arrived — and the
|
||||
top-result hero on the days it has no picture to sit on.
|
||||
|
||||
The :first-child test is the same one Home.svelte uses: .hero-bg only
|
||||
renders when there IS artwork ({#if}), so a .hero-tint that is the
|
||||
first element child is a 0.9 black gradient painting straight onto the
|
||||
pane rather than a legibility scrim over a photo. Same diagonal, same
|
||||
three stops, inverted to white. With artwork it is untouched. */
|
||||
:global(:root.glass) .hero-tint:first-child {
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
rgba(255, 255, 255, 0.055) 28%,
|
||||
rgba(255, 255, 255, 0.02) 72%,
|
||||
rgba(255, 255, 255, 0.045)
|
||||
);
|
||||
}
|
||||
|
||||
:global(:root.glass) .cover.placeholder,
|
||||
:global(:root.glass) .card .placeholder,
|
||||
:global(:root.glass) .mini.placeholder,
|
||||
:global(:root.glass) .mini-art.placeholder {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.11));
|
||||
}
|
||||
/* The cover's own backing colour, seen through art that has transparency
|
||||
and while it decodes. */
|
||||
:global(:root.glass) .cover {
|
||||
background: var(--glass-surface);
|
||||
}
|
||||
:global(:root.glass) .own {
|
||||
background: var(--glass-press);
|
||||
}
|
||||
</style>
|
||||
@@ -325,4 +325,19 @@
|
||||
.row :global(.plm-trigger:hover) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── dark glass ────────────────────────────────────────────────────────
|
||||
Ladder in global.css. An active transfer row is a card on the pane; a
|
||||
finished one is bare until hovered, and keeps that shape. */
|
||||
:global(:root.glass) .row {
|
||||
background: var(--glass-surface);
|
||||
box-shadow: var(--glass-ring);
|
||||
}
|
||||
:global(:root.glass) .row.done-row {
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
:global(:root.glass) .row.done-row:hover {
|
||||
background: var(--glass-hover);
|
||||
}
|
||||
</style>
|
||||
@@ -1303,4 +1303,45 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
.cta:hover { background: var(--accent-hover); }
|
||||
|
||||
/* ── dark glass ────────────────────────────────────────────────────────
|
||||
Every surface below is opaque in solid mode and stays exactly that way;
|
||||
these are additive :root.glass rules only. Values come from the ladder
|
||||
in global.css — surfaces on a pane elevate with WHITE, because the pane
|
||||
is already 0.7075 black over the acrylic and more black would sink them
|
||||
under it. */
|
||||
|
||||
/* Kyle's screenshot: hovering a card dropped an opaque #181818 rectangle
|
||||
over the pane frost and the ambience wash. The base .release paints
|
||||
nothing at all, so this hover IS the card's only paint. */
|
||||
:global(:root.glass) .release:hover,
|
||||
:global(:root.glass) .release:focus-visible,
|
||||
:global(:root.glass) .ax-card:hover {
|
||||
background: var(--glass-hover);
|
||||
}
|
||||
|
||||
/* Artwork wells — what shows while the cover is still loading, or forever
|
||||
when there is none. A ring instead of a fill keeps the well readable as
|
||||
a frame rather than a hole. */
|
||||
:global(:root.glass) .ax-art,
|
||||
:global(:root.glass) .cover.placeholder {
|
||||
background: var(--glass-surface);
|
||||
box-shadow: var(--glass-ring);
|
||||
}
|
||||
|
||||
/* The greeting banner, and ONLY when it has no artwork behind it. With a
|
||||
lead release the tint sits on an opaque .feat-bg image and is doing
|
||||
legibility work — untouched. With none, .feat-tint is the first child
|
||||
(the {#if} renders no element) and that 0.9 black gradient is painting
|
||||
straight onto the pane: a black slab on the frost. Same diagonal, same
|
||||
three stops, inverted to white so the banner reads as a lifted glass
|
||||
panel instead of a hole. */
|
||||
:global(:root.glass) .feat-tint:first-child {
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
rgba(255, 255, 255, 0.055) 28%,
|
||||
rgba(255, 255, 255, 0.02) 72%,
|
||||
rgba(255, 255, 255, 0.045)
|
||||
);
|
||||
}
|
||||
</style>
|
||||
@@ -3558,4 +3558,98 @@
|
||||
}
|
||||
.lib-tool.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: #000; }
|
||||
.lib-tool.primary .lib-import-pct { background: rgba(0, 0, 0, 0.22); color: #000; }
|
||||
|
||||
/* ── dark glass ────────────────────────────────────────────────────────
|
||||
The ladder lives in global.css. Solid mode sees none of this.
|
||||
|
||||
The toolbar was the loudest surface on the page and it was loud for two
|
||||
compounding reasons, both measured on 2026-08-27 with a known-colour
|
||||
ground substituted behind the window (research/scroll-diag/glassshot.mjs):
|
||||
its own fill nets 0.82 + 0.7075x0.18 = 0.947 — a slab by alpha alone —
|
||||
AND its backdrop-filter samples the see-through pane, which the
|
||||
compositor flattens against black (the measurement lives in
|
||||
Sidebar.svelte). Chroma of the shipped bar, blue-minus-red over a
|
||||
#6b3fa8 ground: +2, i.e. dead. Fill alone got it to +12; dropping the
|
||||
filter as well got it to +16, against +30 for the bare pane beside it.
|
||||
Both halves were needed, so both are here. */
|
||||
:global(:root.glass) .lib-toolbar {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
/* Same story, smaller: the field and the tool buttons sit in that bar. */
|
||||
:global(:root.glass) .lib-search,
|
||||
:global(:root.glass) .lib-tool {
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
:global(:root.glass) .lib-search {
|
||||
background: var(--glass-well);
|
||||
border-color: var(--glass-hairline);
|
||||
}
|
||||
/* .lib-tool.primary — the Import button — keeps its accent fill: a filled
|
||||
accent button is meant to read as solid. :not(.primary) is what makes
|
||||
that true. `:root.glass .lib-tool` carries Svelte's scope hash and
|
||||
scores (0,4,0), which outranks `.lib-tool.primary` at (0,3,0), so
|
||||
without the exclusion this rule quietly repaints the accent button as a
|
||||
3%-white ghost. Verified by reading the computed style back. */
|
||||
:global(:root.glass) .lib-tool:not(.primary) {
|
||||
background: var(--glass-surface);
|
||||
border-color: var(--glass-hairline);
|
||||
}
|
||||
|
||||
/* Rows and cards on the pane. */
|
||||
:global(:root.glass) .lb-tr:not(.lb-th):hover,
|
||||
:global(:root.glass) .release:hover,
|
||||
:global(:root.glass) .release:focus-visible,
|
||||
:global(:root.glass) .lib-dir:hover,
|
||||
:global(:root.glass) .sortbtn:hover {
|
||||
background: var(--glass-hover);
|
||||
}
|
||||
/* Selected keeps its accent ring doing the work — only the fill under the
|
||||
ring changes, so the two states stay as far apart as they were. */
|
||||
:global(:root.glass) .release.active,
|
||||
:global(:root.glass) .lb-tr.active {
|
||||
background: var(--glass-hover);
|
||||
}
|
||||
|
||||
/* Artwork wells and the shelf avatars. */
|
||||
:global(:root.glass) .cover.placeholder,
|
||||
:global(:root.glass) .cover.gen-pending,
|
||||
:global(:root.glass) .lib-shelf-dot,
|
||||
:global(:root.glass) .lib-pick-none {
|
||||
background: var(--glass-surface);
|
||||
}
|
||||
|
||||
/* The album expander and the notch that points at its card. These two MUST
|
||||
carry the same value: the notch is a rotated square half-buried under the
|
||||
panel's top edge, so any difference between them draws a triangle. */
|
||||
:global(:root.glass) .xp-panel,
|
||||
:global(:root.glass) .notch {
|
||||
background: var(--glass-surface);
|
||||
}
|
||||
/* The rail's shade is a legibility scrim over the blurred cover behind the
|
||||
CTA column. Its solid-mode tail is rgba(24,24,24,0.92), which on a now
|
||||
translucent panel turns the rail's lower half into a near-opaque block
|
||||
inside an otherwise glass card — the stacked-tint failure the artist
|
||||
page already paid for once. Same shape, same colour, thinned so the
|
||||
panel keeps ONE value top to bottom; the glow it is shading rests at
|
||||
0.32 opacity, so this is still more than enough to read over. */
|
||||
:global(:root.glass) .rail-shade {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
transparent 0%,
|
||||
rgba(24, 24, 24, 0.3) 55%,
|
||||
rgba(24, 24, 24, 0.55) 100%
|
||||
);
|
||||
}
|
||||
:global(:root.glass) .pill.ghost,
|
||||
:global(:root.glass) .pill:disabled,
|
||||
:global(:root.glass) .rail-cta :global(.plm-trigger) {
|
||||
background: var(--glass-surface);
|
||||
}
|
||||
:global(:root.glass) .pill.ghost:hover,
|
||||
:global(:root.glass) .rail-cta :global(.plm-trigger:hover) {
|
||||
background: var(--glass-hover);
|
||||
}
|
||||
</style>
|
||||
@@ -1904,4 +1904,54 @@
|
||||
animation-duration: 1ms;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── dark glass ────────────────────────────────────────────────────────
|
||||
Ladder in global.css. Solid mode is untouched throughout.
|
||||
|
||||
The banner is the album page's problem, verbatim: its ::after scrim
|
||||
lands on opaque --bg-base, which keeps the whole hero zone solid over
|
||||
the frost, and a thinner landing COLOUR cannot fix it — a translucent
|
||||
tail can never match frost and draws a hard line at the box edge
|
||||
(CatalogAlbum.svelte carries Kyle's note from 2026-08-26). So the layer
|
||||
dissolves instead: img and scrim fade out together and end on frost at
|
||||
frost's own value. Same stops as the album banner, for the same reason. */
|
||||
: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%);
|
||||
}
|
||||
|
||||
/* Rows. .dragged keeps its lift by staying one rung ABOVE hover, which is
|
||||
the relationship it has in solid mode (--bg-press over --bg-highlight). */
|
||||
:global(:root.glass) .trow:hover,
|
||||
:global(:root.glass) .mp-row:hover {
|
||||
background: var(--glass-hover);
|
||||
}
|
||||
:global(:root.glass) .trow.dragged {
|
||||
background: var(--glass-press);
|
||||
}
|
||||
|
||||
/* Title and description editors, and the track filter: their backdrops
|
||||
reach the see-through pane, where a backdrop sample black-flattens
|
||||
(measured — Sidebar.svelte holds the numbers). The fills already carry
|
||||
the look on their own. */
|
||||
:global(:root.glass) .h1-input,
|
||||
:global(:root.glass) .desc-input,
|
||||
:global(:root.glass) .search {
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
:global(:root.glass) .bulkline {
|
||||
background: var(--glass-surface);
|
||||
border-color: var(--glass-hairline);
|
||||
}
|
||||
:global(:root.glass) .bulkline .bulkx:hover {
|
||||
background: var(--glass-hover);
|
||||
}
|
||||
:global(:root.glass) .pill:disabled,
|
||||
:global(:root.glass) .thumb-fallback {
|
||||
background: var(--glass-surface);
|
||||
}
|
||||
|
||||
/* Menus stay solid on purpose — see the note in Playlists.svelte. */
|
||||
</style>
|
||||
@@ -892,4 +892,43 @@
|
||||
color: var(--text-muted);
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
/* ── dark glass ────────────────────────────────────────────────────────
|
||||
Ladder in global.css. The shelf is nothing but cards, and every one was
|
||||
an opaque #181818 tile on the frost.
|
||||
|
||||
Menus are deliberately NOT in here. A popover has to stay readable over
|
||||
whatever it lands on, and the Settings copy for this mode now says so
|
||||
out loud: cards, rows and highlights thin out; menus and dialogs stay
|
||||
solid. The same rule holds for .menu in Playlist.svelte. */
|
||||
:global(:root.glass) .card {
|
||||
background: var(--glass-surface);
|
||||
box-shadow: var(--glass-ring);
|
||||
}
|
||||
:global(:root.glass) .card:hover {
|
||||
background: var(--glass-hover);
|
||||
}
|
||||
/* Same backdrop-over-frost flatten as the library's tools.
|
||||
:not(.primary) is load-bearing, not tidiness: `:root.glass .tool` plus
|
||||
Svelte's scope hash scores (0,4,0) against `.tool.primary`'s (0,3,0), so
|
||||
without it this rule outranks the accent fill and "New playlist" turns
|
||||
into a ghost button. Caught by measuring the live computed style —
|
||||
rgba(255,255,255,0.03) where rgb(188,107,46) belonged. */
|
||||
:global(:root.glass) .tool:not(.primary) {
|
||||
background: var(--glass-surface);
|
||||
border-color: var(--glass-hairline);
|
||||
}
|
||||
:global(:root.glass) .tool {
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
:global(:root.glass) .empty-icon {
|
||||
background: var(--glass-surface);
|
||||
box-shadow: var(--glass-ring);
|
||||
}
|
||||
/* --accent-soft is already translucent and composites correctly; only the
|
||||
--bg-press half of this gradient needed thinning. */
|
||||
:global(:root.glass) .art.fallback {
|
||||
background: linear-gradient(135deg, var(--accent-soft), rgba(255, 255, 255, 0.11) 72%);
|
||||
}
|
||||
</style>
|
||||
@@ -382,4 +382,27 @@
|
||||
color: var(--accent-hover);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── dark glass ────────────────────────────────────────────────────────
|
||||
Ladder in global.css. The query field is the page's whole first
|
||||
impression and was a 420px opaque grey lozenge on the frost. */
|
||||
:global(:root.glass) input {
|
||||
background: var(--glass-well);
|
||||
box-shadow: var(--glass-ring);
|
||||
}
|
||||
/* The focus ring is a 2px --text-primary halo in both modes and reads
|
||||
fine against the well; left alone deliberately. */
|
||||
|
||||
:global(:root.glass) .group-head:hover,
|
||||
:global(:root.glass) .file:hover {
|
||||
background: var(--glass-hover);
|
||||
}
|
||||
:global(:root.glass) .badge,
|
||||
:global(:root.glass) .dl-all.queued {
|
||||
background: var(--glass-surface);
|
||||
box-shadow: var(--glass-ring);
|
||||
}
|
||||
:global(:root.glass) .art-mini {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.11));
|
||||
}
|
||||
</style>
|
||||
@@ -294,7 +294,7 @@
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">Dark glass</div>
|
||||
<div class="hint">Lets whatever is behind the window glow through the app's dark surfaces with a live blur (Windows acrylic). Panels, menus and cards stay solid so nothing gets hard to read.</div>
|
||||
<div class="hint">Lets whatever is behind the window glow through the app's dark surfaces with a live blur (Windows acrylic). Cards, rows and highlights thin out with it; menus and dialogs stay solid so nothing gets hard to read.</div>
|
||||
</div>
|
||||
<select
|
||||
class="device-select"
|
||||
@@ -947,4 +947,46 @@
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ── dark glass ────────────────────────────────────────────────────────
|
||||
This page is almost entirely cards: twenty-odd .row blocks at opaque
|
||||
--bg-elevated, each with a --bg-base input or select sunk into it. Under
|
||||
glass that read as a column of black slabs stacked on the frost. The
|
||||
ladder lives in global.css; solid mode is untouched. */
|
||||
|
||||
/* Cards lift off the pane with white and a hairline, not with more black —
|
||||
black would net 0.81 against the pane's own 0.7075 and sink below it. */
|
||||
:global(:root.glass) .row,
|
||||
:global(:root.glass) .layout-row {
|
||||
background: var(--glass-surface);
|
||||
box-shadow: var(--glass-ring);
|
||||
}
|
||||
/* A watched-folder chip sits INSIDE a .row, so it takes the ring only —
|
||||
a second surface fill on top of the card's would read as a patch. */
|
||||
:global(:root.glass) .folder {
|
||||
background: none;
|
||||
box-shadow: var(--glass-ring);
|
||||
}
|
||||
|
||||
/* Controls sink INTO the card, which is the one place a little black is
|
||||
right. 0.18 nets ~0.76 — a five-point well; the edge does the defining,
|
||||
which is why the hairline comes with it. */
|
||||
:global(:root.glass) .device-select,
|
||||
:global(:root.glass) .layout-input {
|
||||
background: var(--glass-well);
|
||||
border-color: var(--glass-hairline);
|
||||
}
|
||||
/* The focus ring already switches to --text-secondary in both modes; it
|
||||
reads fine against the well and is deliberately left alone. */
|
||||
|
||||
:global(:root.glass) .btn.ghost {
|
||||
border-color: var(--glass-hairline);
|
||||
}
|
||||
:global(:root.glass) .stepper button {
|
||||
background: var(--glass-surface);
|
||||
box-shadow: var(--glass-ring);
|
||||
}
|
||||
:global(:root.glass) .stepper button:hover {
|
||||
background: var(--glass-hover);
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user