openvibers/openvibe.live Commits

Shared footer + tooltip system, API Docs rebrand, front-page viewport work

OP OpenVibers committed 16 Sept 2026 · b627c39768 · 8 files, +619 −129

The footer was a block of hand-written markup in index.html and ~60 lines of CSS that only
Live had. It is now openvibe-shared/footer.js — markup, styling, the Network column, the legal
column and the signed-in row all come from the shared package, and each site supplies only its
own links through footer-live.js. Two variants: the full five-column hub on browse and landing
pages, and a one-line compact bar on channels, players and the broadcast desk, where a giant
footer under a stream is just in the way. The variant follows SPA route changes.

openvibe-shared/tooltip.js is new alongside it: markup-driven rich tooltips (data-ovtip*),
one reused floating element positioned fixed so an overflow:hidden ancestor can never clip it,
flipping to stay on screen, and tap-to-open on touch. The "Vibe Coders" hero button is now
"API Docs" — what it actually links to — with a tooltip explaining you can point an AI at the
docs and build against the APIs without knowing how to code.

Front page: hero button idle animation is one shared clock instead of six independent ones, and
it only runs while the row is on screen, restarting each time it is reached. Home sections below
the fold get content-visibility so the browser can skip laying them out until needed.

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 1f9cf9ba..67d7304f 100644
---- a/public/css/home-fx.css
++++ b/public/css/home-fx.css
@@@ -41,7 +41,12 @@
background: linear-gradient(100deg, transparent, color-mix(in srgb, var(--c) 34%, transparent) 50%, transparent);
animation: hbWave var(--hb-wave) linear infinite;
animation-delay: calc(var(--i, 0) * var(--hb-step));
++ animation-play-state: paused;
}
++/* home-fx.js adds .fx-live when the row scrolls into view and restarts it, so the wave always
++ plays when someone arrives at the buttons instead of running unseen on a page-load clock. */
++#page-home .hero-cta.fx-live .hb::before { animation-play-state: running; }
++#page-home .hero-cta.fx-live .hb-ico { animation-play-state: running; }
.hb::after {
content: ''; position: absolute; inset: -1px; border-radius: inherit; z-index: -2; padding: 1px;
background: conic-gradient(from var(--a, 0deg), transparent 0 70%, var(--c) 85%, transparent 100%);
@@@ -56,6 +61,7 @@
box-shadow: 0 2px 6px rgba(0,0,0,0.35) inset;
transition: transform 0.35s cubic-bezier(.2,1.6,.3,1), background 0.25s, color 0.25s, box-shadow 0.25s;
animation: hbPulse var(--hb-wave) ease-in-out infinite; animation-delay: calc(var(--i, 0) * var(--hb-step));
++ animation-play-state: paused;
}
.hb-label { flex: 1; text-align: left; }
/* Chevron appears on hover/focus: an explicit "this goes somewhere" cue. */
@@@ -144,3 +150,9 @@
.rs-lite .cta-ambient { animation: none !important; filter: none !important; }
.rs-lite .cta-embers, .rs-lite .cta-robot-glow, .rs-lite .cta-owner-shine, .rs-lite .hero-collage { display: none !important; }
.rs-lite .hero-aurora, .rs-lite .hero-aurora::before, .rs-lite .hero-aurora::after { filter: none !important; mix-blend-mode: normal !important; animation: none !important; }
++
++
++/* ── Rendering budget ──────────────────────────────────────────
++ Below-the-fold sections are skipped by layout and paint until they approach the viewport.
++ contain-intrinsic-size keeps the scrollbar honest so the page doesn't jump as they resolve. */
++#page-home .home-section-lazy { content-visibility: auto; contain-intrinsic-size: auto 480px; }
ddiff --git a/public/css/style.css b/public/css/style.css
iindex 1d0ee8b5..211fffc8 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -6133,65 +6133,8 @@ textarea.form-input { resize: vertical; min-height: 60px; }
/* ═══════════════════════════════════════════════════════════════
FOOTER
═══════════════════════════════════════════════════════════════ */
--.footer {
-- background: var(--bg-secondary);
-- border-top: 1px solid var(--border);
-- /* The floating chat/DM buttons sit above the fold on phones — the old 8px footer put the
-- legal links underneath them, which is why Terms/Privacy/DMCA were cut off. */
-- padding: clamp(24px, 4vw, 40px) clamp(14px, 4vw, 32px) calc(clamp(20px, 3vw, 28px) + env(safe-area-inset-bottom, 0px));
-- margin-top: 40px;
--}
--.footer-inner { max-width: 1240px; margin: 0 auto; }
--.footer-cols {
-- display: grid; gap: clamp(20px, 3vw, 36px);
-- grid-template-columns: minmax(230px, 1.6fr) repeat(4, minmax(120px, 1fr));
-- align-items: start;
--}
--.footer-col { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
--.footer-col h3 {
-- margin: 0 0 2px; font-size: 0.72rem; font-weight: 800; letter-spacing: 0.1em;
-- text-transform: uppercase; color: var(--text-muted);
--}
--.footer-col a {
-- color: var(--text-secondary); font-size: 0.86rem; text-decoration: none; line-height: 1.4;
-- transition: color 0.15s, transform 0.15s; width: fit-content;
--}
--.footer-col a:hover { color: var(--accent); transform: translateX(2px); }
--.footer-brand {
-- display: inline-flex; align-items: center; gap: 9px; color: var(--text-primary);
-- font-size: 1.05rem; font-weight: 700; text-decoration: none; margin-bottom: 2px;
--}
--.footer-brand strong { color: var(--accent); font-weight: 800; }
--.footer-brand:hover { color: var(--text-primary); }
--.footer-tagline { margin: 0; font-size: 0.82rem; line-height: 1.55; color: var(--text-muted); max-width: 46ch; }
--.footer-social { display: flex; gap: 8px; margin-top: 4px; }
--.footer-social a {
-- width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center;
-- color: var(--text-secondary); background: var(--bg-tertiary); border: 1px solid var(--border);
-- font-size: 0.95rem; transition: color 0.15s, border-color 0.15s, transform 0.15s;
--}
--.footer-social a:hover { color: var(--accent); border-color: var(--accent); transform: translateY(-2px); }
--.footer-bar {
-- display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px;
-- margin-top: clamp(20px, 3vw, 30px); padding-top: 16px; border-top: 1px solid var(--border);
-- /* The floating DM/chat buttons are pinned to the bottom corners. Reserve their width until
-- the viewport is wide enough that the centred footer already clears them. */
-- padding-inline: clamp(0px, calc(84px - (100vw - 1240px) / 2), 84px);
--}
--.footer-copy { color: var(--text-muted); font-size: 0.78rem; }
--.footer-legal-inline { display: flex; gap: 16px; }
--.footer-legal-inline a { color: var(--text-muted); font-size: 0.78rem; text-decoration: none; }
--.footer-legal-inline a:hover { color: var(--accent); }
--@media (max-width: 900px) {
-- .footer-cols { grid-template-columns: repeat(2, minmax(0, 1fr)); }
-- .footer-col--brand { grid-column: 1 / -1; }
--}
--@media (max-width: 560px) {
-- /* Clear the floating chat buttons so nothing is hidden behind them. */
-- .footer { padding-bottom: calc(96px + env(safe-area-inset-bottom, 0px)); }
-- .footer-cols { gap: 22px 16px; }
-- .footer-bar { justify-content: center; text-align: center; padding-inline: 0; }
-- .footer-legal-inline { justify-content: center; }
++/* Footer styling now lives in openvibe-shared/footer.js so every site matches. */
++
}
/* ═══════════════════════════════════════════════════════════════
ddiff --git a/public/index.html b/public/index.html
iindex 475899f7..5c77e2f2 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -67,8 +67,8 @@
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="/css/messenger.css?v=1">
-- <link rel="stylesheet" href="/css/home-fx.css?v=8">
-- <link rel="stylesheet" href="/css/home-tour.css?v=2">
++ <link rel="stylesheet" href="/css/home-fx.css?v=9">
++ <link rel="stylesheet" href="/css/home-tour.css?v=4">
<link rel="stylesheet" href="/css/ov-guide.css?v=1">
<link rel="stylesheet" href="/css/i18n-star.css?v=5">
<link rel="stylesheet" href="/css/recap.css?v=1">
@@@ -279,16 +279,22 @@
</div>
</div>
</div>
-- <div class="hero-cta">
++ <div class="hero-cta" data-fx-viewport>
<button style="--i:0" class="hb hb-stream" onclick="currentUser ? navigate('/broadcast') : showModal('register')"><span class="hb-ico"><i class="fa-solid fa-broadcast-tower"></i></span><span class="hb-label">Stream</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></button>
<a href="https://openvibe.network" style="--i:1" class="hb hb-tools"><span class="hb-ico"><i class="fa-solid fa-screwdriver-wrench"></i></span><span class="hb-label">Tools</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
<a href="https://openvibe.games/game" style="--i:2" class="hb hb-game"><span class="hb-ico"><i class="fa-solid fa-gamepad"></i></span><span class="hb-label">Game</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
<a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank" style="--i:3" class="hb hb-github" rel="noopener"><span class="hb-ico"><i class="fa-brands fa-github"></i></span><span class="hb-label">GitHub</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
<a href="https://discord.gg/M6MuRUaeJj" target="_blank" style="--i:4" class="hb hb-discord" rel="noopener noreferrer"><span class="hb-ico"><i class="fa-brands fa-discord"></i></span><span class="hb-label">Discord</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
-- <a href="/documentation" onclick="return handleLinkClick(event, '/documentation')" style="--i:5" class="hb hb-vibe"><span class="hb-ico"><i class="fa-solid fa-wand-magic-sparkles"></i></span><span class="hb-label">Vibe Coders</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
++ <a href="/documentation" onclick="return handleLinkClick(event, '/documentation')" style="--i:5" class="hb hb-vibe"
++ data-ovtip-title="Built for vibe coders"
++ data-ovtip-icon="fa-wand-magic-sparkles"
++ data-ovtip-accent="#f472b6"
++ data-ovtip-place="top"
++ data-ovtip="Every feature here has an <b>open API</b> — chat, streams, clips, overlays, robots, the lot. <em>You don't need to know how to code.</em> Point an AI at these docs, describe the bot or overlay you want, and it writes it for you."
++ data-ovtip-foot="<i class=&quot;fa-solid fa-heart&quot;></i> Open here, good vibes — come build something"><span class="hb-ico"><i class="fa-solid fa-wand-magic-sparkles"></i></span><span class="hb-label">API Docs</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
</div>
<!-- One stream, everywhere — restream pipeline + feature tour (public/js/home-tour.js) -->
-- <div class="home-tour-mount" id="home-tour-mount"></div>
++ <div class="home-tour-mount home-section-lazy" id="home-tour-mount" data-fx-viewport></div>
<!-- Newest shipped commit — built in the open, receipts included. -->
<a class="hero-latest" id="hero-latest" href="/updates" onclick="return handleLinkClick(event, '/updates')" style="display:none"></a>
<div class="hero-philosophy-links">
@@@ -325,7 +331,7 @@
</div>
<!-- Community pulse: goals near completion, latest activity, weekly leaders -->
-- <div id="home-pulse-section" style="display:none">
++ <div class="home-section-lazy" id="home-pulse-section" style="display:none">
<div class="section-header">
<h2><i class="fa-solid fa-bolt"></i> Happening Now</h2>
</div>
@@@ -333,7 +339,7 @@
</div>
<!-- AI Moments showcase -->
-- <div id="home-moments-section" style="display:none">
++ <div class="home-section-lazy" id="home-moments-section" style="display:none">
<div class="section-header">
<h2><i class="fa-solid fa-brain"></i> AI Moments</h2>
<span class="muted" style="font-size:0.8rem">wild seconds the AI clipped from recent streams</span>
@@@ -5039,60 +5045,9 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- FOOTER -->
<!-- ═══════════════════════════════════════════════════════════ -->
--<footer class="footer">
-- <div class="footer-inner">
-- <div class="footer-cols">
-- <div class="footer-col footer-col--brand">
-- <a class="footer-brand" href="/" onclick="return handleLinkClick(event, '/')"><span class="ov-mark" data-size="26"></span> <span>OpenVibe<strong>.Live</strong></span></a>
-- <p class="footer-tagline">Free, open-source live streaming. Go live from a browser, OBS or a robot, restream everywhere, and keep your chat, emotes and clips in one place.</p>
-- <div class="footer-social">
-- <a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank" rel="noopener" title="OpenVibe.Live on GitHub" aria-label="GitHub"><i class="fa-brands fa-github"></i></a>
-- <a href="https://discord.gg/M6MuRUaeJj" target="_blank" rel="noopener noreferrer" title="Join the OpenVibe Discord" aria-label="Discord"><i class="fa-brands fa-discord"></i></a>
-- <a href="/sitemap.xml" title="Sitemap" aria-label="Sitemap"><i class="fa-solid fa-sitemap"></i></a>
-- </div>
-- </div>
-- <nav class="footer-col" aria-label="Watch">
-- <h3>Watch</h3>
-- <a href="/" onclick="return handleLinkClick(event, '/')">Live streams</a>
-- <a href="/vods" onclick="return handleLinkClick(event, '/vods')">Stream VODs</a>
-- <a href="/clips" onclick="return handleLinkClick(event, '/clips')">Clips</a>
-- <a href="/arena" onclick="return handleLinkClick(event, '/arena')">The Arena</a>
-- <a href="/chat" onclick="return handleLinkClick(event, '/chat')">Global chat</a>
-- </nav>
-- <nav class="footer-col" aria-label="Create">
-- <h3>Create</h3>
-- <a href="/broadcast" onclick="return handleLinkClick(event, '/broadcast')">Start streaming</a>
-- <a href="/broadcast?guide=golive:restream" onclick="event.preventDefault(); if (typeof startRestreamGuide === 'function') startRestreamGuide();">Set up restreams</a>
-- <a href="/dashboard" onclick="return handleLinkClick(event, '/dashboard')">Streamer dashboard</a>
-- <a href="/pastes" onclick="return handleLinkClick(event, '/pastes')">Pastes</a>
-- <a href="/documentation" onclick="return handleLinkClick(event, '/documentation')">Documentation</a>
-- </nav>
-- <nav class="footer-col" aria-label="The OpenVibe network">
-- <h3>Network</h3>
-- <a href="https://openvibe.tools" target="_blank" rel="noopener">Free online tools</a>
-- <a href="https://dev.openvibe.tools" target="_blank" rel="noopener">Developer tools</a>
-- <a href="https://net.openvibe.tools" target="_blank" rel="noopener">Network diagnostics</a>
-- <a href="https://img.openvibe.tools" target="_blank" rel="noopener">Image converter</a>
-- <a href="https://openvibe.network" target="_blank" rel="noopener">Account &amp; themes</a>
-- </nav>
-- <nav class="footer-col" aria-label="Legal">
-- <h3>Legal</h3>
-- <a href="/tos" onclick="return handleLinkClick(event, '/tos')">Terms of Service</a>
-- <a href="/privacy" onclick="return handleLinkClick(event, '/privacy')">Privacy Policy</a>
-- <a href="/dmca" onclick="return handleLinkClick(event, '/dmca')">DMCA</a>
-- <a href="/documentation#philosophy" onclick="return handleLinkClick(event, '/documentation')">What is this?</a>
-- </nav>
-- </div>
-- <div class="footer-bar">
-- <span class="footer-copy">Open source &amp; community driven &middot; no ads, no trackers, no investors</span>
-- <span class="footer-legal-inline">
-- <a href="/tos" onclick="return handleLinkClick(event, '/tos')">Terms</a>
-- <a href="/privacy" onclick="return handleLinkClick(event, '/privacy')">Privacy</a>
-- <a href="/dmca" onclick="return handleLinkClick(event, '/dmca')">DMCA</a>
-- </span>
-- </div>
-- </div>
--</footer>
++<!-- Shared cross-site footer (openvibe-shared/footer.js) — links, legal and the signed-in
++ row come from the shared module so every OpenVibe property stays in step. -->
++<footer id="ov-footer" class="ovf"></footer>
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- COSMETICS INVENTORY MODAL -->
@@@ -5209,10 +5164,13 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/draggable-fab.js?v=2" defer></script>
<script src="/js/messenger.js?v=2" defer></script>
<script src="/js/ov-mark.js?v=1" defer></script>
--<script src="/js/home-fx.js?v=3" defer></script>
--<script src="/js/home-tour.js?v=5" defer></script>
++<script src="/shared/tooltip.js" defer></script>
++<script src="/shared/footer.js" defer></script>
++<script src="/js/footer-live.js?v=1" defer></script>
++<script src="/js/home-fx.js?v=6" defer></script>
++<script src="/js/home-tour.js?v=6" defer></script>
<script src="/js/ov-guide.js?v=1" defer></script>
--<script src="/js/guides-live.js?v=1" defer></script>
++<script src="/js/guides-live.js?v=3" defer></script>
<!-- ── OpenVibe shared libs (notifications, account sync, themes) ── -->
<script src="/shared/theme-loader.js" defer></script>
ddiff --git a/public/js/footer-live.js b/public/js/footer-live.js
nnew file mode 100644
iindex 00000000..0e749712
---- /dev/null
++++ b/public/js/footer-live.js
@@@ -0,0 +1,71 @@
++/**
++ * footer-live.js — OpenVibe.Live's configuration for the shared cross-site footer.
++ *
++ * The footer itself (markup, styling, Network column, Legal column, signed-in row) comes from
++ * openvibe-shared/footer.js, which every OpenVibe property loads. This file supplies only the
++ * links that belong to Live, and decides which variant a page should use.
++ *
++ * Variant policy: a streamer's channel, a player and the broadcast desk are places people are
++ * *doing* something, so they get the one-line compact footer. Browse and landing pages, where
++ * someone may be looking for where to go next, get the full one.
++ */
++(function () {
++ 'use strict';
++ const COMPACT_PAGES = ['channel', 'vod-player', 'clip-player', 'broadcast', 'game', 'canvas', 'chat', 'arena', 'recap'];
++ const LINKS = [
++ {
++ heading: 'Watch',
++ items: [
++ { name: 'Live streams', url: '/', onclick: "return handleLinkClick(event, '/')" },
++ { name: 'Stream VODs', url: '/vods', onclick: "return handleLinkClick(event, '/vods')" },
++ { name: 'Clips', url: '/clips', onclick: "return handleLinkClick(event, '/clips')" },
++ { name: 'The Arena', url: '/arena', onclick: "return handleLinkClick(event, '/arena')" },
++ { name: 'Global chat', url: '/chat', onclick: "return handleLinkClick(event, '/chat')" },
++ ],
++ },
++ {
++ heading: 'Create',
++ items: [
++ { name: 'Start streaming', url: '/broadcast', onclick: "return handleLinkClick(event, '/broadcast')" },
++ { name: 'Set up restreams', url: '/broadcast?guide=golive:restream', onclick: "event.preventDefault(); if (typeof startRestreamGuide === 'function') startRestreamGuide(); return false;" },
++ { name: 'Streamer dashboard', url: '/dashboard', onclick: "return handleLinkClick(event, '/dashboard')" },
++ { name: 'Pastes', url: '/pastes', onclick: "return handleLinkClick(event, '/pastes')" },
++ { name: 'API docs', url: '/documentation', onclick: "return handleLinkClick(event, '/documentation')" },
++ ],
++ },
++ ];
++
++ /** Which footer this page deserves — compact where the footer is not the point. */
++ function variantForPage() {
++ const p = location.pathname;
++ if (p === '/' || p === '') return 'full';
++ if (/^\/@/.test(p)) return 'compact'; // streamer channels
++ const seg = p.split('/').filter(Boolean)[0] || '';
++ if (COMPACT_PAGES.includes(seg)) return 'compact';
++ if (seg === 'vod' || seg === 'clip' || seg === 'recap') return 'compact';
++ return 'full';
++ }
++
++ function apply() {
++ if (!window.OpenVibeFooter) return;
++ OpenVibeFooter.setVariant(variantForPage());
++ }
++
++ function boot() {
++ if (!window.OpenVibeFooter) return setTimeout(boot, 120);
++ OpenVibeFooter.init({
++ service: 'live',
++ brandName: 'OpenVibe.Live',
++ tagline: 'Free, open-source live streaming. Go live from a browser, OBS or a robot, restream everywhere, and keep your chat, emotes and clips in one place.',
++ legalBase: location.origin,
++ variant: variantForPage(),
++ links: LINKS,
++ });
++ // SPA route changes swap the variant without a reload.
++ const push = history.pushState;
++ history.pushState = function () { const r = push.apply(this, arguments); setTimeout(apply, 40); return r; };
++ window.addEventListener('popstate', () => setTimeout(apply, 40));
++ }
++
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
++})();
ddiff --git a/public/js/home-fx.js b/public/js/home-fx.js
iindex 8f962c55..ad6fc0d4 100644
---- a/public/js/home-fx.js
++++ b/public/js/home-fx.js
@@@ -15,14 +15,19 @@
const REDUCED = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const COARSE = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;
if (REDUCED) return;
-- if (COARSE || window.innerWidth <= 820 || (navigator.deviceMemory && navigator.deviceMemory <= 4)) { document.documentElement.classList.add('rs-lite'); return; }
++ // Phones, coarse pointers and low-memory devices run in "lite" mode: no particle canvas, no
++ // pointer spotlight, no tilt. They still get the cheap IntersectionObserver work, because the
++ // viewport gate below is the only thing that starts the hero button wave — bailing out of the
++ // whole module here is why those buttons sat frozen on a phone.
++ const LITE = COARSE || window.innerWidth <= 820 || (navigator.deviceMemory && navigator.deviceMemory <= 4);
++ if (LITE) document.documentElement.classList.add('rs-lite');
const onHome = () => { const p = document.getElementById('page-home'); return !!(p && p.classList.contains('active')); };
// ── Hero sparks canvas ─────────────────────────────────────
let sparksStop = null;
function startSparks() {
-- if (sparksStop || COARSE || window.innerWidth < 720) return;
++ if (sparksStop || LITE || window.innerWidth < 720) return;
const hero = document.querySelector('#page-home .hero');
if (!hero) return;
const canvas = document.createElement('canvas');
@@@ -64,6 +69,7 @@
// ── Cursor spotlight + magnetic buttons ─────────────────────
function attachHeroPointer() {
++ if (LITE) return;
const hero = document.querySelector('#page-home .hero');
if (!hero || hero.dataset.hfx) return;
hero.dataset.hfx = '1';
@@@ -82,12 +88,22 @@
}
// ── Scroll reveal ──────────────────────────────────────────
-- let io = null;
++ let io = null, revealSafety = 0;
function attachReveal() {
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; el.classList.add('hfx-reveal'); el.style.setProperty('--d', `${(i % 6) * 60}ms`); io.observe(el); });
++ // Failsafe. A revealed element starts at opacity 0, so anything the observer never reports
++ // on would stay invisible — a skipped content-visibility subtree or a restored page can do
++ // that. One late pass shows whatever is already on screen regardless.
++ clearTimeout(revealSafety);
++ revealSafety = setTimeout(() => {
++ document.querySelectorAll('.hfx-reveal:not(.is-in)').forEach(el => {
++ const r = el.getBoundingClientRect();
++ if (r.height && r.top < window.innerHeight && r.bottom > 0) el.classList.add('is-in');
++ });
++ }, 2500);
}
// ── Count-up on the hero stats ─────────────────────────────
@@@ -114,7 +130,7 @@
// ── Stream card tilt ───────────────────────────────────────
function attachTilt() {
-- if (COARSE) return;
++ if (LITE) return;
document.addEventListener('pointermove', (e) => {
const card = e.target.closest && e.target.closest('#page-home .stream-card');
if (!card) return;
@@@ -128,14 +144,51 @@
// ── Headline shimmer ───────────────────────────────────────
function attachHeadline() {
++ if (LITE) return; // an infinite gradient sweep over text is a real battery cost on a phone
const h1 = document.querySelector('#page-home .hero h1');
if (h1) h1.classList.add('hfx-shimmer');
const rot = document.getElementById('hero-rotate');
if (rot) rot.classList.add('hfx-glow');
}
++ /**
++ * Run expensive idle animations only while they are actually visible.
++ *
++ * Two problems this solves. First, the hero button wave is on a page-load clock: scroll down
++ * on a phone, come back, and you land in the quiet part of the cycle having seen nothing.
++ * Restarting it on entry means the wave always plays when someone arrives at it. Second,
++ * animating off-screen costs battery and main-thread time for something nobody can see.
++ */
++ function attachViewportAnimations() {
++ if (!('IntersectionObserver' in window)) {
++ document.querySelectorAll('[data-fx-viewport]').forEach(el => el.classList.add('fx-live'));
++ return;
++ }
++ const io = new IntersectionObserver((entries) => {
++ for (const en of entries) {
++ const el = en.target;
++ if (en.isIntersecting) {
++ // Re-trigger from the top so the effect is seen, not joined mid-cycle.
++ el.classList.remove('fx-live');
++ void el.offsetWidth;
++ el.classList.add('fx-live');
++ } else {
++ el.classList.remove('fx-live');
++ }
++ }
++ }, { rootMargin: '0px 0px 0px 0px', threshold: 0.2 });
++ const watch = () => document.querySelectorAll('[data-fx-viewport]:not([data-fx-bound])').forEach(el => {
++ el.setAttribute('data-fx-bound', '1');
++ io.observe(el);
++ });
++ watch();
++ // Sections mount asynchronously, so pick up anything that arrives later.
++ try { new MutationObserver(watch).observe(document.body, { childList: true, subtree: true }); } catch { /* */ }
++ }
++
function boot() {
-- attachHeroPointer(); attachHeadline(); attachCountUp(); attachTilt();
++ attachHeroPointer();
++ attachViewportAnimations(); attachHeadline(); attachCountUp(); attachTilt();
const apply = () => { if (onHome()) { startSparks(); attachReveal(); } else if (sparksStop) sparksStop(); };
apply();
const page = document.getElementById('page-home');
ddiff --git a/server/index.js b/server/index.js
iindex e54db080..8088c6ad 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -415,7 +415,7 @@ app.use((req, res, next) => {
// Serve openvibe-shared browser assets at /shared/ — serve directly from the
// resolved source directory to avoid write operations (the public/ directory
// is read-only under systemd ProtectSystem=strict on production).
--const SHARED_BROWSER_FILES = ['theme-loader.js', 'notification-ui.js', 'account-switcher.js', 'openvibe-sw.js'];
++const SHARED_BROWSER_FILES = ['theme-loader.js', 'notification-ui.js', 'account-switcher.js', 'openvibe-sw.js', 'footer.js', 'ov-mark.js', 'tooltip.js'];
let sharedServePath = null;
(function resolveSharedAssets() {
ddiff --git a/vendor/openvibe-shared/footer.js b/vendor/openvibe-shared/footer.js
nnew file mode 100644
iindex 00000000..bccc6a11
---- /dev/null
++++ b/vendor/openvibe-shared/footer.js
@@@ -0,0 +1,299 @@
++/**
++ * openvibe-shared/footer.js — the cross-site footer, the counterpart to navbar.js.
++ *
++ * OpenVibeFooter.init({ service, variant, links, apiBase, mount })
++ * OpenVibeFooter.setVariant('full' | 'compact') switch at runtime (SPA route changes)
++ * OpenVibeFooter.buildHTML(cfg) markup only — usable from Node for SSR
++ *
++ * Why this exists: every property was hand-rolling its own footer, so the network links, the
++ * legal links and the styling drifted apart, and a site that gained a new sibling had to be
++ * edited by hand. Here the site passes only the links that are *its own*; the Network column,
++ * the Legal column, the signed-in row and all the styling come from this module, so adding a
++ * property or changing a policy link updates every site at once.
++ *
++ * Variants:
++ * full the multi-column footer, for home and browse pages
++ * compact one quiet line, for pages where the footer is not the point — a streamer's
++ * channel, a player, a tool someone is mid-task in
++ *
++ * Signed-in visitors get a cross-site row: their account, their channel, themes and the tools
++ * hub, so the network behaves like one product instead of a ring of separate sites. It reads
++ * the shared ov_token the same way navbar.js does and never blocks rendering on it.
++ */
++(function (root, factory) {
++ const api = factory();
++ if (typeof module === 'object' && module.exports) module.exports = api; // Node (SSR)
++ if (typeof window !== 'undefined') window.OpenVibeFooter = api; // browser
++})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
++ 'use strict';
++
++ const NETWORK_URL = 'https://openvibe.network';
++ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++
++ // One place that knows what the network contains. Adding a property here gives every site
++ // the link, with no edit anywhere else.
++ const NETWORK = [
++ { id: 'live', name: 'Live streaming', url: 'https://openvibe.live', desc: 'Go live, restream and clip' },
++ { id: 'tools', name: 'Free online tools', url: 'https://openvibe.tools', desc: 'The whole toolbox' },
++ { id: 'dev', name: 'Developer tools', url: 'https://dev.openvibe.tools', desc: 'Formatters, encoders, validators' },
++ { id: 'net', name: 'Network diagnostics', url: 'https://net.openvibe.tools', desc: 'Ping, DNS, Whois, SSL' },
++ { id: 'img', name: 'Image converter', url: 'https://img.openvibe.tools', desc: 'Convert, resize, compress' },
++ { id: 'audio', name: 'Audio converter', url: 'https://audio.openvibe.tools', desc: 'Convert, trim, pitch-shift' },
++ { id: 'docs', name: 'PDF & document tools', url: 'https://docs.openvibe.tools', desc: 'Merge, split, compress' },
++ { id: 'text', name: 'Text tools', url: 'https://text.openvibe.tools', desc: 'Fancy text, case, ASCII' },
++ { id: 'yt', name: 'YouTube downloader', url: 'https://yt.openvibe.tools', desc: 'Video and audio' },
++ { id: 'network', name: 'Account & themes', url: NETWORK_URL, desc: 'One account, every site' },
++ ];
++
++ const LEGAL = [
++ { name: 'Terms of Service', path: '/tos', fallback: 'https://openvibe.live/tos' },
++ { name: 'Privacy Policy', path: '/privacy', fallback: 'https://openvibe.live/privacy' },
++ { name: 'DMCA', path: '/dmca', fallback: 'https://openvibe.live/dmca' },
++ ];
++
++ const SOCIAL = [
++ { name: 'GitHub', icon: 'fa-brands fa-github', url: 'https://github.com/OpenVibers' },
++ { name: 'Discord', icon: 'fa-brands fa-discord', url: 'https://discord.gg/M6MuRUaeJj' },
++ ];
++
++ const DEFAULTS = {
++ service: null, // 'live' | 'tools' | 'dev' | … (auto-detected when omitted)
++ brandName: null,
++ tagline: 'Free, open source and community run. Built in the open by the people using it.',
++ variant: 'full',
++ mount: '#ov-footer',
++ links: [], // [{ heading, items: [{ name, url, onclick? }] }]
++ legalBase: null, // origin that hosts /tos, /privacy, /dmca for this site
++ apiBase: NETWORK_URL,
++ user: null,
++ showNetwork: true,
++ showAccount: true,
++ sitemap: '/sitemap.xml',
++ };
++
++ let _cfg = { ...DEFAULTS };
++ let _mounted = null;
++
++ function detectService() {
++ if (typeof location === 'undefined') return 'network';
++ const h = location.hostname;
++ if (h.endsWith('openvibe.live')) return 'live';
++ if (h.endsWith('openvibe.network')) return 'network';
++ if (h.endsWith('openvibe.games')) return 'games';
++ const sub = h.replace(/\.openvibe\.tools$/, '');
++ return sub === h ? 'tools' : (sub || 'tools');
++ }
++
++ function brandFor(service) {
++ const found = NETWORK.find(n => n.id === service);
++ if (found) return found.name === 'Account & themes' ? 'OpenVibe' : found.name;
++ return 'OpenVibe';
++ }
++
++ function legalHref(item, cfg) {
++ // Policies live on whichever site owns them; a tool subdomain links out rather than 404ing.
++ if (cfg.legalBase) return cfg.legalBase.replace(/\/$/, '') + item.path;
++ if (typeof location !== 'undefined' && location.hostname.endsWith('openvibe.live')) return item.path;
++ return item.fallback;
++ }
++
++ // ── Markup ─────────────────────────────────────────────────
++ function linkTag(item) {
++ const rel = /^https?:\/\//.test(item.url) ? ' target="_blank" rel="noopener"' : '';
++ const onclick = item.onclick ? ` onclick="${esc(item.onclick)}"` : '';
++ const title = item.desc ? ` title="${esc(item.desc)}"` : '';
++ return `<a href="${esc(item.url)}"${rel}${onclick}${title}>${esc(item.name)}</a>`;
++ }
++
++ function buildHTML(cfg) {
++ const c = { ...DEFAULTS, ...(cfg || {}) };
++ const service = c.service || detectService();
++ const brand = c.brandName || brandFor(service);
++ const network = NETWORK.filter(n => n.id !== service);
++ const year = new Date().getFullYear();
++
++ if (c.variant === 'compact') {
++ return `<div class="ovf-inner ovf-inner--compact">
++ <a class="ovf-brand ovf-brand--sm" href="/"><span class="ov-mark" data-size="20"></span><span>${esc(brand)}</span></a>
++ <nav class="ovf-compact-links" aria-label="Site links">
++ ${(c.links[0] ? c.links[0].items.slice(0, 3) : []).map(linkTag).join('')}
++ <a href="${NETWORK_URL}" target="_blank" rel="noopener">Network</a>
++ ${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name.replace(' of Service', '').replace(' Policy', ''))}</a>`).join('')}
++ </nav>
++ <span class="ovf-compact-meta">Open source · ${year}</span>
++ </div>`;
++ }
++
++ const cols = (c.links || []).map(group => `
++ <nav class="ovf-col" aria-label="${esc(group.heading)}">
++ <h3>${esc(group.heading)}</h3>
++ ${group.items.map(linkTag).join('')}
++ </nav>`).join('');
++
++ return `<div class="ovf-inner">
++ <div class="ovf-cols">
++ <div class="ovf-col ovf-col--brand">
++ <a class="ovf-brand" href="/"><span class="ov-mark" data-size="26"></span><span>${esc(brand)}</span></a>
++ <p class="ovf-tagline">${esc(c.tagline)}</p>
++ <div class="ovf-social">
++ ${SOCIAL.map(s => `<a href="${s.url}" target="_blank" rel="noopener" aria-label="${s.name}" title="${s.name}"><i class="${s.icon}"></i></a>`).join('')}
++ <a href="${esc(c.sitemap)}" aria-label="Sitemap" title="Sitemap"><i class="fa-solid fa-sitemap"></i></a>
++ </div>
++ </div>
++ ${cols}
++ ${c.showNetwork ? `<nav class="ovf-col" aria-label="The OpenVibe network">
++ <h3>Network</h3>
++ ${network.slice(0, 6).map(linkTag).join('')}
++ <a class="ovf-more" href="${NETWORK_URL}" target="_blank" rel="noopener">Everything else <i class="fa-solid fa-arrow-right"></i></a>
++ </nav>` : ''}
++ <nav class="ovf-col" aria-label="Legal">
++ <h3>Legal</h3>
++ ${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name)}</a>`).join('')}
++ </nav>
++ </div>
++ <div class="ovf-account" id="ovf-account" hidden></div>
++ <div class="ovf-bar">
++ <span class="ovf-copy">Open source &amp; community driven · built in the open</span>
++ <span class="ovf-legal-inline">
++ ${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name.replace(' of Service', '').replace(' Policy', ''))}</a>`).join('')}
++ </span>
++ </div>
++ </div>`;
++ }
++
++ // ── Signed-in row: the same account, wherever you are ──────
++ function accountHTML(user, service) {
++ const name = esc(user.display_name || user.username || 'you');
++ const avatar = user.avatar_url
++ ? `<img src="${esc(user.avatar_url)}" alt="">`
++ : `<span>${esc(String(name).charAt(0).toUpperCase())}</span>`;
++ const quick = [
++ service !== 'live' ? { name: 'My channel', url: `https://openvibe.live/@${esc(user.username || '')}`, icon: 'fa-tower-broadcast' } : { name: 'My dashboard', url: '/dashboard', icon: 'fa-gauge-high' },
++ { name: 'Account', url: `${NETWORK_URL}/my`, icon: 'fa-user' },
++ { name: 'Themes', url: `${NETWORK_URL}/themes`, icon: 'fa-palette' },
++ { name: 'Tools', url: 'https://openvibe.tools', icon: 'fa-screwdriver-wrench' },
++ ];
++ return `<span class="ovf-account-who"><span class="ovf-avatar">${avatar}</span> Signed in as <strong>${name}</strong> across OpenVibe</span>
++ <span class="ovf-account-links">${quick.map(q => `<a href="${esc(q.url)}"${/^https?:/.test(q.url) ? ' target="_blank" rel="noopener"' : ''}><i class="fa-solid ${q.icon}"></i> ${esc(q.name)}</a>`).join('')}</span>`;
++ }
++
++ function storedToken() {
++ try {
++ const m = document.cookie.match(/(?:^|; )ov_token=([^;]*)/);
++ return (m && decodeURIComponent(m[1])) || localStorage.getItem('ov_token') || localStorage.getItem('token') || null;
++ } catch { return null; }
++ }
++
++ async function fillAccount(cfg) {
++ const host = document.getElementById('ovf-account');
++ if (!host || !cfg.showAccount || cfg.variant === 'compact') return;
++ let user = cfg.user;
++ if (!user) {
++ const token = storedToken();
++ if (!token) return;
++ try {
++ const res = await fetch(`${cfg.apiBase}/api/auth/me`, { headers: { Authorization: 'Bearer ' + token }, credentials: 'include' });
++ if (!res.ok) return;
++ const data = await res.json();
++ user = data.user || data;
++ } catch { return; }
++ }
++ if (!user || !user.username) return;
++ host.innerHTML = accountHTML(user, cfg.service || detectService());
++ host.hidden = false;
++ }
++
++ // ── Styles (injected once) ─────────────────────────────────
++ const CSS = `
++.ovf{background:var(--bg-secondary,#12131c);border-top:1px solid var(--border,rgba(255,255,255,.08));margin-top:40px;
++ padding:clamp(24px,4vw,40px) clamp(14px,4vw,32px) calc(clamp(20px,3vw,28px) + env(safe-area-inset-bottom,0px));}
++.ovf[data-variant="compact"]{margin-top:24px;padding:14px clamp(12px,3vw,24px) calc(14px + env(safe-area-inset-bottom,0px));}
++.ovf-inner{max-width:1240px;margin:0 auto}
++.ovf-inner--compact{display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:8px 18px;text-align:center}
++.ovf-cols{display:grid;gap:clamp(20px,3vw,36px);grid-template-columns:minmax(230px,1.6fr) repeat(auto-fit,minmax(120px,1fr));align-items:start}
++.ovf-col{display:flex;flex-direction:column;gap:8px;min-width:0}
++.ovf-col h3{margin:0 0 2px;font-size:.72rem;font-weight:800;letter-spacing:.1em;text-transform:uppercase;color:var(--text-muted,#8b93ad)}
++.ovf-col a{color:var(--text-secondary,#b6bdd2);font-size:.86rem;text-decoration:none;line-height:1.4;width:fit-content;transition:color .15s,transform .15s}
++.ovf-col a:hover{color:var(--accent,#a78bfa);transform:translateX(2px)}
++.ovf-more{font-weight:600;opacity:.85}.ovf-more i{font-size:.7rem;transition:transform .15s}.ovf-more:hover i{transform:translateX(3px)}
++.ovf-brand{display:inline-flex;align-items:center;gap:9px;color:var(--text-primary,#f1f4fb);font-size:1.05rem;font-weight:700;text-decoration:none}
++.ovf-brand--sm{font-size:.9rem}
++.ovf-tagline{margin:0;font-size:.82rem;line-height:1.55;color:var(--text-muted,#8b93ad);max-width:46ch}
++.ovf-social{display:flex;gap:8px;margin-top:4px}
++.ovf-social a{width:36px;height:36px;border-radius:10px;display:grid;place-items:center;color:var(--text-secondary,#b6bdd2);
++ background:var(--bg-tertiary,#1a1b28);border:1px solid var(--border,rgba(255,255,255,.08));font-size:.95rem;transition:color .15s,border-color .15s,transform .15s}
++.ovf-social a:hover{color:var(--accent,#a78bfa);border-color:var(--accent,#a78bfa);transform:translateY(-2px)}
++.ovf-account{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px 18px;
++ margin-top:clamp(18px,2.5vw,26px);padding:10px 14px;border-radius:14px;
++ background:color-mix(in srgb, var(--accent,#a78bfa) 8%, var(--bg-tertiary,#1a1b28));
++ border:1px solid color-mix(in srgb, var(--accent,#a78bfa) 26%, var(--border,rgba(255,255,255,.08)));
++ animation:ovfIn .4s ease-out both}
++@keyframes ovfIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
++.ovf-account-who{display:inline-flex;align-items:center;gap:8px;font-size:.84rem;color:var(--text-secondary,#b6bdd2)}
++.ovf-account-who strong{color:var(--text-primary,#f1f4fb)}
++.ovf-avatar{width:24px;height:24px;border-radius:50%;overflow:hidden;display:grid;place-items:center;
++ background:var(--accent,#a78bfa);color:#fff;font-size:.7rem;font-weight:800;flex:none}
++.ovf-avatar img{width:100%;height:100%;object-fit:cover}
++.ovf-account-links{display:flex;flex-wrap:wrap;gap:6px}
++.ovf-account-links a{display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border-radius:999px;font-size:.78rem;font-weight:600;
++ color:var(--text-secondary,#b6bdd2);background:var(--bg-secondary,#12131c);border:1px solid var(--border,rgba(255,255,255,.08));
++ text-decoration:none;transition:color .15s,border-color .15s,transform .15s}
++.ovf-account-links a:hover{color:var(--accent,#a78bfa);border-color:var(--accent,#a78bfa);transform:translateY(-1px)}
++.ovf-account-links i{font-size:.72rem;opacity:.8}
++.ovf-bar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px;
++ margin-top:clamp(20px,3vw,30px);padding-top:16px;border-top:1px solid var(--border,rgba(255,255,255,.08));
++ padding-inline:clamp(0px, calc(84px - (100vw - 1240px) / 2), 84px)}
++.ovf-copy{color:var(--text-muted,#8b93ad);font-size:.78rem}
++.ovf-legal-inline,.ovf-compact-links{display:flex;flex-wrap:wrap;gap:14px}
++.ovf-legal-inline a,.ovf-compact-links a{color:var(--text-muted,#8b93ad);font-size:.78rem;text-decoration:none;transition:color .15s}
++.ovf-legal-inline a:hover,.ovf-compact-links a:hover{color:var(--accent,#a78bfa)}
++.ovf-compact-meta{color:var(--text-muted,#8b93ad);font-size:.74rem}
++@media (max-width:900px){.ovf-cols{grid-template-columns:repeat(2,minmax(0,1fr))}.ovf-col--brand{grid-column:1/-1}}
++@media (max-width:560px){
++ .ovf{padding-bottom:calc(96px + env(safe-area-inset-bottom,0px))}
++ .ovf[data-variant="compact"]{padding-bottom:calc(84px + env(safe-area-inset-bottom,0px))}
++ .ovf-cols{gap:22px 16px}
++ .ovf-bar{justify-content:center;text-align:center;padding-inline:0}
++ .ovf-legal-inline{justify-content:center}
++ .ovf-account{flex-direction:column;align-items:flex-start}
++}
++@media (prefers-reduced-motion:reduce){.ovf-account{animation:none}.ovf-col a:hover,.ovf-social a:hover,.ovf-account-links a:hover{transform:none}}`;
++
++ function injectCSS() {
++ if (typeof document === 'undefined' || document.getElementById('ovf-css')) return;
++ const st = document.createElement('style');
++ st.id = 'ovf-css';
++ st.textContent = CSS;
++ document.head.appendChild(st);
++ }
++
++ // ── Mount ──────────────────────────────────────────────────
++ function render() {
++ if (!_mounted) return;
++ _mounted.className = 'ovf ' + (_mounted.dataset.extraClass || '');
++ _mounted.setAttribute('data-variant', _cfg.variant);
++ _mounted.innerHTML = buildHTML(_cfg);
++ try { if (typeof window !== 'undefined' && typeof window.ovMarkMount === 'function') window.ovMarkMount(_mounted); } catch { /* */ }
++ fillAccount(_cfg);
++ }
++
++ function init(cfg = {}) {
++ _cfg = { ...DEFAULTS, ..._cfg, ...cfg };
++ _cfg.service = _cfg.service || detectService();
++ injectCSS();
++ const el = typeof _cfg.mount === 'string' ? document.querySelector(_cfg.mount) : _cfg.mount;
++ if (!el) return null;
++ _mounted = el;
++ render();
++ return el;
++ }
++
++ function setVariant(variant) {
++ if (variant !== 'full' && variant !== 'compact') return;
++ if (_cfg.variant === variant) return;
++ _cfg.variant = variant;
++ render();
++ }
++
++ return { init, setVariant, buildHTML, render, CSS, NETWORK, LEGAL, detectService, get config() { return { ..._cfg }; } };
++});
ddiff --git a/vendor/openvibe-shared/tooltip.js b/vendor/openvibe-shared/tooltip.js
nnew file mode 100644
iindex 00000000..9a3a055b
---- /dev/null
++++ b/vendor/openvibe-shared/tooltip.js
@@@ -0,0 +1,154 @@
++/**
++ * openvibe-shared/tooltip.js — rich tooltips for every OpenVibe property.
++ *
++ * Markup-driven, so a site never writes tooltip code:
++ *
++ * <a data-ovtip="Plain text tip">…</a>
++ * <a data-ovtip-title="API docs"
++ * data-ovtip="<b>Vibe code it.</b> Point an AI at these docs…"
++ * data-ovtip-icon="fa-wand-magic-sparkles"
++ * data-ovtip-place="top"
++ * data-ovtip-accent="#f472b6">…</a>
++ *
++ * One floating element is reused for every tooltip, positioned with fixed coordinates so it is
++ * never clipped by an overflow:hidden ancestor — the usual reason CSS-only tooltips get cut off
++ * inside cards and scroll containers. It flips to stay on screen, follows the trigger while the
++ * page scrolls, opens on hover, focus and long-press, and closes on Escape, scroll-away or tap
++ * elsewhere. Touch devices get a tap-to-open behaviour instead of hover, which is what makes
++ * these usable on a phone at all.
++ */
++(function () {
++ 'use strict';
++ if (typeof window === 'undefined' || window.OpenVibeTooltip) return;
++
++ const CSS = `
++.ovtip{position:fixed;z-index:30000;max-width:min(330px,calc(100vw - 24px));padding:12px 14px;border-radius:14px;
++ background:linear-gradient(165deg,var(--bg-card,#171826) 0%,var(--bg-secondary,#12131c) 100%);
++ border:1px solid color-mix(in srgb, var(--ovtip-accent,var(--accent,#a78bfa)) 45%, var(--border,rgba(255,255,255,.1)));
++ box-shadow:0 20px 50px rgba(0,0,0,.55),0 0 0 1px rgba(255,255,255,.04),0 0 30px color-mix(in srgb, var(--ovtip-accent,var(--accent,#a78bfa)) 22%, transparent);
++ color:var(--text-secondary,#b6bdd2);font-size:.83rem;line-height:1.55;pointer-events:none;opacity:0;
++ transform:translateY(6px) scale(.97);transition:opacity .18s ease,transform .22s cubic-bezier(.2,1.3,.3,1);
++ overflow:hidden}
++.ovtip.is-on{opacity:1;transform:none}
++.ovtip.is-interactive{pointer-events:auto}
++.ovtip::before{content:'';position:absolute;inset:0;pointer-events:none;
++ background:radial-gradient(120% 90% at 0% 0%, color-mix(in srgb, var(--ovtip-accent,var(--accent,#a78bfa)) 16%, transparent), transparent 60%)}
++/* A light sweep on open — draws the eye without moving anything around it. */
++.ovtip::after{content:'';position:absolute;top:0;bottom:0;left:-60%;width:45%;pointer-events:none;
++ background:linear-gradient(100deg,transparent,color-mix(in srgb, var(--ovtip-accent,var(--accent,#a78bfa)) 26%, transparent),transparent)}
++.ovtip.is-on::after{animation:ovtipSweep .9s cubic-bezier(.3,.7,.3,1) .1s 1}
++@keyframes ovtipSweep{to{transform:translateX(420%)}}
++.ovtip-head{display:flex;align-items:center;gap:8px;margin-bottom:6px;position:relative}
++.ovtip-ico{width:26px;height:26px;border-radius:8px;display:grid;place-items:center;flex:none;font-size:.78rem;color:#fff;
++ background:linear-gradient(135deg,var(--ovtip-accent,var(--accent,#a78bfa)),color-mix(in srgb,var(--ovtip-accent,var(--accent,#a78bfa)) 55%,#000));
++ box-shadow:0 4px 12px color-mix(in srgb, var(--ovtip-accent,var(--accent,#a78bfa)) 45%, transparent)}
++.ovtip-title{font-size:.9rem;font-weight:800;color:var(--text-primary,#f1f4fb);letter-spacing:-.01em}
++.ovtip-body{position:relative}
++.ovtip-body b,.ovtip-body strong{color:var(--text-primary,#f1f4fb);font-weight:700}
++.ovtip-body em{color:var(--ovtip-accent,var(--accent,#a78bfa));font-style:normal;font-weight:600}
++.ovtip-body code{background:var(--bg-tertiary,#1a1b28);padding:1px 6px;border-radius:5px;font-size:.78rem}
++.ovtip-foot{margin-top:8px;padding-top:8px;border-top:1px dashed var(--border,rgba(255,255,255,.1));
++ font-size:.74rem;color:var(--text-muted,#8b93ad);display:flex;align-items:center;gap:6px;position:relative}
++.ovtip-arrow{position:fixed;z-index:30001;width:10px;height:10px;transform:rotate(45deg);opacity:0;transition:opacity .18s;
++ background:var(--bg-card,#171826);border:1px solid color-mix(in srgb, var(--ovtip-accent,var(--accent,#a78bfa)) 45%, var(--border,rgba(255,255,255,.1)))}
++.ovtip-arrow.is-on{opacity:1}
++.ovtip-arrow.from-top{border-top:0;border-left:0}
++.ovtip-arrow.from-bottom{border-bottom:0;border-right:0}
++@media (prefers-reduced-motion:reduce){.ovtip{transition:opacity .12s}.ovtip.is-on::after{animation:none}}`;
++
++ let tip = null, arrow = null, current = null, raf = 0, hideTimer = 0;
++ const isTouch = () => window.matchMedia('(hover: none)').matches;
++
++ function ensure() {
++ if (tip) return;
++ const st = document.createElement('style'); st.id = 'ovtip-css'; st.textContent = CSS; document.head.appendChild(st);
++ tip = document.createElement('div'); tip.className = 'ovtip'; tip.setAttribute('role', 'tooltip');
++ arrow = document.createElement('div'); arrow.className = 'ovtip-arrow';
++ document.body.append(arrow, tip);
++ tip.addEventListener('pointerenter', () => clearTimeout(hideTimer));
++ tip.addEventListener('pointerleave', () => hide());
++ }
++
++ function content(el) {
++ const title = el.getAttribute('data-ovtip-title');
++ const icon = el.getAttribute('data-ovtip-icon');
++ const body = el.getAttribute('data-ovtip') || '';
++ const foot = el.getAttribute('data-ovtip-foot');
++ return `${title ? `<div class="ovtip-head">${icon ? `<span class="ovtip-ico"><i class="fa-solid ${icon}"></i></span>` : ''}<span class="ovtip-title">${title}</span></div>` : ''}
++ <div class="ovtip-body">${body}</div>
++ ${foot ? `<div class="ovtip-foot">${foot}</div>` : ''}`;
++ }
++
++ function place() {
++ if (!current || !tip) return;
++ if (!current.isConnected) return hide();
++ const r = current.getBoundingClientRect();
++ // Off-screen trigger: nothing to point at.
++ if (r.bottom < -40 || r.top > window.innerHeight + 40) return hide();
++ const tw = tip.offsetWidth, th = tip.offsetHeight, gap = 12;
++ const prefer = current.getAttribute('data-ovtip-place') || 'top';
++ let top, from;
++ const fitsAbove = r.top - th - gap > 8;
++ const fitsBelow = r.bottom + th + gap < window.innerHeight - 8;
++ if ((prefer === 'top' && fitsAbove) || (!fitsBelow && fitsAbove)) { top = r.top - th - gap; from = 'from-bottom'; }
++ else { top = r.bottom + gap; from = 'from-top'; }
++ let left = r.left + r.width / 2 - tw / 2;
++ left = Math.max(12, Math.min(left, window.innerWidth - tw - 12));
++ tip.style.top = `${Math.round(top)}px`;
++ tip.style.left = `${Math.round(left)}px`;
++ const ax = Math.max(left + 14, Math.min(r.left + r.width / 2 - 5, left + tw - 24));
++ arrow.className = `ovtip-arrow is-on ${from}`;
++ arrow.style.left = `${Math.round(ax)}px`;
++ arrow.style.top = `${Math.round(from === 'from-top' ? top - 5 : top + th - 5)}px`;
++ raf = requestAnimationFrame(place);
++ }
++
++ function show(el) {
++ ensure();
++ clearTimeout(hideTimer);
++ if (current === el && tip.classList.contains('is-on')) return;
++ current = el;
++ tip.style.setProperty('--ovtip-accent', el.getAttribute('data-ovtip-accent') || '');
++ arrow.style.setProperty('--ovtip-accent', el.getAttribute('data-ovtip-accent') || '');
++ tip.innerHTML = content(el);
++ tip.classList.toggle('is-interactive', el.hasAttribute('data-ovtip-interactive'));
++ tip.classList.remove('is-on'); arrow.classList.remove('is-on');
++ cancelAnimationFrame(raf);
++ place();
++ requestAnimationFrame(() => { tip.classList.add('is-on'); arrow.classList.add('is-on'); });
++ el.setAttribute('aria-describedby', 'ovtip');
++ }
++
++ function hide() {
++ if (!tip) return;
++ cancelAnimationFrame(raf);
++ tip.classList.remove('is-on'); arrow.classList.remove('is-on');
++ if (current) current.removeAttribute('aria-describedby');
++ current = null;
++ }
++ const hideSoon = () => { clearTimeout(hideTimer); hideTimer = setTimeout(hide, 120); };
++
++ function trigger(e) { return e.target.closest && e.target.closest('[data-ovtip]'); }
++
++ function bind() {
++ const fromTouch = (e) => e.pointerType === 'touch' || (!e.pointerType && isTouch());
++ document.addEventListener('pointerover', (e) => { if (fromTouch(e)) return; const t = trigger(e); if (t) show(t); });
++ document.addEventListener('pointerout', (e) => { if (fromTouch(e)) return; if (trigger(e)) hideSoon(); });
++ document.addEventListener('focusin', (e) => { const t = trigger(e); if (t) show(t); });
++ document.addEventListener('focusout', (e) => { if (trigger(e)) hideSoon(); });
++ // Touch: first tap opens the tip, a tap elsewhere closes it. The trigger still works —
++ // we never swallow the click, so a tap on an already-open tip follows the link.
++ document.addEventListener('click', (e) => {
++ const t = trigger(e);
++ if (!t) return hide();
++ // On touch the first tap opens the tip; a second tap follows the link.
++ if (isTouch() && current !== t) { e.preventDefault(); show(t); }
++ }, true);
++ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hide(); });
++ window.addEventListener('scroll', () => { if (current && isTouch()) hide(); }, { passive: true });
++ window.addEventListener('resize', hide, { passive: true });
++ }
++
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bind); else bind();
++ window.OpenVibeTooltip = { show, hide, ensure };
++})();