openvibers/openvibe.live Commits
Shared footer + tooltip system, API Docs rebrand, front-page viewport work
Changes
@@@ -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; }@@@ -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. */++}/* ═══════════════════════════════════════════════════════════════@@@ -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="fa-solid fa-heart"></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 & 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 & community driven · 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>nnew file mode 100644@@@ -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();++})();@@@ -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 phoneconst 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');@@@ -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() {nnew file mode 100644@@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 & 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 }; } };++});nnew file mode 100644@@@ -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 };++})();
@@ -41,7 +41,12 @@
@@ -41,7 +41,12 @@
background: linear-gradient(100deg, transparent, color-mix(in srgb, var(--c) 34%, transparent) 50%, transparent);
background: linear-gradient(100deg, transparent, color-mix(in srgb, var(--c) 34%, transparent) 50%, transparent);
animation: hbWave var(--hb-wave) linear infinite;
animation: hbWave var(--hb-wave) linear infinite;
animation-delay: calc(var(--i, 0) * var(--hb-step));
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 {
.hb::after {
content: ''; position: absolute; inset: -1px; border-radius: inherit; z-index: -2; padding: 1px;
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%);
background: conic-gradient(from var(--a, 0deg), transparent 0 70%, var(--c) 85%, transparent 100%);
@@ -56,6 +61,7 @@
@@ -56,6 +61,7 @@
box-shadow: 0 2px 6px rgba(0,0,0,0.35) inset;
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;
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: 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; }
.hb-label { flex: 1; text-align: left; }
/* Chevron appears on hover/focus: an explicit "this goes somewhere" cue. */
/* Chevron appears on hover/focus: an explicit "this goes somewhere" cue. */
@@ -144,3 +150,9 @@
@@ -144,3 +150,9 @@
.rs-lite .cta-ambient { animation: none !important; filter: none !important; }
.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 .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; }
.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; }
@@ -6133,65 +6133,8 @@ textarea.form-input { resize: vertical; min-height: 60px; }
@@ -6133,65 +6133,8 @@ textarea.form-input { resize: vertical; min-height: 60px; }
/* ═══════════════════════════════════════════════════════════════
/* ═══════════════════════════════════════════════════════════════
FOOTER
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. */
+
}
}
/* ═══════════════════════════════════════════════════════════════
/* ═══════════════════════════════════════════════════════════════
@@ -67,8 +67,8 @@
@@ -67,8 +67,8 @@
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="/css/messenger.css?v=1">
<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/ov-guide.css?v=1">
<link rel="stylesheet" href="/css/i18n-star.css?v=5">
<link rel="stylesheet" href="/css/i18n-star.css?v=5">
<link rel="stylesheet" href="/css/recap.css?v=1">
<link rel="stylesheet" href="/css/recap.css?v=1">
@@ -279,16 +279,22 @@
@@ -279,16 +279,22 @@
</div>
</div>
</div>
</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>
<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.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://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://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="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="fa-solid fa-heart"></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>
</div>
<!-- One stream, everywhere — restream pipeline + feature tour (public/js/home-tour.js) -->
<!-- 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. -->
<!-- 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>
<a class="hero-latest" id="hero-latest" href="/updates" onclick="return handleLinkClick(event, '/updates')" style="display:none"></a>
<div class="hero-philosophy-links">
<div class="hero-philosophy-links">
@@ -325,7 +331,7 @@
@@ -325,7 +331,7 @@
</div>
</div>
<!-- Community pulse: goals near completion, latest activity, weekly leaders -->
<!-- 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">
<div class="section-header">
<h2><i class="fa-solid fa-bolt"></i> Happening Now</h2>
<h2><i class="fa-solid fa-bolt"></i> Happening Now</h2>
</div>
</div>
@@ -333,7 +339,7 @@
@@ -333,7 +339,7 @@
</div>
</div>
<!-- AI Moments showcase -->
<!-- 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">
<div class="section-header">
<h2><i class="fa-solid fa-brain"></i> AI Moments</h2>
<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>
<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
@@ -5039,60 +5045,9 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- FOOTER -->
<!-- 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 & 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 & community driven · 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 -->
<!-- COSMETICS INVENTORY MODAL -->
@@ -5209,10 +5164,13 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -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/draggable-fab.js?v=2" defer></script>
<script src="/js/messenger.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/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/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) ── -->
<!-- ── OpenVibe shared libs (notifications, account sync, themes) ── -->
<script src="/shared/theme-loader.js" defer></script>
<script src="/shared/theme-loader.js" defer></script>
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -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();
+})();
@@ -15,14 +15,19 @@
@@ -15,14 +15,19 @@
const REDUCED = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const REDUCED = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const COARSE = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;
const COARSE = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;
if (REDUCED) return;
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')); };
const onHome = () => { const p = document.getElementById('page-home'); return !!(p && p.classList.contains('active')); };
// ── Hero sparks canvas ─────────────────────────────────────
// ── Hero sparks canvas ─────────────────────────────────────
let sparksStop = null;
let sparksStop = null;
function startSparks() {
function startSparks() {
- if (sparksStop || COARSE || window.innerWidth < 720) return;
+ if (sparksStop || LITE || window.innerWidth < 720) return;
const hero = document.querySelector('#page-home .hero');
const hero = document.querySelector('#page-home .hero');
if (!hero) return;
if (!hero) return;
const canvas = document.createElement('canvas');
const canvas = document.createElement('canvas');
@@ -64,6 +69,7 @@
@@ -64,6 +69,7 @@
// ── Cursor spotlight + magnetic buttons ─────────────────────
// ── Cursor spotlight + magnetic buttons ─────────────────────
function attachHeroPointer() {
function attachHeroPointer() {
+ if (LITE) return;
const hero = document.querySelector('#page-home .hero');
const hero = document.querySelector('#page-home .hero');
if (!hero || hero.dataset.hfx) return;
if (!hero || hero.dataset.hfx) return;
hero.dataset.hfx = '1';
hero.dataset.hfx = '1';
@@ -82,12 +88,22 @@
@@ -82,12 +88,22 @@
}
}
// ── Scroll reveal ──────────────────────────────────────────
// ── Scroll reveal ──────────────────────────────────────────
- let io = null;
+ let io = null, revealSafety = 0;
function attachReveal() {
function attachReveal() {
if (!('IntersectionObserver' in window)) return;
if (!('IntersectionObserver' in window)) return;
if (!io) io = new IntersectionObserver((entries) => { for (const en of entries) if (en.isIntersecting) { en.target.classList.add('is-in'); io.unobserve(en.target); } }, { rootMargin: '0px 0px -8% 0px', threshold: 0.08 });
if (!io) io = new IntersectionObserver((entries) => { for (const en of entries) if (en.isIntersecting) { en.target.classList.add('is-in'); io.unobserve(en.target); } }, { rootMargin: '0px 0px -8% 0px', threshold: 0.08 });
const sel = '#page-home .section-header, #page-home .stream-card, #page-home .home-cta-banner, #page-home .home-star-section, #page-home .rs-hero-mount, #page-home .pulse-grid > *, #page-home .moments-row > *, #page-home .home-digest';
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); });
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 ─────────────────────────────
// ── Count-up on the hero stats ─────────────────────────────
@@ -114,7 +130,7 @@
@@ -114,7 +130,7 @@
// ── Stream card tilt ───────────────────────────────────────
// ── Stream card tilt ───────────────────────────────────────
function attachTilt() {
function attachTilt() {
- if (COARSE) return;
+ if (LITE) return;
document.addEventListener('pointermove', (e) => {
document.addEventListener('pointermove', (e) => {
const card = e.target.closest && e.target.closest('#page-home .stream-card');
const card = e.target.closest && e.target.closest('#page-home .stream-card');
if (!card) return;
if (!card) return;
@@ -128,14 +144,51 @@
@@ -128,14 +144,51 @@
// ── Headline shimmer ───────────────────────────────────────
// ── Headline shimmer ───────────────────────────────────────
function attachHeadline() {
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');
const h1 = document.querySelector('#page-home .hero h1');
if (h1) h1.classList.add('hfx-shimmer');
if (h1) h1.classList.add('hfx-shimmer');
const rot = document.getElementById('hero-rotate');
const rot = document.getElementById('hero-rotate');
if (rot) rot.classList.add('hfx-glow');
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() {
function boot() {
- attachHeroPointer(); attachHeadline(); attachCountUp(); attachTilt();
+ attachHeroPointer();
+ attachViewportAnimations(); attachHeadline(); attachCountUp(); attachTilt();
const apply = () => { if (onHome()) { startSparks(); attachReveal(); } else if (sparksStop) sparksStop(); };
const apply = () => { if (onHome()) { startSparks(); attachReveal(); } else if (sparksStop) sparksStop(); };
apply();
apply();
const page = document.getElementById('page-home');
const page = document.getElementById('page-home');
@@ -415,7 +415,7 @@ app.use((req, res, next) => {
@@ -415,7 +415,7 @@ app.use((req, res, next) => {
// Serve openvibe-shared browser assets at /shared/ — serve directly from the
// Serve openvibe-shared browser assets at /shared/ — serve directly from the
// resolved source directory to avoid write operations (the public/ directory
// resolved source directory to avoid write operations (the public/ directory
// is read-only under systemd ProtectSystem=strict on production).
// 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;
let sharedServePath = null;
(function resolveSharedAssets() {
(function resolveSharedAssets() {
new file mode 100644
new file mode 100644
@@ -0,0 +1,299 @@
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 & 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 }; } };
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,154 @@
@@ -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 };
+})();