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:
KeelrLeek committed 2026-08-27 17:24:20 -05:00
1 parent 7ab678fd22
commit 547cee6649
26 files changed
+1220 -144

No files matched your search

+50
View File
@@ -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
View File
@@ -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>
+54
View File
@@ -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>
+34
View File
@@ -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>
+15
View File
@@ -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>
+41
View File
@@ -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>
+94
View File
@@ -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>
+50
View File
@@ -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>
+39
View File
@@ -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>
+23
View File
@@ -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>
+43 -1
View File
@@ -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>