dark glass, the whole app: frost on every pane, and the periscope pattern for every glass bar the frost sits under - backdrop-filter black-flattens over see-through ground (measured 3-vs-40), so bars over frost blur a scroll-synced clone behind a static band cut instead; the bar moved out of the scroller (pane shell, inner .content keeping the closest-contract, the old negative-margin flow replayed by a spacer after the artist page's sticky physics named the scrollport padding sacred); painted pages declare data-painted and keep the real backdrop bar, which is how the artist hero runs under the glass again; np wash rides the scroll away and dissolves to frost like the album banner; perf measured to vsync - 142px blur wraps (27.9ms frames down to 6.9), idle scroll-gated reclones, clip-path band, content-visibility on library rows, async cover decode; mini frosts over the mini:glass channel and the rest-frame flags ride window config because the env-var lane dies on some webview2 runtimes

This commit is contained in:
KeelrLeek committed 2026-08-26 14:55:25 -05:00
1 parent 225c9139aa
commit 6ba1864498
16 files changed
+821 -117

No files matched your search

+162 -1
View File
@@ -18,6 +18,121 @@
import * as api from "../lib/api.js";
import Icon from "./Icon.svelte";
// ── the periscope (dark glass only) ─────────────────────────────────
// The bar's backdrop-filter black-flattens over frost (see the CSS note),
// so under glass the bar blurs a CLONE of the list instead: the original
// rows are masked out of the bar band, and a cloneNode copy — filtered
// with a normal blur, which composites fine over frost — paints them
// back as fog, scroll-synced. Approved from the side-by-side test page
// (research/glass-periscope) 2026-08-26.
let glassy = $state(document.documentElement.classList.contains("glass"));
let viewEl = $state(null);
let periEl = $state(null);
$effect(() => {
const mo = new MutationObserver(() => {
glassy = document.documentElement.classList.contains("glass");
});
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
return () => mo.disconnect();
});
$effect(() => {
if (!glassy || !viewEl || !periEl) return;
const view = viewEl;
const peri = periEl;
let pending = 0;
let lastScroll = 0;
let lastNodes = 500;
const sync = () => {
lastScroll = performance.now();
const c = peri.firstElementChild?.firstElementChild;
if (c) c.style.transform = `translateY(${-view.scrollTop}px)`;
};
const reclone = () => {
// Blur on an OVERSCAN wrapper, 48px past the clip on every side:
// a gaussian fades toward transparent at its element's bounds,
// and with the bounds out there the falloff is clipped away
// instead of vignetting the band edges (found on the np head —
// same physics here). The clone keeps the scoping class (every
// scoped rule, collapsed mode included, styles it identically)
// and is pinned inside by margins at its measured width.
// height 142 = overscan + band + overscan: the filter rasters
// this box, never the full cloned list.
const wrap = document.createElement("div");
wrap.style.cssText =
"position:absolute;top:-48px;left:-48px;right:-48px;height:142px;" +
"overflow:hidden;filter:blur(17px) saturate(1.3);will-change:transform;" +
"pointer-events:none;";
const c = view.cloneNode(true);
c.style.position = "relative";
c.style.margin = "48px 0 0 48px";
c.style.width = view.clientWidth + "px";
c.style.height = "auto";
c.style.overflow = "visible";
c.querySelectorAll("canvas").forEach((n) => n.remove());
lastNodes = c.querySelectorAll("*").length;
wrap.appendChild(c);
const fresh = !peri.firstElementChild;
if (fresh) {
wrap.style.opacity = "0";
wrap.style.transition = "opacity 160ms ease";
}
peri.replaceChildren(wrap);
if (fresh) requestAnimationFrame(() => (wrap.style.opacity = "1"));
sync();
};
// Trailing debounce; no `class` in the filter — hover flips were
// re-cloning per mouse move, and the fog doesn't read hover states.
// Delay scales with list size and the clone runs at idle (see the
// GlassBar note: big pages were janking frames with reclone tasks).
const queueReclone = () => {
clearTimeout(pending);
const delay = Math.min(600, 120 + lastNodes / 40);
pending = setTimeout(() => {
// Never clone mid-scroll, never while hidden (see GlassBar).
if (document.hidden) return;
if (performance.now() - lastScroll < 250) return queueReclone();
if ("requestIdleCallback" in window) {
requestIdleCallback(() => reclone(), { timeout: 800 });
} else {
reclone();
}
}, delay);
};
const mo = new MutationObserver((records) => {
for (const r of records) {
if (r.target === view && (r.addedNodes.length || r.removedNodes.length)) {
peri.replaceChildren();
break;
}
}
queueReclone();
});
mo.observe(view, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["src"],
});
const ro = new ResizeObserver(queueReclone);
ro.observe(view);
const onVis = () => {
if (!document.hidden) queueReclone();
};
document.addEventListener("visibilitychange", onVis);
view.addEventListener("scroll", sync, { passive: true });
reclone();
return () => {
mo.disconnect();
ro.disconnect();
document.removeEventListener("visibilitychange", onVis);
view.removeEventListener("scroll", sync);
clearTimeout(pending);
peri.replaceChildren();
};
});
// Through goToView, not a bare `ui.view = …`: the glass bar's history and
// trail are fed by recorded visits, and a destination that skips the
// recorder is one the back arrow can never return to.
@@ -160,6 +275,9 @@
<nav class="sidebar" class:collapsed={ui.sidebarCollapsed}>
<!-- One element, outside the list, so it never fades with the content. -->
<div class="gbar">
{#if glassy}
<div class="peri" bind:this={periEl} aria-hidden="true"></div>
{/if}
<button
class="status"
data-state={app.connection}
@@ -207,7 +325,7 @@
any difference between the copies reads as doubling. Here nothing fades
but text, and no glyph can move — every x comes from padding, which
does not depend on the panel's width. -->
<div class="view">
<div class="view" bind:this={viewEl}>
{#each items as item (item.id)}
<button
class="nav-item"
@@ -299,6 +417,47 @@
overflow: hidden;
}
/* ── dark glass: frost + the periscope bar ──────────────────────────
The pane frosts (0.35 over the .app canvas tint). The bar CANNOT use
backdrop-filter here: over see-through frost the compositor flattens
the sampled backdrop against opaque black and the bar renders as a
slab (measured 2026-08-26: band 3 vs pane 40). Instead, under glass:
the original rows are masked out of the bar band, and the .peri box
inside the bar shows a scroll-synced CLONE of the list under a
normal filter blur — normal filters composite correctly over frost
(the np wash proves it daily). Numbers approved on the test page
(research/glass-periscope): frost 0.35, tint 0.28, blur 17, sat 1.3.
Solid mode: every rule below is inert, original recipe throughout. */
:global(:root.glass) .sidebar {
background: rgba(0, 0, 0, 0.35);
}
:global(:root.glass) .gbar {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
/* Direct child only — the periscope clone is inside .peri and must
never inherit this band cut (it would blank the fog). clip-path,
not mask: rectangle clips ride the compositor's fast path (see
App.svelte's band-cut note). */
:global(:root.glass) .sidebar > .view {
clip-path: inset(46px 0 0 0);
}
.peri {
position: absolute;
inset: 0;
overflow: hidden;
z-index: 0;
pointer-events: none;
}
/* The clone's filter and geometry are inline on its overscan wrapper —
built in the reclone() above; the band mask can't reach the clone
(direct-child selector), so no cancel rule is needed here. */
.gbar .status {
position: relative;
z-index: 1;
}
/* ── the glass bar ── */
.gbar {
position: absolute;
@@ -316,6 +475,8 @@
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.05);
}
.gicon {
position: absolute;
right: 18px;