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
+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>
|
||||
Reference in new issue
Block a user