openvibers/openvibe.live Commits
Hero render cost: no backdrop blur over moving content, a flat collage, and no idle DOM churn
Changes
@@@ -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; }@@@ -88,8 +88,8 @@overlay) and is fetched without blocking the first paint: media="print" makes the browsertreat 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 thispage 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>@@@ -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 = nearconst 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) ──@@@ -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 });}}@@@ -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;}
@@ -4,10 +4,13 @@
@@ -4,10 +4,13 @@
prefers-reduced-motion.
prefers-reduced-motion.
═══════════════════════════════════════════════════════════════ */
═══════════════════════════════════════════════════════════════ */
#page-home .hero { position: relative; }
#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 {
.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; }
#page-home .hero-content { position: relative; z-index: 2; }
/* ── Hero buttons: icon tile + label, brand colour per destination ──
/* ── Hero buttons: icon tile + label, brand colour per destination ──
@@ -31,6 +34,10 @@
@@ -31,6 +34,10 @@
}
}
#page-home .hero-cta > .hb { min-width: 0; }
#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; }
#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 {
.hb {
--c: var(--accent); position: relative; display: inline-flex; align-items: center; gap: 10px;
--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;
padding: clamp(6px, 1.2vw, 8px) clamp(14px, 2.4vw, 20px) clamp(6px, 1.2vw, 8px) 6px;
@@ -246,3 +253,78 @@
@@ -246,3 +253,78 @@
}
}
}
}
@media (hover: none) and (prefers-reduced-motion: reduce) { .hb[data-ovtip]::after { animation: none; } }
@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; }
@@ -88,8 +88,8 @@
@@ -88,8 +88,8 @@
overlay) and is fetched without blocking the first paint: media="print" makes the browser
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. -->
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="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/home-tour.css?v=11">
<link rel="stylesheet" href="/css/i18n-star.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">
<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
@@ -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="/live-notify.js?v=3" defer></script>
<script src="/js/ov-skeleton.js?v=1" 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-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 ──────────────────────────────────────────────────────────
<!-- ── Route bundles ──────────────────────────────────────────────────────────
Measured with Chrome's precise coverage on a loaded home page: 14.3% of the JavaScript this
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 —
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
@@ -5139,7 +5139,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
};
};
})();
})();
</script>
</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-player.js?v=22" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/chat-shared.js?v=3" 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
@@ -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/tooltip.js?v=2" defer></script>
<script src="/shared/footer.js?v=1" defer></script>
<script src="/shared/footer.js?v=1" defer></script>
<script src="/js/footer-live.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/home-tour.js?v=10" defer></script>
<script src="/js/ov-guide.js?v=3" defer></script>
<script src="/js/ov-guide.js?v=3" defer></script>
<script src="/js/guides-live.js?v=8" defer></script>
<script src="/js/guides-live.js?v=8" defer></script>
@@ -1956,7 +1956,9 @@ function _startHeroStatsLive() {
@@ -1956,7 +1956,9 @@ function _startHeroStatsLive() {
for (const [key, now, avg, peak] of reBase) {
for (const [key, now, avg, peak] of reBase) {
wrap.querySelectorAll(`[data-stat="${key}"] .hero-stat-delta`).forEach(el => {
wrap.querySelectorAll(`[data-stat="${key}"] .hero-stat-delta`).forEach(el => {
const html = _baselineDeltaHTML(now, avg, peak);
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)) {
for (const [key, rec] of Object.entries(DELTA_FOR)) {
@@ -2185,13 +2187,10 @@ function renderHeroCollage(media) {
@@ -2185,13 +2187,10 @@ function renderHeroCollage(media) {
const z = Math.round(-260 + Math.random() * 380);
const z = Math.round(-260 + Math.random() * 380);
const depth = (z + 260) / 380; // 0 = far, 1 = near
const depth = (z + 260) / 380; // 0 = far, 1 = near
const op = (0.32 + depth * 0.44).toFixed(2);
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 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);
const rot = (Math.random() * 6 - 3).toFixed(1);
el.style.cssText = `left:${positions[i].left}%;top:${positions[i].top}%;`
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;`;
+ `--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]);
_heroFloatFill(el, media[i % media.length]);
wrap.appendChild(el);
wrap.appendChild(el);
@@ -2201,12 +2200,17 @@ function renderHeroCollage(media) {
@@ -2201,12 +2200,17 @@ function renderHeroCollage(media) {
if (reduce || media.length <= count) return;
if (reduce || media.length <= count) return;
let ptr = count;
let ptr = count;
_heroCollageTimer = setInterval(() => {
_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;
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 card = cards[Math.floor(Math.random() * cards.length)];
const item = media[ptr % media.length]; ptr++;
const item = media[ptr % media.length]; ptr++;
card.classList.add('swapping');
card.classList.add('swapping');
setTimeout(() => { _heroFloatFill(card, item); card.classList.remove('swapping'); }, 560);
setTimeout(() => { _heroFloatFill(card, item); card.classList.remove('swapping'); }, 560);
- }, 5600);
+ }, 8000);
}
}
// Mouse-parallax: gently tilt the whole 3D collage toward the cursor (desktop only).
// Mouse-parallax: gently tilt the whole 3D collage toward the cursor (desktop only).
@@ -2218,15 +2222,22 @@ function _heroParallaxInit() {
@@ -2218,15 +2222,22 @@ function _heroParallaxInit() {
if (!hero || !collage) return;
if (!hero || !collage) return;
if (window.matchMedia && (window.matchMedia('(prefers-reduced-motion: reduce)').matches || window.matchMedia('(pointer: coarse)').matches)) return;
if (window.matchMedia && (window.matchMedia('(prefers-reduced-motion: reduce)').matches || window.matchMedia('(pointer: coarse)').matches)) return;
_heroParallaxBound = true;
_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) ──
// ── Hero data: stats + collage + AI slogans (falls back gracefully) ──
@@ -81,7 +81,10 @@
@@ -81,7 +81,10 @@
const x = e.clientX - r.left, y = e.clientY - r.top;
const x = e.clientX - r.left, y = e.clientY - r.top;
cancelAnimationFrame(raf);
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
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'; });
hero.addEventListener('pointerleave', () => { spot.style.opacity = '0'; });
@@ -115,20 +118,35 @@
@@ -115,20 +118,35 @@
revealSafety = setTimeout(sweep, 1200);
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 (!('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 });
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.classList.add('hfx-reveal');
el.style.setProperty('--d', `${(i % 6) * 60}ms`);
el.style.setProperty('--d', `${(i % 6) * 60}ms`);
io.observe(el);
io.observe(el);
@@ -160,20 +178,36 @@
@@ -160,20 +178,36 @@
if (!stats) return;
if (!stats) return;
const run = () => stats.querySelectorAll('b, strong, .hero-stat-value, .hero-stat-num').forEach(countUp);
const run = () => stats.querySelectorAll('b, strong, .hero-stat-value, .hero-stat-num').forEach(countUp);
run();
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 ───────────────────────────────────────
// ── Stream card tilt ───────────────────────────────────────
function attachTilt() {
function attachTilt() {
if (LITE) return;
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) => {
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'); } });
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 @@
@@ -238,8 +272,12 @@
// Coalesce to one pass per frame. Unthrottled, this ran an eight-selector
// Coalesce to one pass per frame. Unthrottled, this ran an eight-selector
// querySelectorAll over the whole document on every single DOM mutation — every chat
// querySelectorAll over the whole document on every single DOM mutation — every chat
// message, every grid tick, every activity row.
// 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 });
new MutationObserver(queue).observe(container, { childList: true, subtree: true });
}
}
}
}
@@ -165,13 +165,24 @@
@@ -165,13 +165,24 @@
}
}
const effective = active || Math.min(natural, max);
const effective = active || Math.min(natural, max);
for (const b of ctrl.querySelectorAll('.ovd-btn')) {
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
// 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.
// 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();
render();
grid._ovdRender = render;
grid._ovdRender = render;
}
}