openvibers/openvibe.live Commits

Idle pages stop animating: 72 running animations down to 27, 41% less main-thread time

OP OpenVibers committed 16 Sept 2026 · 4506283f33 · 5 files, +311 −16

Measured on a loaded, idle home page: 63-72 CSS/SMIL animations running at once, 45 of them on
elements outside the viewport. Several animated background-position, box-shadow or
stroke-dashoffset — properties that cannot be composited, so each one costs a style recalc and a
paint every frame for something nobody is looking at.

Interleaved A/B, three 10s idle windows each, medians, same loaded page:

  running animations      72  ->  27     (-62%)
  main-thread task time   2.633s -> 1.560s per 10s window   (-40.7%)
  style recalc time       0.347s -> 0.246s                  (-28.9%)

Four changes get there:

- home-fx.js already toggled .fx-live on [data-fx-viewport] sections as they scroll in and out,
  but only a handful of animations were gated on it by hand, so every new effect had to remember
  to opt in and most did not. One rule now pauses everything inside a section that is not in view,
  whatever it is, and the hero and CTA banner are tagged alongside the hero row and the tour.
  Paused holds the frame rather than resetting, so scrolling back does not restart mid-effect.

- The navbar mark ran six loops plus two SMIL orbits and a sweeping gradient for the entire
  session. It now animates when something happened: a route change spins it once on a spring
  curve, keeps it lively for five seconds, then settles it with an overshoot and holds it still.
  Hovering wakes it too. CSS loops stop via animation-play-state (every 0% frame is a rest pose);
  SMIL cannot be paused from CSS, so the SVG's own pauseAnimations() handles the orbits.

- The ALPHA badge is in the navbar, so it is on screen for the whole session, and it animated
  background-position and box-shadow together. Now a translating pseudo-element and an opacity
  fade — same look, composited.

- The hero row's vibe wash (background-position) and its touch-only hint halo (an expanding
  box-shadow on every hero button) are both transform/opacity now.

Route changes also got the motion the mark implies: a thin accent bar sweeps the top and the
incoming page rises the last few pixels into place. Both are transform-only, cannot shift layout,
and strip themselves on a timer so a swap interrupted mid-flight never leaves content invisible.

Also fixes a real cause of dead-looking buttons: OVGuide.close() never called closeSpot(), while
open() did. dashboard() deliberately closes the guide and then spotlights a real control, so a
spotlight dismissed by navigating away rather than by its own button stayed in the DOM — and
.ovg-spot-card is position:fixed with pointer-events:auto, so it silently swallowed taps on
whatever it covered. close() and every route change now clear it, the scroll lock is released
even on close()'s early return, and teardownRoute sweeps any orphaned overlay as a backstop.

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 0b3a086e..d54b1317 100644
---- a/public/css/home-fx.css
++++ b/public/css/home-fx.css
@@@ -174,12 +174,18 @@
the wand, and a secondary "API docs" label that keeps the destination honest without the
button reading like documentation. All of it pauses with the rest of the row when off-screen. */
.hb-label small { font-size: 0.68rem; font-weight: 600; opacity: 0.62; margin-left: 5px; letter-spacing: 0.01em; }
--.hb-vibe {
++/* The wash used to slide background-position, which repaints the button on every frame of an
++ 11-second loop for the whole time the row is on screen. The gradient now lives on a pseudo-
++ element twice the width and simply translates, which the compositor handles without a repaint. */
++.hb-vibe { position: relative; overflow: hidden; }
++.hb-vibe::after {
++ content: ''; position: absolute; top: 0; bottom: 0; left: -60%; width: 220%;
++ z-index: 0; pointer-events: none;
background-image: linear-gradient(115deg, color-mix(in srgb, var(--c) 16%, transparent), transparent 42%, color-mix(in srgb, #a78bfa 14%, transparent));
-- background-size: 220% 100%;
animation: hbVibeWash 11s ease-in-out infinite;
}
--@keyframes hbVibeWash { 0%, 100% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } }
++.hb-vibe > * { position: relative; z-index: 1; }
++@keyframes hbVibeWash { 0%, 100% { transform: translateX(0); } 50% { transform: translateX(27%); } }
.hb-vibe .hb-ico { position: relative; overflow: visible; }
/* A spark that leaves the wand every few seconds. */
.hb-vibe .hb-ico::after {
@@@ -207,14 +213,22 @@
.hb[data-ovtip]::after {
content: ''; display: block; position: absolute; top: 9px; right: 11px;
width: 6px; height: 6px; border-radius: 50%; padding: 0; inset: auto 11px auto auto;
-- background: var(--c); box-shadow: 0 0 0 0 color-mix(in srgb, var(--c) 60%, transparent);
++ background: var(--c);
-webkit-mask: none; mask: none; opacity: 1; z-index: 2; pointer-events: none;
++ }
++ /* The halo was an expanding box-shadow — a repaint every frame, forever, on every hero button
++ on a phone. It is a second dot that scales and fades instead: transform and opacity only. */
++ .hb[data-ovtip]::before {
++ content: ''; display: block; position: absolute; top: 9px; right: 11px;
++ width: 6px; height: 6px; border-radius: 50%;
++ background: color-mix(in srgb, var(--c) 55%, transparent);
++ z-index: 1; pointer-events: none; transform-origin: center;
animation: hbHintPulse 2.4s ease-out infinite;
}
@keyframes hbHintPulse {
-- 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--c) 55%, transparent); }
-- 70% { box-shadow: 0 0 0 7px transparent; }
-- 100% { box-shadow: 0 0 0 0 transparent; }
++ 0% { transform: scale(1); opacity: 0.85; }
++ 70% { transform: scale(3.3); opacity: 0; }
++ 100% { transform: scale(3.3); opacity: 0; }
}
}
@media (hover: none) and (prefers-reduced-motion: reduce) { .hb[data-ovtip]::after { animation: none; } }
ddiff --git a/public/css/style.css b/public/css/style.css
iindex 29820870..6dc8db1b 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -14907,7 +14907,29 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
/* ── OV brand mark (navbar + hero): the O is a ring with a comet running around it, the V is
drawn inside with a light sweep, a dot orbits the ring. Transform/opacity/stroke motion only. ── */
.brand-mark { display: inline-grid; place-items: center; width: 32px; height: 32px; color: var(--accent); flex: none; }
++/* Idle is the default state. Every loop below is declared as before but held at its 0% frame —
++ each one's 0% is a sensible resting pose — and only runs while .is-awake is on the mark, which
++ app.js adds for five seconds after a route change. Six looping animations on an element that is
++ on screen the entire session is a composited repaint per frame for no reason while someone is
++ reading. */
.brand-mark svg { overflow: visible; transform-origin: 50% 50%; animation: ovFloat 5s ease-in-out infinite; }
++.brand-mark svg,
++.brand-mark .ov-glow,
++.brand-mark .ov-comet,
++.brand-mark .ov-v,
++.brand-mark .ov-dot,
++.brand-mark .ov-spark { animation-play-state: paused; }
++.brand-mark.is-awake svg,
++.brand-mark.is-awake .ov-glow,
++.brand-mark.is-awake .ov-comet,
++.brand-mark.is-awake .ov-v,
++.brand-mark.is-awake .ov-dot,
++.brand-mark.is-awake .ov-spark { animation-play-state: running; }
++/* Hovering is a reason to come alive too, without waiting for a navigation. */
++.nav-brand:hover .brand-mark svg,
++.nav-brand:hover .brand-mark .ov-glow,
++.nav-brand:hover .brand-mark .ov-comet,
++.nav-brand:hover .brand-mark .ov-dot { animation-play-state: running; }
.ov-glow { fill: currentColor; opacity: 0.1; transform-box: fill-box; transform-origin: center; animation: ovGlow 3.4s ease-in-out infinite; }
.ov-ring { fill: none; stroke: currentColor; stroke-width: 4; opacity: 0.28; }
.ov-comet { fill: none; stroke-width: 4; stroke-linecap: round; stroke-dasharray: 34 79; transform-box: fill-box; transform-origin: center; animation: ovComet 3.6s linear infinite; }
@@@ -14925,6 +14947,21 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@keyframes ovDraw { from { stroke-dashoffset: 46; } to { stroke-dashoffset: 0; } }
@keyframes ovDot { 0%, 100% { transform: scale(1); opacity: 0.85; } 50% { transform: scale(1.5); opacity: 1; } }
@keyframes ovSpin { from { transform: rotate(0deg) scale(1); } 40% { transform: rotate(200deg) scale(1.18); } to { transform: rotate(360deg) scale(1); } }
++/* One spin as the route lands, on a spring-ish curve that overshoots past 360 and eases back. */
++.brand-mark.is-spin svg { animation: ovSpinIn 1.05s cubic-bezier(.16, 1.16, .3, 1) 1 both; animation-play-state: running; }
++@keyframes ovSpinIn {
++ 0% { transform: rotate(0deg) scale(0.86); }
++ 45% { transform: rotate(228deg) scale(1.2); }
++ 72% { transform: rotate(348deg) scale(0.97); }
++ 100% { transform: rotate(360deg) scale(1); }
++}
++/* And a short overshoot as it comes to rest, so going still reads as deliberate. */
++.brand-mark.is-settling svg { animation: ovSettle 0.68s cubic-bezier(.26, 1.32, .42, 1) 1 both; animation-play-state: running; }
++@keyframes ovSettle {
++ 0% { transform: rotate(5deg) scale(1.07); }
++ 55% { transform: rotate(-2.5deg) scale(0.982); }
++ 100% { transform: none; }
++}
/* Hero: bigger, plus sparks off the V */
.brand-mark--hero { width: 1.05em; height: 1.05em; vertical-align: -0.16em; color: var(--accent); margin-right: 0.08em; }
.brand-mark--hero svg { width: 100%; height: 100%; }
@@@ -15483,3 +15520,90 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
the last readable line and the footer rule. Other pages end on content that needs the breathing
room, so this is scoped to the home page rather than changed globally. */
#page-home.page { padding-bottom: 10px; }
++
++
++/* ═══════════════════════════════════════════════════════════════
++ Route-change motion (app.js: ovRouteProgress / ovAnimatePageEnter)
++
++ A route swap here is instant in the DOM and slow to fill from the network, so without a signal
++ the page reads as stuck for that gap. The bar is one element with a transform-only sweep; the
++ page rises the last few pixels into place. Neither can shift layout, and both strip themselves
++ on a timer so a swap interrupted mid-flight never leaves content invisible.
++ ═══════════════════════════════════════════════════════════════ */
++#ov-routebar {
++ position: fixed; top: 0; left: 0; right: 0; height: 2px;
++ z-index: 20000; pointer-events: none; opacity: 0;
++ background: linear-gradient(90deg,
++ transparent,
++ color-mix(in srgb, var(--accent) 85%, transparent) 35%,
++ var(--accent-light, var(--accent)) 55%,
++ color-mix(in srgb, var(--accent) 85%, transparent) 75%,
++ transparent);
++ transform: translateX(-100%);
++}
++#ov-routebar.is-run { opacity: 1; animation: ovRouteBar 0.62s cubic-bezier(.3, .8, .2, 1) both; }
++#ov-routebar.is-done { opacity: 0; transform: translateX(0); transition: opacity 0.3s ease; }
++@keyframes ovRouteBar {
++ from { transform: translateX(-100%); }
++ to { transform: translateX(100%); }
++}
++
++.page.is-entering { animation: ovPageIn 0.42s cubic-bezier(.22, 1, .36, 1) both; }
++@keyframes ovPageIn {
++ from { opacity: 0; transform: translate3d(0, 10px, 0); }
++ to { opacity: 1; transform: none; }
++}
++
++@media (prefers-reduced-motion: reduce) {
++ #ov-routebar { display: none; }
++ .page.is-entering { animation: none !important; }
++ .brand-mark.is-spin svg, .brand-mark.is-settling svg { animation: none !important; }
++}
++/* Phones do the page rise but skip the bar — it sits under the browser's own chrome anyway. */
++.rs-lite .page.is-entering { animation-duration: 0.32s; }
++
++
++/* ═══════════════════════════════════════════════════════════════
++ Off-screen sections animate nothing.
++
++ Measured on an idle home page: 63 running animations, 45 of them on elements outside the
++ viewport — the restream diagram's nine flowing wires, the CTA banner's embers, drifts and
++ sweeps, the marks' orbits. Several animate background-position, box-shadow or stroke-dashoffset,
++ which cannot be composited and so cost a style recalc and a paint every frame for something
++ nobody is looking at.
++
++ home-fx.js already toggles .fx-live on [data-fx-viewport] containers as they enter and leave the
++ viewport; only a handful of animations were gated on it by hand, which meant every new effect
++ had to remember to opt in and most did not. This makes it structural: anything inside a section
++ that is not currently live is paused, whatever it is. Paused holds the current frame rather
++ than resetting, so scrolling back does not restart mid-effect.
++ ═══════════════════════════════════════════════════════════════ */
++[data-fx-viewport]:not(.fx-live),
++[data-fx-viewport]:not(.fx-live) *::before,
++[data-fx-viewport]:not(.fx-live) *::after,
++[data-fx-viewport]:not(.fx-live) * {
++ animation-play-state: paused !important;
++}
++
++/* The ALPHA badge is in the navbar, so it is on screen for the whole session — and it animated
++ background-position and box-shadow together, the two most expensive things to animate. The
++ gradient now slides on a transform inside a clipped pseudo-element and the glow is an opacity
++ fade on a second one, so the same effect composites instead of repainting. */
++.alpha-badge { position: relative; isolation: isolate; overflow: hidden; animation: none !important; }
++.alpha-badge > * { position: relative; z-index: 2; }
++.alpha-badge::before {
++ content: ''; position: absolute; top: 0; left: -50%; width: 200%; bottom: 0; z-index: 0;
++ background: linear-gradient(135deg, #ff6b6b, #ee5a24, #ff6b6b, #ee5a24);
++ background-size: 50% 100%;
++ animation: alphaSlide 2.5s ease-in-out infinite;
++}
++.alpha-badge::after {
++ content: ''; position: absolute; inset: -3px; z-index: -1; border-radius: inherit;
++ box-shadow: 0 0 14px rgba(238, 90, 36, 0.7);
++ animation: alphaGlow 2.5s ease-in-out infinite;
++}
++@keyframes alphaSlide { 0%, 100% { transform: translateX(0); } 50% { transform: translateX(-25%); } }
++@keyframes alphaGlow { 0%, 100% { opacity: 0.45; } 50% { opacity: 1; } }
++@media (prefers-reduced-motion: reduce) {
++ .alpha-badge::before, .alpha-badge::after { animation: none !important; }
++}
ddiff --git a/public/index.html b/public/index.html
iindex 81a2086b..05b0e9dd 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=203">
-- <link rel="stylesheet" href="/css/home-fx.css?v=13">
++ <link rel="stylesheet" href="/css/style.css?v=206">
++ <link rel="stylesheet" href="/css/home-fx.css?v=14">
<link rel="stylesheet" href="/css/home-tour.css?v=10">
<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">
@@@ -259,7 +259,7 @@
<!-- ── HOME: Stream List ─────────────────────────────── -->
<section id="page-home" class="page active">
-- <div class="hero">
++ <div class="hero" data-fx-viewport>
<div class="hero-bg" id="hero-bg" aria-hidden="true"><div class="hero-bg-layer"></div><div class="hero-bg-layer"></div></div>
<div class="hero-aurora" aria-hidden="true"></div>
<div class="hero-collage" id="hero-collage" aria-hidden="true"></div>
@@@ -399,7 +399,7 @@
</div>
<!-- Streaming CTA -->
-- <div class="home-cta-banner" id="home-cta-banner">
++ <div class="home-cta-banner" id="home-cta-banner" data-fx-viewport>
<div class="cta-ambient" aria-hidden="true"></div>
<div class="cta-embers" aria-hidden="true">
<span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span>
@@@ -5095,7 +5095,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<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/app.js?v=173" defer></script>
++<script src="/js/app.js?v=174" 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>
@@@ -5180,7 +5180,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/footer-live.js?v=1" defer></script>
<script src="/js/home-fx.js?v=9" defer></script>
<script src="/js/home-tour.js?v=9" defer></script>
--<script src="/js/ov-guide.js?v=2" defer></script>
++<script src="/js/ov-guide.js?v=3" defer></script>
<script src="/js/guides-live.js?v=8" defer></script>
<!-- ── OpenVibe shared libs (notifications, account sync, themes) ── -->
ddiff --git a/public/js/app.js b/public/js/app.js
iindex 9dbee81a..b7e1c5d2 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -960,6 +960,26 @@ function ovUserMenu(event) {
* timer rather than on animationend, so an element whose animation never started (off-screen,
* display:none) is still returned to normal.
*/
++/**
++ * Clear transient overlays that a route change has orphaned.
++ *
++ * Guides, spotlights and confirm sheets are all "this page, right now" UI. Each one is supposed to
++ * clean up after itself, and each one has at least one exit that skips its own teardown — closing
++ * a guide from a link inside it, navigating away from a spotlight, a confirm sheet whose caller
++ * threw. The ones that matter are position:fixed with pointer-events:auto: they keep absorbing
++ * taps over whatever they cover, which reads to the reader as "this button is dead". This is the
++ * backstop, not the fix — the modules still clean up their own.
++ */
++function ovClearStrandedOverlays() {
++ try { if (window.OVGuide && typeof OVGuide.closeSpotlight === 'function') OVGuide.closeSpotlight(); } catch { /* */ }
++ try { if (window.OVGuide && OVGuide.state && OVGuide.state.open) OVGuide.close(false); } catch { /* */ }
++ for (const sel of ['.ovg-spot', '.bc-ws-confirm-overlay', '.bg-guide']) {
++ document.querySelectorAll(sel).forEach(el => { try { el.remove(); } catch { /* */ } });
++ }
++ // The scroll lock outliving its modal is the other way a page goes dead.
++ try { if (!document.querySelector('.ovg.is-open')) document.body.classList.remove('ovg-lock'); } catch { /* */ }
++}
++
function ovPutCards(container, html, append) {
if (!container) return;
if (!append) { container.innerHTML = html; return; }
@@@ -1051,6 +1071,7 @@ function dashNav(event) {
* press added another set. Now both paths go through here.
*/
function teardownRoute(nextPath) {
++ ovClearStrandedOverlays();
if (typeof destroyPlayer === 'function') destroyPlayer();
if (typeof destroyChat === 'function') destroyChat();
/* destroyCanvasPage lived in js/canvas.js, which index.html has not loaded since canvas moved
@@@ -1243,10 +1264,127 @@ function routeFromURL() {
}
}
++/* ═══════════════════════════════════════════════════════════════════════════
++ Route-change motion: the brand mark, and the page itself.
++ ═══════════════════════════════════════════════════════════════════════════ */
++
++/** How long the mark stays animated after a route change before settling. */
++const BRAND_AWAKE_MS = 5000;
++let _brandIdleTimer = 0, _brandSettleTimer = 0, _brandSpinTimer = 0, _brandEverWoken = false;
++
++/**
++ * Wake the OV mark for a moment, then let it go still.
++ *
++ * The mark ran six looping animations — a float, a glow pulse, a comet sweep, a dot pulse and two
++ * SMIL orbit motions, plus a sweeping gradient — forever, on every page. That is a composited
++ * repaint every frame for the entire time someone sits reading, which is most of the time they
++ * are here. It now animates when there is a reason to: the route changed, so the mark spins once
++ * and stays lively for five seconds, then settles with an overshoot and holds still until the
++ * next change.
++ *
++ * CSS loops stop via animation-play-state, which freezes them on their 0% keyframe — every one of
++ * those is a sensible rest pose. SMIL cannot be paused from CSS at all, so the SVG element's own
++ * pauseAnimations()/unpauseAnimations() handles the orbit dots and the gradient sweep.
++ */
++function ovWakeBrandMark() {
++ let marks;
++ try { marks = document.querySelectorAll('.brand-mark'); } catch { return; }
++ if (!marks || !marks.length) return;
++ let reduce = false;
++ try { reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { /* */ }
++ if (reduce) return;
++
++ _brandEverWoken = true;
++ clearTimeout(_brandIdleTimer); clearTimeout(_brandSettleTimer); clearTimeout(_brandSpinTimer);
++ marks.forEach((m) => {
++ m.classList.remove('is-settling', 'is-spin');
++ m.classList.add('is-awake');
++ void m.offsetWidth; // restart the spin on a repeat navigation
++ m.classList.add('is-spin');
++ const svg = m.querySelector('svg');
++ try { svg && svg.unpauseAnimations && svg.unpauseAnimations(); } catch { /* no SMIL here */ }
++ });
++
++ // The spin owns the svg's animation slot while it runs, so the ambient float cannot start
++ // until it is off. Hand over as soon as the spin finishes rather than at the end of the window.
++ _brandSpinTimer = setTimeout(() => marks.forEach(m => m.classList.remove('is-spin')), 1080);
++
++ _brandIdleTimer = setTimeout(() => {
++ marks.forEach((m) => {
++ m.classList.remove('is-awake', 'is-spin');
++ m.classList.add('is-settling');
++ const svg = m.querySelector('svg');
++ // Pause in place rather than rewinding: a jump back to t=0 would throw the orbit dot
++ // across the ring at the exact moment the mark is supposed to be coming to rest.
++ try { svg && svg.pauseAnimations && svg.pauseAnimations(); } catch { /* */ }
++ });
++ _brandSettleTimer = setTimeout(() => marks.forEach(m => m.classList.remove('is-settling')), 720);
++ }, BRAND_AWAKE_MS);
++}
++
++/**
++ * A thin bar that runs across the top on a route change.
++ *
++ * Routes here are instant to swap and slow to fill — the shell is already up, the content arrives
++ * over the network. Without a signal the page looks broken for that gap. The bar is one element,
++ * transform-only, and removes itself.
++ */
++let _routeBarTimer = 0;
++function ovRouteProgress() {
++ let reduce = false;
++ try { reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { /* */ }
++ if (reduce) return;
++ let bar = document.getElementById('ov-routebar');
++ if (!bar) {
++ bar = document.createElement('div');
++ bar.id = 'ov-routebar';
++ bar.setAttribute('aria-hidden', 'true');
++ document.body.appendChild(bar);
++ }
++ clearTimeout(_routeBarTimer);
++ bar.classList.remove('is-run', 'is-done');
++ void bar.offsetWidth;
++ bar.classList.add('is-run');
++ _routeBarTimer = setTimeout(() => {
++ bar.classList.remove('is-run');
++ bar.classList.add('is-done');
++ _routeBarTimer = setTimeout(() => bar.classList.remove('is-done'), 320);
++ }, 620);
++}
++
++/**
++ * The incoming page rises into place instead of appearing.
++ *
++ * Transform and opacity only, so it cannot shift layout, and the class is stripped on a timer —
++ * an element whose animation never ran (a route swapped again mid-flight, a hidden ancestor) must
++ * not be left holding opacity 0.
++ */
++let _pageEnterTimer = 0;
++function ovAnimatePageEnter(el) {
++ if (!el) return;
++ let reduce = false;
++ try { reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { /* */ }
++ if (reduce) return;
++ clearTimeout(_pageEnterTimer);
++ document.querySelectorAll('.page.is-entering').forEach(p => p.classList.remove('is-entering'));
++ el.classList.remove('is-entering');
++ void el.offsetWidth;
++ el.classList.add('is-entering');
++ _pageEnterTimer = setTimeout(() => el.classList.remove('is-entering'), 700);
++}
++
function showPage(page) {
++ const changed = currentPage !== page;
currentPage = page;
const el = document.getElementById(`page-${page}`);
if (el) el.classList.add('active');
++ // The mark and the page transition are both "you moved" signals, so they fire on a real
++ // change only — re-rendering the page you are already on should not restart either.
++ if (changed) { ovWakeBrandMark(); ovRouteProgress(); ovAnimatePageEnter(el); }
++ // A fresh page load is not a "change" — currentPage is already the page being shown — so the
++ // mark would never take its first sleep and the SMIL orbits would loop for the whole session.
++ // Wake it once on arrival; the same timer puts it to sleep five seconds later.
++ else if (!_brandEverWoken) ovWakeBrandMark();
// Game/Canvas: hide footer only, keep navbar visible; other pages restore both
const navbar = document.querySelector('.navbar');
ddiff --git a/public/js/ov-guide.js b/public/js/ov-guide.js
iindex 294c60ec..c0f7e963 100644
---- a/public/js/ov-guide.js
++++ b/public/js/ov-guide.js
@@@ -137,8 +137,18 @@
show(start, 1);
}
function close(finished) {
-- const root = $('#ovg'); if (!root || !S.open) return;
-- S.open = false; root.classList.remove('is-open'); document.body.classList.remove('ovg-lock');
++ const root = $('#ovg');
++ // Always release the scroll lock, even on the early return below: if anything ever leaves
++ // S.open false with the class still on, the page silently stops scrolling.
++ document.body.classList.remove('ovg-lock');
++ if (!root || !S.open) return;
++ S.open = false; root.classList.remove('is-open');
++ // A spotlight can outlive the card that opened it — dashboard() closes the guide and then
++ // spotlights a real control on purpose. open() already clears any stray one; close() did
++ // not, so a spotlight dismissed by navigating away rather than by its own button stayed in
++ // the DOM. .ovg-spot-card is fixed and pointer-events:auto, so a stranded one silently
++ // swallows taps on whatever it happens to cover.
++ closeSpot();
const j = S.journey;
if (j) { store(j.id, { ...(store(j.id) || {}), seen: true, finished: !!finished || !!(store(j.id) || {}).finished, dismissed: !finished, when: Date.now() }); try { if (typeof j.onClose === 'function') j.onClose(S.ctx, !!finished); } catch { /* */ } }
const closedId = j ? j.id : null;
@@@ -227,7 +237,16 @@
return true;
}
const _push = history.pushState;
-- history.pushState = function () { const r = _push.apply(this, arguments); setTimeout(() => { if (!S.open) fromUrl(); }, 60); return r; };
++ history.pushState = function () {
++ // A spotlight points at one control on one page. Once the reader has navigated it is
++ // pointing at nothing, and its card keeps taking taps where it sits — so a route change
++ // dismisses it, the same way its own button would.
++ closeSpot();
++ const r = _push.apply(this, arguments);
++ setTimeout(() => { if (!S.open) fromUrl(); }, 60);
++ return r;
++ };
++ window.addEventListener('popstate', () => closeSpot());
function boot() { if (!fromUrl()) resumePending(); }
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => setTimeout(boot, 300)); else setTimeout(boot, 300);