openvibers/openvibe.live Commits

Hero render cost: no backdrop blur over moving content, a flat collage, and no idle DOM churn

OP OpenVibers committed 16 Sept 2026 · 3cd0120779 · 5 files, +189 −47

Reported as laggy even on desktop and hard to scroll past on a phone. Measured on production,
idle at the top of the home page for ten seconds:

  desktop: 8 long tasks totalling 4,013ms (worst 1,547ms), 144 composited layers = 46MP on a
           1.2MP viewport; the hero alone held 52 backdrop-filters, 18 filters, 41 will-change
  phone (4x CPU): 29 long tasks totalling 6,616ms — the main thread blocked 66% of the time

GPU side — each of these was a per-frame cost for a look that can be drawn once:
- Backdrop blur on the stat board, all 24 stat tiles, the "shipped" pill and the collage badges.
  Behind them sat a Ken Burns background and twelve drifting cards, so every blur re-sampled and
  re-blurred every frame. Solid translucent fills over an already-darkened background.
- The collage was a preserve-3d scene with translateZ depth and a per-card blur filter. Now flat:
  2D drift, depth from the size and opacity the cards already had, no per-card filter.
- brightness/saturate filters on the 1.5MP background frame that was scaling, and a 32px blur with
  lighten blending on the 1.35MP aurora. A dark overlay and wider gradient falloff instead; only
  the frame on screen is promoted to a layer.
- The headline pulsed through filter:brightness and filter:drop-shadow. Steady glow instead.
- The pointer spotlight moved the centre of a gradient spanning the whole hero via custom
  properties — a full-hero repaint per mouse move. Now a fixed disc moved by transform.
- Mouse parallax wrote --tilt-x/--tilt-y on the collage; custom properties inherit, so every move
  restyled every card. It writes a 2D transform directly, reading layout once per frame.

Main-thread side — 420 DOM mutations in ten idle seconds, each batch feeding observers:
- The page's reveal pass re-queried the whole home page on every mutation and called
  getBoundingClientRect on rebuilt cards, forcing a full layout (~150ms each time). It now only
  looks at added nodes, and lets IntersectionObserver report whether they are on screen.
- The count-up observer re-queried the stat board once per odometer digit roll; now once per frame,
  and only when nodes were added.
- The live-stats loop replaced two delta pills every ten seconds whether or not they changed.
- The per-row density control re-rendered on every grid height change (images loading, skeletons
  resolving) and rewrote its buttons; it now renders only when the width changes.
- The card tilt handler read layout on every pointer event; now once per frame, home page only.
- Collage swaps are 8s instead of 5.6s and skip while the hero is off screen or the page is hidden.

Phones: nothing in the hero animates on its own (no drift, no Ken Burns, no aurora motion, no
spotlight or sparks), since this region is scrolled past far more than it is watched.

Also: "API docs" beside "Build & Mod" only shows when the buttons are wide enough for it.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMxJm1dB8rdsaMSgNjVa82

Changes

ddiff --git a/public/css/home-fx.css b/public/css/home-fx.css
iindex c7aad102..04d504dc 100644
---- a/public/css/home-fx.css
++++ b/public/css/home-fx.css
@@@ -4,10 +4,13 @@
prefers-reduced-motion.
═══════════════════════════════════════════════════════════════ */
#page-home .hero { position: relative; }
--.hfx-sparks { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 0; mix-blend-mode: screen; opacity: 0.9; }
++.hfx-sparks { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 0; opacity: 0.9; }
.hfx-spot {
-- position: absolute; inset: 0; pointer-events: none; z-index: 0; opacity: 0; transition: opacity 0.4s;
-- background: radial-gradient(360px circle at var(--x, 50%) var(--y, 30%), color-mix(in srgb, var(--accent) 22%, transparent), transparent 70%);
++ position: absolute; left: 0; top: 0; width: 720px; height: 720px; margin: -360px 0 0 -360px;
++ pointer-events: none; z-index: 0; opacity: 0; transition: opacity 0.4s;
++ border-radius: 50%;
++ background: radial-gradient(circle, color-mix(in srgb, var(--accent) 22%, transparent) 0%, transparent 70%);
++ transform: translate3d(50vw, 30vh, 0);
}
#page-home .hero-content { position: relative; z-index: 2; }
/* ── Hero buttons: icon tile + label, brand colour per destination ──
@@@ -31,6 +34,10 @@
}
#page-home .hero-cta > .hb { min-width: 0; }
#page-home .hero-cta .hb-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++/* The "API docs" aside only fits beside "Build & Mod" when the buttons are wide; everywhere else it
++ is what pushes the label into an ellipsis. The tooltip still says it. */
++#page-home .hero-cta .hb-label small { display: none; }
++@media (min-width: 1500px) { #page-home .hero-cta .hb-label small { display: inline; } }
.hb {
--c: var(--accent); position: relative; display: inline-flex; align-items: center; gap: 10px;
padding: clamp(6px, 1.2vw, 8px) clamp(14px, 2.4vw, 20px) clamp(6px, 1.2vw, 8px) 6px;
@@@ -246,3 +253,78 @@
}
}
@media (hover: none) and (prefers-reduced-motion: reduce) { .hb[data-ovtip]::after { animation: none; } }
++
++
++/* ═══════════════════════════════════════════════════════════════════════════
++ Hero render cost.
++
++ Measured on production, idle at the top of the home page for ten seconds: 144 composited layers
++ totalling 46 megapixels on a 1.2MP desktop viewport, 52 backdrop-filters, 18 filters and 41
++ will-change hints in the hero alone, and 8 long main-thread tasks adding up to four seconds.
++ Every one of the rules below trades a per-frame GPU or paint cost for the same look drawn once:
++
++ - Backdrop blur is gone from the stat board, every stat tile, the "shipped" pill and the collage
++ badges. Each of those re-sampled and re-blurred whatever moved behind it — and behind it was a
++ Ken Burns background and twelve drifting cards, so it was every frame. Solid translucent fills
++ read the same over a background that is already darkened.
++ - The collage is 2D. It was a preserve-3d scene with translateZ depth and per-card blur, which
++ puts every card in 3D depth-sorting and applies a blur to each card's layer as it moves. Depth
++ now comes from size and opacity, which the cards already had.
++ - The background frame and the aurora carry no filters. brightness/saturate on a 1.5MP layer
++ that is scaling, and a 32px blur on a 1.35MP layer with lighten blending, both re-filter as
++ they animate; a dark overlay and wider gradient falloff give the same result statically.
++ - The headline glow animated filter:brightness and filter:drop-shadow, which repaint the text
++ every frame. The glow stays; it just no longer pulses through a filter.
++ - Only the frame currently on screen is promoted to its own layer.
++ ═══════════════════════════════════════════════════════════════════════════ */
++#page-home .hero-stats,
++#page-home .hero-stat,
++#page-home .hero-latest,
++#page-home .hero-float-badge {
++ backdrop-filter: none !important; -webkit-backdrop-filter: none !important;
++}
++#page-home .hero-stats { background: color-mix(in srgb, var(--bg-primary) 84%, transparent); }
++#page-home .hero-stat { background: color-mix(in srgb, var(--bg-secondary) 90%, transparent); }
++#page-home .hero-latest { background: color-mix(in srgb, var(--bg-secondary) 88%, transparent); }
++
++/* Collage: flat drift, no 3D context, no per-card blur. */
++.hero-collage { perspective: none !important; transform-style: flat !important; transition: transform 0.6s cubic-bezier(.2, .7, .2, 1); }
++.hero-float { transform-style: flat !important; will-change: auto !important; transform: translate3d(0, 12px, 0) scale(var(--depth-scale, 1)); }
++.hero-float-inner { filter: none !important; }
++.hero-float:hover .hero-float-inner { filter: none !important; }
++@keyframes heroFloat3d {
++ 0% { transform: translate3d(-4px, -9px, 0) rotate(var(--rot, -2deg)) scale(var(--depth-scale, 1)); }
++ 50% { transform: translate3d(5px, 3px, 0) rotate(calc(var(--rot, -2deg) * 0.3)) scale(var(--depth-scale, 1)); }
++ 100% { transform: translate3d(3px, 9px, 0) rotate(calc(var(--rot, -2deg) * -0.6)) scale(var(--depth-scale, 1)); }
++}
++
++/* Background frames: no filter; the darkening is an overlay painted once. */
++.hero-bg-layer { filter: none !important; will-change: auto !important; }
++.hero-bg-layer.active { will-change: transform !important; }
++.hero-bg::after {
++ content: ''; position: absolute; inset: 0; pointer-events: none;
++ background: rgba(0, 0, 0, 0.5);
++}
++
++/* Aurora: the gradients are already soft; the blur and blend were redundant and expensive. */
++.hero-aurora, .hero-aurora::before, .hero-aurora::after {
++ filter: none !important; mix-blend-mode: normal !important; will-change: auto !important;
++}
++.hero-aurora {
++ background:
++ radial-gradient(90% 65% at 50% 125%, color-mix(in srgb, var(--accent) 50%, #ff7a18) 0%, color-mix(in srgb, var(--accent) 18%, transparent) 45%, transparent 78%),
++ radial-gradient(55% 45% at 50% 118%, color-mix(in srgb, var(--accent-light, var(--accent)) 55%, #ffd479) 0%, transparent 75%) !important;
++ opacity: 0.34 !important;
++}
++
++/* Headline: a steady glow instead of a filter animation. */
++.hfx-shimmer { animation: none !important; filter: drop-shadow(0 0 14px color-mix(in srgb, var(--accent) 32%, transparent)); }
++.hfx-glow { animation: none !important; }
++
++/* Phones: nothing in the hero moves on its own. The page is scrolled past here far more than it is
++ looked at, and every running animation in a region being scrolled costs a frame budget the
++ scroll needs. */
++.rs-lite .hero-float { animation: heroFloatIn 0.6s cubic-bezier(0.2, 0.8, 0.2, 1) var(--in-delay, 0s) forwards !important; }
++.rs-lite .hero-bg-layer.active { animation: none !important; transform: scale(1.06) !important; will-change: auto !important; }
++.rs-lite .hero-aurora, .rs-lite .hero-aurora::before, .rs-lite .hero-aurora::after { animation: none !important; }
++.rs-lite .hfx-spot, .rs-lite .hfx-sparks { display: none !important; }
ddiff --git a/public/index.html b/public/index.html
iindex ff5d6b72..db4b244a 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -88,8 +88,8 @@
overlay) and is fetched without blocking the first paint: media="print" makes the browser
treat it as non-critical, and the onload flips it back to all once it has arrived. -->
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
-- <link rel="stylesheet" href="/css/style.css?v=213">
-- <link rel="stylesheet" href="/css/home-fx.css?v=16">
++ <link rel="stylesheet" href="/css/style.css?v=214">
++ <link rel="stylesheet" href="/css/home-fx.css?v=18">
<link rel="stylesheet" href="/css/home-tour.css?v=11">
<link rel="stylesheet" href="/css/i18n-star.css?v=11">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
@@@ -5095,7 +5095,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/live-notify.js?v=3" defer></script>
<script src="/js/ov-skeleton.js?v=1" defer></script>
<script src="/js/ov-odometer.js?v=3" defer></script>
--<script src="/js/ov-density.js?v=3" defer></script>
++<script src="/js/ov-density.js?v=4" defer></script>
<!-- ── Route bundles ──────────────────────────────────────────────────────────
Measured with Chrome's precise coverage on a loaded home page: 14.3% of the JavaScript this
page ships actually executes. The broadcast desk and the Arena are the clearest of the rest —
@@@ -5139,7 +5139,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
};
})();
</script>
--<script src="/js/app.js?v=180" defer></script>
++<script src="/js/app.js?v=181" defer></script>
<script src="/js/stream-player.js?v=22" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
@@@ -5219,7 +5219,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/shared/tooltip.js?v=2" defer></script>
<script src="/shared/footer.js?v=1" defer></script>
<script src="/js/footer-live.js?v=1" defer></script>
--<script src="/js/home-fx.js?v=9" defer></script>
++<script src="/js/home-fx.js?v=10" defer></script>
<script src="/js/home-tour.js?v=10" defer></script>
<script src="/js/ov-guide.js?v=3" defer></script>
<script src="/js/guides-live.js?v=8" defer></script>
ddiff --git a/public/js/app.js b/public/js/app.js
iindex bcccaffc..216274cb 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -1956,7 +1956,9 @@ function _startHeroStatsLive() {
for (const [key, now, avg, peak] of reBase) {
wrap.querySelectorAll(`[data-stat="${key}"] .hero-stat-delta`).forEach(el => {
const html = _baselineDeltaHTML(now, avg, peak);
-- if (html) el.outerHTML = html;
++ // Replacing the node every ten seconds whether or not it changed was a steady
++ // stream of mutations for every observer watching the board.
++ if (html && el.outerHTML !== html) el.outerHTML = html;
});
}
for (const [key, rec] of Object.entries(DELTA_FOR)) {
@@@ -2185,13 +2187,10 @@ function renderHeroCollage(media) {
const z = Math.round(-260 + Math.random() * 380);
const depth = (z + 260) / 380; // 0 = far, 1 = near
const op = (0.32 + depth * 0.44).toFixed(2);
-- const blur = (Math.max(0, -z) / 130 * 1.7).toFixed(2);
const size = Math.round(84 + depth * 98);
-- const rx = (Math.random() * 10 - 5).toFixed(1);
-- const ry = (Math.random() * 14 - 7).toFixed(1);
const rot = (Math.random() * 6 - 3).toFixed(1);
el.style.cssText = `left:${positions[i].left}%;top:${positions[i].top}%;`
-- + `--w:${size}px;--z:${z}px;--op:${op};--blur:${blur}px;--rx:${rx}deg;--ry:${ry}deg;--rot:${rot}deg;`
++ + `--w:${size}px;--op:${op};--rot:${rot}deg;`
+ `--dur:${(11 + Math.random() * 9).toFixed(1)}s;--delay:${(Math.random() * -8).toFixed(1)}s;--in-delay:${(i * 0.05).toFixed(2)}s;`;
_heroFloatFill(el, media[i % media.length]);
wrap.appendChild(el);
@@@ -2201,12 +2200,17 @@ function renderHeroCollage(media) {
if (reduce || media.length <= count) return;
let ptr = count;
_heroCollageTimer = setInterval(() => {
++ // A swap decodes an image and repaints a card. Nobody sees it while the hero is scrolled
++ // away or the tab is in the background, so it waits.
if (document.hidden) return;
++ const heroEl = document.querySelector('#page-home .hero');
++ if (heroEl && heroEl.hasAttribute('data-fx-viewport') && !heroEl.classList.contains('fx-live')) return;
++ if (!document.getElementById('page-home')?.classList.contains('active')) return;
const card = cards[Math.floor(Math.random() * cards.length)];
const item = media[ptr % media.length]; ptr++;
card.classList.add('swapping');
setTimeout(() => { _heroFloatFill(card, item); card.classList.remove('swapping'); }, 560);
-- }, 5600);
++ }, 8000);
}
// Mouse-parallax: gently tilt the whole 3D collage toward the cursor (desktop only).
@@@ -2218,15 +2222,22 @@ function _heroParallaxInit() {
if (!hero || !collage) return;
if (window.matchMedia && (window.matchMedia('(prefers-reduced-motion: reduce)').matches || window.matchMedia('(pointer: coarse)').matches)) return;
_heroParallaxBound = true;
-- let raf = 0, ry = 0, rx = 0;
-- const apply = () => { raf = 0; collage.style.setProperty('--tilt-y', ry.toFixed(2) + 'deg'); collage.style.setProperty('--tilt-x', rx.toFixed(2) + 'deg'); };
-- hero.addEventListener('pointermove', (e) => {
-- const r = hero.getBoundingClientRect();
-- ry = (((e.clientX - r.left) / r.width) * 2 - 1) * 7;
-- rx = -(((e.clientY - r.top) / r.height) * 2 - 1) * 5;
-- if (!raf) raf = requestAnimationFrame(apply);
-- }, { passive: true });
-- hero.addEventListener('pointerleave', () => { ry = 0; rx = 0; if (!raf) raf = requestAnimationFrame(apply); });
++ // Parallax is a plain 2D shift of the whole collage, written straight to its transform. It used
++ // to write --tilt-x/--tilt-y custom properties; custom properties inherit, so every pointer move
++ // invalidated style for the collage and every card inside it, and the rotateX/rotateY they fed
++ // kept the scene in 3D. The rect is read once per frame rather than once per pointer event.
++ let raf = 0, nx = 0, ny = 0, lastE = null;
++ const apply = () => {
++ raf = 0;
++ if (lastE) {
++ const r = hero.getBoundingClientRect();
++ nx = ((lastE.clientX - r.left) / r.width) * 2 - 1;
++ ny = ((lastE.clientY - r.top) / r.height) * 2 - 1;
++ }
++ collage.style.transform = `translate3d(${(-nx * 14).toFixed(1)}px, ${(-ny * 9).toFixed(1)}px, 0)`;
++ };
++ hero.addEventListener('pointermove', (e) => { lastE = e; if (!raf) raf = requestAnimationFrame(apply); }, { passive: true });
++ hero.addEventListener('pointerleave', () => { lastE = null; nx = 0; ny = 0; if (!raf) raf = requestAnimationFrame(apply); });
}
// ── Hero data: stats + collage + AI slogans (falls back gracefully) ──
ddiff --git a/public/js/home-fx.js b/public/js/home-fx.js
iindex 2eb8c3d0..326b70cf 100644
---- a/public/js/home-fx.js
++++ b/public/js/home-fx.js
@@@ -81,7 +81,10 @@
const x = e.clientX - r.left, y = e.clientY - r.top;
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
-- spot.style.setProperty('--x', `${x}px`); spot.style.setProperty('--y', `${y}px`); spot.style.opacity = '1';
++ // A transform on a fixed-size disc, not a gradient centre in a custom property on a
++ // hero-sized element: the old way repainted the whole 1.3MP hero on every move.
++ spot.style.transform = `translate3d(${x}px, ${y}px, 0)`;
++ if (spot.style.opacity !== '1') spot.style.opacity = '1';
});
});
hero.addEventListener('pointerleave', () => { spot.style.opacity = '0'; });
@@@ -115,20 +118,35 @@
revealSafety = setTimeout(sweep, 1200);
}
-- function attachReveal() {
++ const REVEAL_SEL = '#page-home .section-header, #page-home .stream-card, #page-home .home-cta-banner, #page-home .home-star-section, #page-home .rs-hero-mount, #page-home .pulse-grid > *, #page-home .moments-row > *, #page-home .home-digest';
++ // Elements added after the first pass wait here for their first IntersectionObserver report,
++ // which says whether they are already on screen without forcing a layout to find out.
++ let lateIo = null;
++ function attachReveal(roots) {
if (!('IntersectionObserver' in window)) return;
if (!io) io = new IntersectionObserver((entries) => { for (const en of entries) if (en.isIntersecting) { en.target.classList.add('is-in'); io.unobserve(en.target); } }, { rootMargin: '0px 0px -8% 0px', threshold: 0.08 });
-- const sel = '#page-home .section-header, #page-home .stream-card, #page-home .home-cta-banner, #page-home .home-star-section, #page-home .rs-hero-mount, #page-home .pulse-grid > *, #page-home .moments-row > *, #page-home .home-digest';
-- document.querySelectorAll(sel).forEach((el, i) => {
-- if (el.classList.contains('hfx-reveal')) return;
-- // Anything already on screen after the first pass is NOT new to the reader — it is a
-- // card the 60-second section refresh just rebuilt underneath them. Fading it out and
-- // back in made the whole home page flash once a minute, collapsing expanded AI
-- // overviews and destroying text selection on the way. Show it and move on.
-- if (!firstRevealPass) {
-- const r = el.getBoundingClientRect();
-- if (r.height && r.top < window.innerHeight && r.bottom > 0) { el.classList.add('hfx-reveal', 'is-in'); return; }
++ if (!lateIo) lateIo = new IntersectionObserver((entries) => {
++ for (const en of entries) {
++ const el = en.target; lateIo.unobserve(el);
++ // Anything already on screen when it arrives is a card the section refresh rebuilt
++ // underneath the reader — show it as it is. Only off-screen arrivals get the reveal.
++ if (en.isIntersecting) el.classList.add('hfx-reveal', 'is-in');
++ else { el.classList.add('hfx-reveal'); io.observe(el); }
}
++ });
++ // First pass reads the page once; after that only the nodes a mutation added are considered.
++ // getBoundingClientRect on a freshly rebuilt card forced a full layout of the home page —
++ // measured at ~150ms, repeated every time the hero's live numbers or a feed re-rendered.
++ const candidates = [];
++ if (!roots) document.querySelectorAll(REVEAL_SEL).forEach(el => candidates.push(el));
++ else for (const r of roots) {
++ if (r.nodeType !== 1) continue;
++ if (r.matches && r.matches(REVEAL_SEL)) candidates.push(r);
++ if (r.querySelectorAll) r.querySelectorAll(REVEAL_SEL).forEach(el => candidates.push(el));
++ }
++ candidates.forEach((el, i) => {
++ if (el.classList.contains('hfx-reveal') || el.dataset.hfxWait) return;
++ if (!firstRevealPass) { el.dataset.hfxWait = '1'; lateIo.observe(el); return; }
el.classList.add('hfx-reveal');
el.style.setProperty('--d', `${(i % 6) * 60}ms`);
io.observe(el);
@@@ -160,20 +178,36 @@
if (!stats) return;
const run = () => stats.querySelectorAll('b, strong, .hero-stat-value, .hero-stat-num').forEach(countUp);
run();
-- new MutationObserver(() => run()).observe(stats, { childList: true, subtree: true });
++ // The odometer rolls digits by mutating the board, so this observer fired once per digit per
++ // roll and re-queried the whole board every time. Coalesced to one pass per frame, and only
++ // when nodes were actually added — a digit roll adds none.
++ let pending = 0;
++ new MutationObserver((muts) => {
++ if (pending) return;
++ if (!muts.some(m => m.addedNodes && m.addedNodes.length)) return;
++ pending = requestAnimationFrame(() => { pending = 0; run(); });
++ }).observe(stats, { childList: true, subtree: true });
}
// ── Stream card tilt ───────────────────────────────────────
function attachTilt() {
if (LITE) return;
++ // Pointer events arrive faster than frames; the old handler read layout on every one of them.
++ let tiltRaf = 0, lastEv = null;
document.addEventListener('pointermove', (e) => {
-- const card = e.target.closest && e.target.closest('#page-home .stream-card');
-- if (!card) return;
-- const r = card.getBoundingClientRect();
-- const px = (e.clientX - r.left) / r.width - 0.5, py = (e.clientY - r.top) / r.height - 0.5;
-- card.style.transform = `perspective(700px) rotateY(${(px * 6).toFixed(2)}deg) rotateX(${(-py * 6).toFixed(2)}deg) translateY(-3px)`;
-- card.classList.add('hfx-tilting');
-- });
++ if (!onHome()) return;
++ lastEv = e;
++ if (tiltRaf) return;
++ tiltRaf = requestAnimationFrame(() => {
++ tiltRaf = 0; const ev = lastEv;
++ const card = ev.target.closest && ev.target.closest('#page-home .stream-card');
++ if (!card) return;
++ const r = card.getBoundingClientRect();
++ const px = (ev.clientX - r.left) / r.width - 0.5, py = (ev.clientY - r.top) / r.height - 0.5;
++ card.style.transform = `perspective(700px) rotateY(${(px * 6).toFixed(2)}deg) rotateX(${(-py * 6).toFixed(2)}deg) translateY(-3px)`;
++ card.classList.add('hfx-tilting');
++ });
++ }, { passive: true });
document.addEventListener('pointerout', (e) => { const card = e.target.closest && e.target.closest('#page-home .stream-card'); if (card && !card.contains(e.relatedTarget)) { card.style.transform = ''; card.classList.remove('hfx-tilting'); } });
}
@@@ -238,8 +272,12 @@
// Coalesce to one pass per frame. Unthrottled, this ran an eight-selector
// querySelectorAll over the whole document on every single DOM mutation — every chat
// message, every grid tick, every activity row.
-- let pending = 0;
-- const queue = () => { if (pending) return; pending = requestAnimationFrame(() => { pending = 0; if (onHome()) attachReveal(); }); };
++ let pending = 0; const added = [];
++ const queue = (muts) => {
++ for (const m of muts) for (const n of m.addedNodes) if (n.nodeType === 1) added.push(n);
++ if (pending || !added.length) return;
++ pending = requestAnimationFrame(() => { pending = 0; const batch = added.splice(0); if (onHome()) attachReveal(batch); });
++ };
new MutationObserver(queue).observe(container, { childList: true, subtree: true });
}
}
ddiff --git a/public/js/ov-density.js b/public/js/ov-density.js
iindex b53a0bce..b536ba81 100644
---- a/public/js/ov-density.js
++++ b/public/js/ov-density.js
@@@ -165,13 +165,24 @@
}
const effective = active || Math.min(natural, max);
for (const b of ctrl.querySelectorAll('.ovd-btn')) {
-- b.setAttribute('aria-pressed', String(Number(b.dataset.n) === effective));
++ const pressed = String(Number(b.dataset.n) === effective);
++ if (b.getAttribute('aria-pressed') !== pressed) b.setAttribute('aria-pressed', pressed);
}
};
// Follow the container, not the window: a resize, an orientation change and a sidebar
// opening are all the same event as far as "how many cards fit" is concerned.
-- try { new ResizeObserver(() => render()).observe(grid); } catch { window.addEventListener('resize', render, { passive: true }); }
++ // Only a width change can change how many columns fit. The grid's height changes constantly —
++ // skeletons resolving, images loading, Load more — and each of those used to re-run the whole
++ // render, rewriting the control and, through the page's mutation observers, the reveal pass.
++ let lastW = -1;
++ try {
++ new ResizeObserver((entries) => {
++ const w = Math.round(entries[0] && entries[0].contentRect ? entries[0].contentRect.width : grid.clientWidth);
++ if (w === lastW) return;
++ lastW = w; render();
++ }).observe(grid);
++ } catch { window.addEventListener('resize', render, { passive: true }); }
render();
grid._ovdRender = render;
}