openvibers/openvibe.live Commits
Idle pages stop animating: 72 running animations down to 27, 41% less main-thread time
Changes
@@@ -174,12 +174,18 @@the wand, and a secondary "API docs" label that keeps the destination honest without thebutton 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; } }@@@ -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 isdrawn 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 srgthe last readable line and the footer rule. Other pages end on content that needs the breathingroom, 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; }++}@@@ -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=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) ── -->@@@ -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 bothconst navbar = document.querySelector('.navbar');@@@ -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);
@@ -174,12 +174,18 @@
@@ -174,12 +174,18 @@
the wand, and a secondary "API docs" label that keeps the destination honest without the
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. */
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-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-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;
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; }
.hb-vibe .hb-ico { position: relative; overflow: visible; }
/* A spark that leaves the wand every few seconds. */
/* A spark that leaves the wand every few seconds. */
.hb-vibe .hb-ico::after {
.hb-vibe .hb-ico::after {
@@ -207,14 +213,22 @@
@@ -207,14 +213,22 @@
.hb[data-ovtip]::after {
.hb[data-ovtip]::after {
content: ''; display: block; position: absolute; top: 9px; right: 11px;
content: ''; display: block; position: absolute; top: 9px; right: 11px;
width: 6px; height: 6px; border-radius: 50%; padding: 0; inset: auto 11px auto auto;
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;
-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;
animation: hbHintPulse 2.4s ease-out infinite;
}
}
@keyframes hbHintPulse {
@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; } }
@media (hover: none) and (prefers-reduced-motion: reduce) { .hb[data-ovtip]::after { animation: none; } }
@@ -14907,7 +14907,29 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -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
/* ── 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. ── */
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; }
.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 { 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-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-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; }
.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
@@ -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 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 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); } }
@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 */
/* 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 { 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%; }
.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
@@ -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
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. */
room, so this is scoped to the home page rather than changed globally. */
#page-home.page { padding-bottom: 10px; }
#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; }
+}
@@ -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=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/home-tour.css?v=10">
<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">
@@ -259,7 +259,7 @@
@@ -259,7 +259,7 @@
<!-- ── HOME: Stream List ─────────────────────────────── -->
<!-- ── HOME: Stream List ─────────────────────────────── -->
<section id="page-home" class="page active">
<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-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-aurora" aria-hidden="true"></div>
<div class="hero-collage" id="hero-collage" aria-hidden="true"></div>
<div class="hero-collage" id="hero-collage" aria-hidden="true"></div>
@@ -399,7 +399,7 @@
@@ -399,7 +399,7 @@
</div>
</div>
<!-- Streaming CTA -->
<!-- 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-ambient" aria-hidden="true"></div>
<div class="cta-embers" aria-hidden="true">
<div class="cta-embers" aria-hidden="true">
<span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span>
<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
@@ -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-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=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-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>
@@ -5180,7 +5180,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -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/footer-live.js?v=1" defer></script>
<script src="/js/home-fx.js?v=9" 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/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>
<script src="/js/guides-live.js?v=8" defer></script>
<!-- ── OpenVibe shared libs (notifications, account sync, themes) ── -->
<!-- ── OpenVibe shared libs (notifications, account sync, themes) ── -->
@@ -960,6 +960,26 @@ function ovUserMenu(event) {
@@ -960,6 +960,26 @@ function ovUserMenu(event) {
* timer rather than on animationend, so an element whose animation never started (off-screen,
* timer rather than on animationend, so an element whose animation never started (off-screen,
* display:none) is still returned to normal.
* 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) {
function ovPutCards(container, html, append) {
if (!container) return;
if (!container) return;
if (!append) { container.innerHTML = html; return; }
if (!append) { container.innerHTML = html; return; }
@@ -1051,6 +1071,7 @@ function dashNav(event) {
@@ -1051,6 +1071,7 @@ function dashNav(event) {
* press added another set. Now both paths go through here.
* press added another set. Now both paths go through here.
*/
*/
function teardownRoute(nextPath) {
function teardownRoute(nextPath) {
+ ovClearStrandedOverlays();
if (typeof destroyPlayer === 'function') destroyPlayer();
if (typeof destroyPlayer === 'function') destroyPlayer();
if (typeof destroyChat === 'function') destroyChat();
if (typeof destroyChat === 'function') destroyChat();
/* destroyCanvasPage lived in js/canvas.js, which index.html has not loaded since canvas moved
/* destroyCanvasPage lived in js/canvas.js, which index.html has not loaded since canvas moved
@@ -1243,10 +1264,127 @@ function routeFromURL() {
@@ -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) {
function showPage(page) {
+ const changed = currentPage !== page;
currentPage = page;
currentPage = page;
const el = document.getElementById(`page-${page}`);
const el = document.getElementById(`page-${page}`);
if (el) el.classList.add('active');
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
// Game/Canvas: hide footer only, keep navbar visible; other pages restore both
const navbar = document.querySelector('.navbar');
const navbar = document.querySelector('.navbar');
@@ -137,8 +137,18 @@
@@ -137,8 +137,18 @@
show(start, 1);
show(start, 1);
}
}
function close(finished) {
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;
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 { /* */ } }
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;
const closedId = j ? j.id : null;
@@ -227,7 +237,16 @@
@@ -227,7 +237,16 @@
return true;
return true;
}
}
const _push = history.pushState;
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(); }
function boot() { if (!fromUrl()) resumePending(); }
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => setTimeout(boot, 300)); else setTimeout(boot, 300);
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => setTimeout(boot, 300)); else setTimeout(boot, 300);