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