From cb899a29139da7a30b91e32138cc6a0b8087e403 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 24 Jun 2026 09:06:45 -0400 Subject: [PATCH 1/2] Anchor ambient visualizer to viewport bottom; occlude via z-index not clip Drop the --player-height bottom inset so the fixed visualizer fills the viewport; the inset player bar no longer leaves a page-background gap. The spacer now occludes via opaque page-surface + z-index. Visualizer no longer reads --player-height, so spacer.ts coalescing is removed. --- .../AudioPlayerBar/AudioPlayerBar.razor.cs | 7 +- .../Controls/WaveformVisualizer.razor.css | 21 ++--- .../Layout/MainLayout.razor.css | 8 +- DeepDrftPublic/Interop/layout/spacer.ts | 77 +++---------------- 4 files changed, 31 insertions(+), 82 deletions(-) diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs b/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs index 122e3d8..13cb7d2 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs @@ -36,10 +36,9 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable // error banner. // // _miniDock is the minimized FAB container. We observe it in minimized state so - // --player-height stays non-zero (the FAB's actual height) and the WaveformVisualizer - // clips to the top of the FAB rather than extending to the viewport bottom (fix §1). - // The player-spacer's .minimized class uses a hardcoded 60px and ignores the var, - // so publishing the FAB height here does not regress the spacer. + // --player-height stays non-zero (the FAB's actual height). The player-spacer's + // .minimized class uses a hardcoded 60px and ignores the var, so this is belt-and- + // braces; the var's sole live consumer is the spacer's .expanded height. private ElementReference _playerRoot; private ElementReference _miniDock; private ElementReference _lastObservedElement; diff --git a/DeepDrftPublic.Client/Controls/WaveformVisualizer.razor.css b/DeepDrftPublic.Client/Controls/WaveformVisualizer.razor.css index 6a5ff6e..0ffe3bd 100644 --- a/DeepDrftPublic.Client/Controls/WaveformVisualizer.razor.css +++ b/DeepDrftPublic.Client/Controls/WaveformVisualizer.razor.css @@ -1,19 +1,20 @@ /* Full-viewport fixed backdrop. Sits behind the detail content (.mix-detail-foreground is z-index:1) and never intercepts pointer events — except the zoom slider, which re-enables them on itself. - Footer clip (Phase 10 W1, spec §2c): the backdrop must stop cleanly ABOVE the audio player bar so - no lava/waveform pixel paints over or under it. `overflow: hidden` clips the canvas to this box, and - `bottom` is inset by `--player-height`, which AudioPlayerBar publishes on :root via its ResizeObserver - (Interop/layout/spacer.ts). The observer now points at whichever element is live: - expanded → the full player dock (tracks breakpoint reflow + error-banner growth) - minimized → the minimized-dock FAB container (~56–60 px) - so --player-height is always non-zero while the player is mounted and the clip line follows the bar in - BOTH states (fix §1 / p10-reframe-w1-fix). The 0px fallback keeps the backdrop full-height on any - page that does not host the player. */ + Anchored to the viewport bottom (`inset: 0` — fills the whole screen). The chrome that must occlude + it paints OVER it on z-index, not by clipping the box: the app bar (z 100), the docked player bar + (z 1200/1300), the site footer (z 1 stacking context), and the layout spacer (z 1 stacking context, + `--deepdrft-page-surface` background) all sit above this z-0 layer. Where those elements are inset + from the screen edges or scrolled below the fold, the visualizer fills the gap continuously — no + page-background strip around the inset player bar. `overflow: hidden` clips the canvas to this box. + + NOTE: this box is decoupled from `--player-height` (it no longer reads that var). The renderer's own + ResizeObserver therefore never fires on a player-bar height change, so an eased Theater-Mode collapse + can no longer clear the GL backing store mid-ease (the former theater-flash source). See + Interop/layout/spacer.ts. */ .mix-waveform-bg { position: fixed; inset: 0; - bottom: var(--player-height, 0px); z-index: 0; pointer-events: none; overflow: hidden; diff --git a/DeepDrftPublic.Client/Layout/MainLayout.razor.css b/DeepDrftPublic.Client/Layout/MainLayout.razor.css index 1273952..ad7d03a 100644 --- a/DeepDrftPublic.Client/Layout/MainLayout.razor.css +++ b/DeepDrftPublic.Client/Layout/MainLayout.razor.css @@ -1,7 +1,13 @@ -/* Spacer to prevent content overlap */ +/* Spacer to prevent content overlap. position:relative + z-index:1 establishes a stacking context + that paints above the WaveformVisualizer backdrop (fixed, z-index:0), and the opaque page-surface + background makes the spacer read as solid page — occluding the visualizer where it sits in flow, + the same way the app bar covers the top. Theme-aware alias, so it inverts for free. */ .player-spacer { width: 100%; flex-shrink: 0; + position: relative; + z-index: 1; + background: var(--deepdrft-page-surface); } .player-spacer.expanded { diff --git a/DeepDrftPublic/Interop/layout/spacer.ts b/DeepDrftPublic/Interop/layout/spacer.ts index 0a640f9..ee40e1e 100644 --- a/DeepDrftPublic/Interop/layout/spacer.ts +++ b/DeepDrftPublic/Interop/layout/spacer.ts @@ -11,44 +11,20 @@ * resets to 0 on `unobserve` (player minimized / disposed) so the spacer * collapses. * - * COALESCING (Phase 20 theater-flash fix). `--player-height` has two consumers: - * the layout spacer div AND the ambient WaveformVisualizer backdrop, whose - * `bottom` inset is this var (WaveformVisualizer.razor.css `.mix-waveform-bg`). - * Moving that inset changes the visualizer canvas's CSS box, which fires the - * renderer's own canvas ResizeObserver — and a GL resize CLEARS the backing - * store. That is correct and cheap for a discrete bar-height change (breakpoint - * reflow, minimize/expand, error banner). But Theater Mode eases the player bar's - * "now showing" band open/closed over ~0.45s via a CSS grid-rows transition, so - * the bar height changes EVERY FRAME of the ease. Mirroring each intermediate - * frame here would re-clear the GL backing store ~27×, reading as a flash. - * - * The fix coalesces the publish with a LEADING + TRAILING edge: the first change - * after a quiet period is written immediately (so a discrete jump — the common - * case — has zero added latency and the clip never lags), then a rapid STREAM of - * further changes (an animated transition) is debounced and only its SETTLED - * end-state is written. So a Theater ease resizes the visualizer at most twice - * (leading 1px move + final settle) instead of once per frame. The settled value - * is always the last write, so at-rest sizing/clip stays exact; and this remains - * the SOLE writer of `--player-height`, so the renderer's ResizeObserver stays the - * sole canvas size writer (its invariant is untouched). + * SOLE CONSUMER (post visualizer-viewport-framing). `--player-height` now feeds + * ONLY the layout spacer div (MainLayout.razor.css `.player-spacer.expanded`). + * The ambient WaveformVisualizer backdrop is anchored `inset: 0` and no longer + * reads this var, so a height change here only resizes the spacer's `height` — a + * cheap, side-effect-free layout write. There is no GL backing store to clear and + * no theater-flash to debounce against, so we publish every observed frame + * directly: the spacer tracks the bar exactly through the Theater-Mode ease with + * no settle lag, and this stays the SOLE writer of `--player-height`. */ const HEIGHT_VAR = '--player-height'; -/** - * Quiet window (ms) after which a pending settled height is flushed. One change - * then silence (a discrete reflow) flushes after this delay but was ALSO written - * on the leading edge, so the trailing flush is a no-op — discrete jumps pay no - * latency. A continuous transition keeps resetting this timer until it ends, then - * flushes the final height once. ~80ms comfortably exceeds a frame interval (so a - * mid-ease frame never trips an early flush) yet settles promptly after the ease. - */ -const SETTLE_MS = 80; - let observer: ResizeObserver | null = null; let lastWritten = -1; -let pendingHeight = -1; -let settleTimer: number | null = null; function setVar(px: number): void { // Round up so sub-pixel heights never leave a hairline of overlap. @@ -58,28 +34,6 @@ function setVar(px: number): void { document.documentElement.style.setProperty(HEIGHT_VAR, `${rounded}px`); } -/** - * Publish a measured height with leading + trailing coalescing. Leading: if no - * settle is pending, this is the first change after a quiet period — write it now. - * Trailing: (re)arm the settle timer so the final value of a rapid stream lands - * once the stream stops. - */ -function publishHeight(px: number): void { - pendingHeight = px; - if (settleTimer === null) { - // Leading edge — discrete jumps land immediately; the first frame of a - // transition lands too (one resize), then the rest is debounced below. - setVar(px); - } - if (settleTimer !== null) { - clearTimeout(settleTimer); - } - settleTimer = window.setTimeout(() => { - settleTimer = null; - setVar(pendingHeight); - }, SETTLE_MS); -} - function measure(entry: ResizeObserverEntry): number { // Prefer the border-box measurement; fall back to contentRect on the // (older) engines that don't populate borderBoxSize. @@ -94,28 +48,17 @@ export function observe(element: Element): void { observer = new ResizeObserver(entries => { const entry = entries[0]; if (!entry) return; - publishHeight(measure(entry)); + setVar(measure(entry)); }); observer.observe(element); // Seed synchronously so the spacer is correct on this frame, before the - // first ResizeObserver callback fires. A fresh observe target is a discrete - // change, so write it straight through (bypassing the debounce) — re-pointing - // the observer (e.g. expanded <-> minimized) must not lag behind a settle. - if (settleTimer !== null) { - clearTimeout(settleTimer); - settleTimer = null; - } + // first ResizeObserver callback fires. setVar(element.getBoundingClientRect().height); } export function unobserve(): void { observer?.disconnect(); observer = null; - if (settleTimer !== null) { - clearTimeout(settleTimer); - settleTimer = null; - } - pendingHeight = -1; setVar(0); } From adbd376d4271cf56b37cc1a8c7e1d3f7d5655fc2 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 24 Jun 2026 10:40:52 -0400 Subject: [PATCH 2/2] Fix stale spacer-observe comment: drop visualizer/clipping ref, name spacer as sole consumer --- .../Controls/AudioPlayerBar/AudioPlayerBar.razor.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs b/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs index 13cb7d2..9acf74b 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs @@ -246,13 +246,15 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable } // For the docked player: we observe in BOTH expanded and minimized states - // so --player-height always reflects the live height of whichever element - // is visible. This keeps the WaveformVisualizer clipped to the top of - // the footer in both states (fix §1). + // so --player-height stays non-zero and always reflects the live height of + // whichever element is visible. The var's sole live consumer is the + // player-spacer's .expanded height (keeps the spacer sized correctly across + // breakpoints and banner reflows). // expanded → observe _playerRoot (full player bar, reflows across breakpoints) // minimized → observe _miniDock (floating FAB container, ~56–60px) - // The player-spacer's .minimized class uses a hardcoded height and ignores - // the var, so publishing the FAB height here does not regress the spacer. + // The player-spacer's .minimized class uses a hardcoded 60px and ignores + // the var, so observing in minimized state is belt-and-braces; it does not + // regress the spacer. var elementToObserve = _isMinimized ? _miniDock : _playerRoot; var alreadyOnThisElement = _spacerObserved && elementToObserve.Id == _lastObservedElement.Id;