diff --git a/src/App.svelte b/src/App.svelte index dc9fcdb..9db9ccf 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -309,6 +309,11 @@ artwork behind the bar (search) has to start below it, or its first heading sits under the frost at rest. */ --bar-h: 46px; + /* Publishes the scroller's timeline to the glass bar's fog, which + is a SIBLING of the scroller — the fog's ride animates against + this instead of chasing scroll events on the main thread (the + chase lagged visibly on the 60Hz laptop; see GlassBar). */ + timeline-scope: --pane-scroll; } .content { @@ -323,6 +328,11 @@ away with the content, keeping the seam colors identical at every scroll position. */ background-attachment: local; + /* The named timeline the fog rides (scope on .pane-shell above). + The artist page overwrites this with its own INLINE + scroll-timeline for its hero rides — that page is painted, has + no fog, and needs no pane timeline. */ + scroll-timeline: --pane-scroll block; overflow-y: auto; overflow-anchor: none; /* Main scroll pane: no elastic rubber-band at its edges. Inner regions diff --git a/src/components/GlassBar.svelte b/src/components/GlassBar.svelte index e554cb9..5fc4d66 100644 --- a/src/components/GlassBar.svelte +++ b/src/components/GlassBar.svelte @@ -61,6 +61,9 @@ let pending = 0; let recloneGen = 0; let lastRecloneMs = 4; + // Consecutive idle callbacks refused for a too-thin slice — see the + // bounded gate in queueReclone. + let thinSlices = 0; // 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 @@ -139,6 +142,21 @@ // 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"); + // The fog rides the SCROLLER'S timeline on the compositor + // (--pane-scroll, published by .pane-shell): progress is + // scrollTop/range and the keyframe span is -range, so the + // ride equals -scrollTop at every point with the main thread + // nowhere in the loop — sync()'s per-event transform chased + // scroll a few frames late on the 60Hz laptop (Kyle, + // 2026-08-30: "the content behind the glass bar is delayed"). + // sync() stays as the fallback: a running animation outranks + // its inline transform, and without timeline support the + // inline write is what remains. + c.style.setProperty( + "--peri-range", + Math.max(0, view.scrollHeight - view.clientHeight) + "px", + ); + c.classList.add("peri-ride"); // Canvases clone BLANK — dead weight in the fog. c.querySelectorAll("canvas").forEach((n) => n.remove()); const banner = c.querySelector(".banner"); @@ -196,12 +214,23 @@ // 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 (!appScrollIdleFor(1000)) return queueReclone(); if ("requestIdleCallback" in window) { requestIdleCallback((deadline) => { if (!appScrollIdleFor(1000)) return queueReclone(); - if (deadline && deadline.timeRemaining() < 15) return queueReclone(); + // The thin-slice skip is BOUNDED. With any frame + // continuously pending, Chromium caps timeRemaining + // at the frame budget minus rendering — measured + // 2026-08-30 on the 60Hz laptop: ~13.5ms, hundreds + // of callbacks in a row — so the old hard <15 gate + // never passed and the fog NEVER built: the band + // rested as a thin veil over the raw page, the + // "lighter pane" Kyle reported. Eight skips is + // ~1-2s of honest waiting; after that, one possibly- + // tight build beats no fog for the whole session. + if (deadline && deadline.timeRemaining() < 15 && ++thinSlices < 8) { + return queueReclone(); + } + thinSlices = 0; reclone(); }); } else { @@ -393,6 +422,42 @@ :global(:root.glass) .glassbar { backdrop-filter: none; -webkit-backdrop-filter: none; + /* THE PANES' OWN FROST, exactly: pure black 0.35 over the 0.55 + canvas nets the same 0.7075 the sidebar, content pane and Now + Playing stand on — same acrylic transmission, and none of the + solid-mode tint's self-light (20,20,24 at 0.28 adds ~6 luma of + its own, measured on-screen 2026-08-30, band 26 vs rail 32). + The band is the same material as the panes by construction; + brightness then follows the wallpaper for both alike. */ + background: rgba(0, 0, 0, 0.35); + } + /* Match the SIDE PANES' brightness by matching their TRANSPARENCY, not + by painting over it. On screen every pane glows with the acrylic + behind it (~0.29 of the desktop through the 0.7075 frost) — a dark + veil over the band blocks that glow and reads as a black slab beside + lit panes (Kyle, 2026-08-30, tried and rejected the same day; CDP + captures cannot see the acrylic, which is how it shipped). So the + band keeps the panes' own alpha stack — tint 0.28 over the canvas — + and the FOG CONTENT is dimmed instead: the album banner bleeding up + bright was the only thing separating this band from the sidebar's, + whose fogged rows are already dark. The ghost of the page stays, + the wallpaper glow stays, the brightness difference goes. */ + /* The fog at full strength with the PANES' FROST veiled over it — + the same stack the sidebar and Now Playing bands wear (blurred + content under their bar tint), which is what makes all three read + as one glass while still ghosting whatever slides beneath (Kyle, + 2026-08-30: "the glass bar does not blur the content behind it + like the left and right sidebar"). The veil must be a ::after — + the bar's own background paints UNDER .peri and cannot tint the + fog (measured: an 0.85 background moved the band 34.6 -> 33.5). + Pure black 0.35, the tint Kyle approved: no self-light, and over + the 0.55 canvas it nets the panes' own 0.7075 wherever the fog is + translucent or still building. */ + :global(:root.glass .pane-shell:not(.painted-page)) .glassbar .peri::after { + content: ""; + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.35); } /* Painted pages ground the glass themselves — the original recipe is physically safe there and IS the designed hero-under-glass look. */ @@ -407,6 +472,33 @@ z-index: 0; pointer-events: none; } + /* The fog's compositor ride lives in global.css (.peri-ride): the + clone is built by JS with no scope hash, and Svelte's -global- + keyframes prefix-stripping desynced the animation-name from the + @keyframes on the first attempt — verified dead via getAnimations(). + Plain CSS cannot be mangled. */ + /* The page's colour wash rides the fog at (near) full strength. The + veil above dims the whole fog to 0.65, which turned "the bar frosts + over colour rather than over black" (the wash's own contract, see + CatalogSearch) into a dark step right where the wash met the band + (Kyle, 2026-08-30). Boosting only the CLONED wash restores its + effective brightness through the veil — 0.92 × 0.65 lands on the + wash's own 0.6 — while rows and art scrolling under the bar keep + the veil's full dimming. The real .wash is not inside .peri, so + this cannot touch the page itself. */ + .peri :global(.wash) { + opacity: 0.92; + /* Bled into the fog's 48px overscan on the three closed sides — + the wash starts exactly at the pane top, so the blur kernel at + the band's top edge sampled past it into nothing and faded the + colour out over the first ~2×17px: a dark band at the very top + of the bar (Kyle, 2026-08-30). The album banner never showed + this because its own box bleeds -70px above the page. The + gradient shift from starting 48px higher is ~3% — invisible. */ + top: -48px; + left: -48px; + right: -48px; + } /* No animations inside the fog: cloned scroll-driven rides can never advance (their timeline source is the real scroller, not an ancestor here) and would hold empty from-poses. Base styles are the sensible diff --git a/src/components/NowPlaying.svelte b/src/components/NowPlaying.svelte index 8c18e60..27f91c6 100644 --- a/src/components/NowPlaying.svelte +++ b/src/components/NowPlaying.svelte @@ -355,6 +355,9 @@ let pending = 0; let recloneGen = 0; let lastRecloneMs = 4; + // Consecutive idle callbacks refused for a too-thin slice — see the + // bounded gate in queueReclone. + let thinSlices = 0; const sync = () => { markAppScroll(); const c = peri.firstElementChild?.firstElementChild; @@ -439,7 +442,15 @@ if ("requestIdleCallback" in window) { requestIdleCallback((deadline) => { if (!appScrollIdleFor(1000)) return queueReclone(); - if (deadline && deadline.timeRemaining() < 15) return queueReclone(); + // Bounded, same fix as GlassBar (2026-08-30): with a + // frame continuously pending, this machine's idle + // slices cap at ~13.5ms and a hard <15 never passes — + // this pane's fog died at the first content churn + // (every track change) and never rebuilt. + if (deadline && deadline.timeRemaining() < 15 && ++thinSlices < 8) { + return queueReclone(); + } + thinSlices = 0; reclone(); }); } else { diff --git a/src/components/Sidebar.svelte b/src/components/Sidebar.svelte index 6ff0c0b..3ba2548 100644 --- a/src/components/Sidebar.svelte +++ b/src/components/Sidebar.svelte @@ -45,6 +45,9 @@ let pending = 0; let recloneGen = 0; let lastRecloneMs = 4; + // Consecutive idle callbacks refused for a too-thin slice — see the + // bounded gate in queueReclone. + let thinSlices = 0; const sync = () => { markAppScroll(); const c = peri.firstElementChild?.firstElementChild; @@ -114,7 +117,15 @@ if ("requestIdleCallback" in window) { requestIdleCallback((deadline) => { if (!appScrollIdleFor(1000)) return queueReclone(); - if (deadline && deadline.timeRemaining() < 15) return queueReclone(); + // Bounded, same fix as GlassBar (2026-08-30): with a + // frame continuously pending, this machine's idle + // slices cap at ~13.5ms and a hard <15 never passes — + // this pane's fog died at the first content churn + // (every track change) and never rebuilt. + if (deadline && deadline.timeRemaining() < 15 && ++thinSlices < 8) { + return queueReclone(); + } + thinSlices = 0; reclone(); }); } else { diff --git a/src/styles/global.css b/src/styles/global.css index 673c2a9..576b9fd 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -284,3 +284,22 @@ input { ::-webkit-scrollbar-track { background: transparent; } + +/* The glass bar fog's compositor ride (built by GlassBar.svelte's reclone, + which sets --peri-range to the scroller's scroll range at clone time). + Progress on --pane-scroll (published by .pane-shell) times -range equals + -scrollTop exactly, with no main-thread chase — the scroll-event sync in + GlassBar lagged visibly on the 60Hz laptop and stays only as the + no-timeline-support fallback (a running animation outranks its inline + transform). Plain CSS on purpose: Svelte's -global- keyframes prefix + stripping desynced name from reference when this lived in the component + (2026-08-30, verified dead via getAnimations). */ +.peri-ride { + animation: peri-ride linear both; + animation-timeline: --pane-scroll; +} +@keyframes peri-ride { + to { + transform: translateY(calc(-1 * var(--peri-range))); + } +}