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:
1 parent
225c9139aa
commit
6ba1864498
16 files changed
+821
-117
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user