openvibers/openvibe.live Commits

Sign-in/sign-out everywhere hooks, network history, Tools link, community-paste redirect scaffold, open/community-run wording

OP OpenVibers committed 18 Sept 2026 · 6baa42fbaa · 14 files, +667 −63

- /api/auth/sso/login accepts next (a path here or an https URL on the OpenVibe network) so
  openvibe.network's sign-in-everywhere chain can hop through Live silently and come back;
  the callback hands the browser to next, sets a year-long ov_sso_hint cookie, and a
  login_required answer returns to next with ?sso=none instead of always landing on /
- GET /api/auth/logout?next= for the sign-out-everywhere chain (clears cookies + page storage)
- app.js reads the hint cookie too; channel/VOD/clip/paste/game routes record themselves in
  the account's network-wide history (shared history.js, lazily loaded, guests excluded)
- hero "Tools" button points at openvibe.tools (it went to openvibe.network); user menu gains History
- PASTES_ON_COMMUNITY=1 (off by default): /p/:slug and /pastes 301 to openvibe.community and the
  SPA router hands paste URLs over via a meta tag — flipped once the Community app is live
- wording: "Free & Open Live Streaming" → "Open Live Streaming, Community Run" (title, OG,
  JSON-LD, unit description, banner, docs); cost claims dropped from the home copy
- vendor/openvibe-shared re-synced from the Network (history.js, ov-mark.js, navbar/footer/brand)

Changes

ddiff --git a/.env.example b/.env.example
iindex 41010f3d..31f53b75 100644
---- a/.env.example
++++ b/.env.example
@@@ -103,3 +103,7 @@ UNSAFE_WORDS_PATH=./server/chat/unsafe-words.txt
# from OpenVibe.Media (drain stalled / disk critical / recovered). Can also be set from the
# admin settings as `ops_alert_webhook_url`.
OPS_ALERT_WEBHOOK_URL=
++
++# Pastes are served by openvibe.community when this is 1 (Live 301s /p/:slug and /pastes there)
++PASTES_ON_COMMUNITY=0
++OV_COMMUNITY_URL=https://openvibe.community
ddiff --git a/deploy/systemd/openvibe-live.service b/deploy/systemd/openvibe-live.service
iindex fac6af1b..0ac27a80 100644
---- a/deploy/systemd/openvibe-live.service
++++ b/deploy/systemd/openvibe-live.service
@@@ -1,5 +1,5 @@
[Unit]
--Description=OpenVibe.Live — Free & Open Live Streaming
++Description=OpenVibe.Live — Open Live Streaming, Community Run
# Media (openvibe-media) and Network (openvibe-network) should be up first, but
# Live degrades gracefully if they aren't (proxies 502, wallet reads fall back).
After=network.target
ddiff --git a/docs/restream-branding.md b/docs/restream-branding.md
iindex 1bf12962..feb15c5c 100644
---- a/docs/restream-branding.md
++++ b/docs/restream-branding.md
@@@ -4,7 +4,7 @@
**Brand colors:** Violet accent `#8b5cf6`, light violet `#a78bfa`, deep violet `#6d28d9`, cyan secondary `#22d3ee`, dark background `#131318`, live red `#e74c3c`
**Logo motif:** Play triangle inside a broadcast ring + signal waves (see `public/assets/logo.svg`)
--**Tagline:** "Free & Open Live Streaming"
++**Tagline:** "Open Live Streaming, Community Run"
**URL:** `https://openvibe.live`
**OpenVibe.Network (SSO / accounts):** `https://openvibe.network`
**OpenVibe.Tools:** `https://openvibe.tools`
ddiff --git a/public/index.html b/public/index.html
iindex 6b928334..9d1f42e3 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -4,17 +4,17 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
-- <title>OpenVibe.Live - Free Open Source Live Streaming Platform</title>
-- <meta name="description" content="Free, open source live streaming platform. Restream to Twitch, YouTube, Kick, and more. VODs, clips, chat moderation, voice channels, emotes, analytics, and 200+ community tools.">
-- <meta name="keywords" content="live streaming, restream, streaming platform, twitch, youtube, open source, free streaming, openvibelive">
++ <title>OpenVibe.Live - Open Source Live Streaming, Community Run</title>
++ <meta name="description" content="Open source live streaming platform, run by its community. Restream to Twitch, YouTube, Kick, and more. VODs, clips, chat moderation, voice channels, emotes, analytics, and 200+ community tools.">
++ <meta name="keywords" content="live streaming, restream, streaming platform, twitch, youtube, open source, openvibelive">
<meta name="author" content="OpenVibe">
<meta name="theme-color" content="#8b5cf6">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://openvibe.live/">
-- <meta property="og:title" content="OpenVibe.Live - Free Open Source Live Streaming">
-- <meta property="og:description" content="Go live for free. Restream to Twitch, YouTube, Kick & more. Clips, VODs, chat moderation, emotes, voice, analytics, and 200+ connected community tools.">
++ <meta property="og:title" content="OpenVibe.Live - Open Source Live Streaming, Community Run">
++ <meta property="og:description" content="Go live in minutes. Restream to Twitch, YouTube, Kick & more. Clips, VODs, chat moderation, emotes, voice, analytics, and 200+ connected community tools.">
<meta property="og:image" content="https://openvibe.live/og-image.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
@@@ -25,8 +25,8 @@
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:url" content="https://openvibe.live/">
-- <meta name="twitter:title" content="OpenVibe.Live - Free Live Streaming">
-- <meta name="twitter:description" content="Free, open source live streaming. Restream to Twitch, YouTube, Kick. VODs, clips, community tools.">
++ <meta name="twitter:title" content="OpenVibe.Live - Open Live Streaming, Community Run">
++ <meta name="twitter:description" content="Open source live streaming, community run. Restream to Twitch, YouTube, Kick. VODs, clips, community tools.">
<meta name="twitter:image" content="https://openvibe.live/og-image.png">
<meta name="twitter:image:alt" content="OpenVibe.Live Platform">
@@@ -133,7 +133,7 @@
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "OpenVibe.Live",
-- "description": "Free, open source live streaming platform with restreaming, VODs, clips, and 200+ community tools",
++ "description": "Open source live streaming platform with restreaming, VODs, clips, and 200+ community tools",
"url": "https://openvibe.live",
"applicationCategory": "Multimedia",
"operatingSystem": "Web",
@@@ -150,7 +150,7 @@
"@type": "WebSite",
"name": "OpenVibe.Live",
"url": "https://openvibe.live",
-- "description": "Free open source live streaming platform",
++ "description": "Open source live streaming platform, community run",
"potentialAction": {
"@type": "SearchAction",
"target": {
@@@ -272,6 +272,7 @@
<a href="#" onclick="event.preventDefault();showModal('buy-funds')"><i class="fa-solid fa-coins"></i> Support Us</a>
<a href="#" onclick="event.preventDefault();if(typeof openCosmeticsModal==='function')openCosmeticsModal()"><i class="fa-solid fa-shirt"></i> Cosmetics</a>
<a href="https://openvibe.network/themes" target="_blank" rel="noopener"><i class="fa-solid fa-palette"></i> Themes</a>
++ <a href="https://openvibe.network/my#history" target="_blank" rel="noopener"><i class="fa-solid fa-clock-rotate-left"></i> History</a>
</div>
<div class="ud-group">
<span class="ud-label">Tools</span>
@@@ -327,7 +328,7 @@
<div class="hero-slogan-timer" id="hero-slogan-timer" hidden></div>
<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.tools" 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>
@@@ -530,7 +531,7 @@
<div class="home-cta-feature">
<i class="fa-solid fa-wand-magic-sparkles"></i>
<strong>AI clips, VODs &amp; transcripts</strong>
-- <span>Every broadcast auto-records, clips on the fly, and gets free AI overviews + searchable audio transcripts.</span>
++ <span>Every broadcast auto-records, clips on the fly, and gets AI overviews + searchable audio transcripts.</span>
</div>
<div class="home-cta-feature">
<i class="fa-solid fa-paste"></i>
@@@ -539,8 +540,8 @@
</div>
<div class="home-cta-feature">
<i class="fa-solid fa-screwdriver-wrench"></i>
-- <strong>200+ free tools</strong>
-- <span>Image &amp; audio editing, PDF tools, video downloads, network diagnostics, dev tools and more — all free at openvibe.tools.</span>
++ <strong>200+ online tools</strong>
++ <span>Image &amp; audio editing, PDF tools, video downloads, network diagnostics, dev tools and more — all at openvibe.tools.</span>
</div>
<div class="home-cta-feature feat-star">
<i class="fa-solid fa-code"></i>
@@@ -565,7 +566,7 @@
<div class="home-cta-feature">
<i class="fa-solid fa-plug-circle-bolt"></i>
<strong>Keep it running</strong>
-- <span>Hosting, bandwidth, storage and development come first. Free streaming is not free to host, and being honest about that is how it stays free to use.</span>
++ <span>Hosting, bandwidth, storage and development come first. Streaming is not free to host, and being honest about that is how the network stays open to everyone.</span>
</div>
<div class="home-cta-feature">
<i class="fa-solid fa-heart"></i>
ddiff --git a/public/js/app.js b/public/js/app.js
iindex 16005f54..6854f630 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -575,7 +575,35 @@ function doRegister() { window.location.href = '/api/auth/sso/login'; }
// Session hint: 'account' after a successful sign-in (silent SSO may re-sign you in when the
// site token lapses), 'guest' after an explicit logout / "browse as guest" (never re-sign in).
function _setSsoHint(v) { try { localStorage.setItem('ov_sso_hint', v); } catch { /* */ } }
--function _ssoHint() { try { return localStorage.getItem('ov_sso_hint') || ''; } catch { return ''; } }
++function _ssoHint() {
++ // The cookie is set by the server on sign-in/sign-out (and by the network's sign-in-everywhere
++ // chain, which never runs this page's JS); localStorage is the page-side copy.
++ const m = document.cookie.match(/(?:^|;\s*)ov_sso_hint=([^;]*)/);
++ if (m && m[1]) return m[1];
++ try { return localStorage.getItem('ov_sso_hint') || ''; } catch { return ''; }
++}
++
++// ── Cross-site history (openvibe.network) ────────────────────
++// What the signed-in account watches/opens here shows up in its network-wide History and the
++// "Recently used" rows of the shared navbar on every other site. Recorded after the route
++// rendered and its title settled; never for guests, and only for the pages worth remembering.
++const _HISTORY_PAGES = { channel: 'stream', 'vod-player': 'vod', 'clip-player': 'clip', 'paste-viewer': 'paste', pastes: null, game: 'game', arena: 'page' };
++let _historyTimer = null;
++function _recordHistory(pageId) {
++ const type = _HISTORY_PAGES[pageId];
++ if (!type) return;
++ clearTimeout(_historyTimer);
++ _historyTimer = setTimeout(() => {
++ const tok = (document.cookie.match(/(?:^|;\s*)ov_token=([^;]*)/) || [])[1] || localStorage.getItem('ov_token');
++ if (!tok || !currentUser) return;
++ const entry = { type, title: document.title.replace(/\s*[-–|]\s*OpenVibe\.Live\s*$/i, ''), url: location.href };
++ const send = () => { try { window.OpenVibeHistory.record(entry, { token: decodeURIComponent(tok) }); } catch { /* */ } };
++ if (window.OpenVibeHistory) return send();
++ if (document.getElementById('ov-history-loader')) return;
++ const sc = document.createElement('script'); sc.id = 'ov-history-loader'; sc.async = true; sc.src = '/shared/history.js'; sc.onload = send;
++ document.head.appendChild(sc);
++ }, 1800);
++}
/** Try to sign back in through openvibe.network without a chooser — once per tab session. */
function _trySilentSso() {
if (_ssoHint() !== 'account') return false;
@@@ -999,7 +1027,7 @@ function whenRouteReady(pageId, render) {
const path = location.pathname;
const run = () => {
if (window.ov && !ov.isCurrent(gen)) return;
-- try { render(); }
++ try { render(); _recordHistory(pageId); }
catch (err) {
console.error(`[route] ${path} failed to render:`, err);
if (window.ov) ov.showRouteError(`page-${pageId}`, err, () => whenRouteReady(pageId, render));
@@@ -1116,6 +1144,10 @@ function routeFromURL() {
} else if (segments[0] === 'privacy') {
window.location.replace('/privacy');
return;
++ } else if (segments[0] === 'p' && segments[1] && document.querySelector('meta[name="ov-pastes-base"]')) {
++ // Pastes live on openvibe.community now — hand the browser over (same slug, same URL shape).
++ location.replace(`${document.querySelector('meta[name="ov-pastes-base"]').content}/p/${encodeURIComponent(segments[1])}${location.search}`);
++ return;
} else if (segments[0] === 'p' && segments[1]) {
showPage('paste-viewer');
whenRouteReady('paste-viewer', () => { if (typeof loadPasteViewer === 'function') loadPasteViewer(segments[1]); });
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex 439ec1f9..670f6180 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -288,12 +288,35 @@ function getNetworkRedirectUri() {
return `${config.baseUrl.toLowerCase()}/api/auth/callback`;
}
++/**
++ * Where a sign-in/sign-out may send the browser afterwards: a path on this site, or an
++ * https URL on the OpenVibe network — openvibe.network's "sign in everywhere" chain hops
++ * through /api/auth/sso/login?silent=1&next=https://openvibe.network/sso/fanout?… and must
++ * be handed back. Anything else falls back to the home page.
++ */
++function safeNext(raw) {
++ const s = String(raw || '');
++ if (!s) return '/';
++ if (s.startsWith('/') && !s.startsWith('//')) return s;
++ try {
++ const u = new URL(s);
++ if (u.protocol !== 'https:' && !(u.protocol === 'http:' && /^(localhost|127\.0\.0\.1)$/.test(u.hostname))) return '/';
++ if (/(^|\.)openvibe\.[a-z]+$/.test(u.hostname) || /(^|\.)openre\.stream$/.test(u.hostname) || /^(localhost|127\.0\.0\.1)$/.test(u.hostname)) return u.toString();
++ } catch { /* not a URL */ }
++ return '/';
++}
++const HINT_COOKIE = { httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000, sameSite: 'Lax' };
++
// ── Initiate OAuth Login (redirect to openvibe.network) ───────────
router.get('/sso/login', (req, res) => {
const state = require('crypto').randomBytes(16).toString('hex');
// Store state in a short-lived cookie for CSRF protection
const isSecure = config.baseUrl.startsWith('https');
res.cookie('oauth_state', state, { httpOnly: true, maxAge: 5 * 60 * 1000, sameSite: 'Lax', secure: isSecure });
++ // Where to go afterwards (the fanout chain, or the page that asked to sign in).
++ const next = safeNext(req.query.next);
++ if (next !== '/') res.cookie('oauth_next', next, { httpOnly: true, maxAge: 5 * 60 * 1000, sameSite: 'Lax', secure: isSecure });
++ else res.clearCookie('oauth_next');
const params = new URLSearchParams({
client_id: OV_CLIENT_ID,
@@@ -312,10 +335,13 @@ router.get('/sso/login', (req, res) => {
router.get('/callback', async (req, res) => {
try {
const { code, state } = req.query;
++ const next = safeNext(req.cookies?.oauth_next);
++ res.clearCookie('oauth_next');
if (!code && req.query.error) {
-- // Silent sign-in found no network session — go home quietly as a guest.
++ // Silent sign-in found no network session — go on quietly as a guest.
res.clearCookie('oauth_state');
-- return res.redirect('/?sso=none');
++ const sep = next.includes('?') ? '&' : '?';
++ return res.redirect(`${next}${sep}sso=none`);
}
if (!code) return res.status(400).send('Missing authorization code');
@@@ -450,6 +476,9 @@ router.get('/callback', async (req, res) => {
// Also set ov_token (used by shared navbar/notification libs)
res.cookie('ov_token', openvibeToolsToken, { httpOnly: false, path: '/', maxAge: 7 * 24 * 60 * 60 * 1000, sameSite: 'Lax', secure: isSecure });
++ // Long-lived hint (outlives the token): this browser has signed in here, so a later visit
++ // with no session may try one silent sign-in through the network.
++ res.cookie('ov_sso_hint', 'account', { ...HINT_COOKIE, secure: isSecure });
// Store refresh token in httpOnly cookie (not readable by JS — server handles refresh)
if (openvibeRefreshToken) {
@@@ -470,10 +499,11 @@ router.get('/callback', async (req, res) => {
var acct = { id: u.id, username: u.username, display_name: u.display_name, avatar_url: u.avatar_url, is_anon: false, token: ${JSON.stringify(openvibeToolsToken)}, added_at: Date.now() };
localStorage.setItem('openvibe_accounts', JSON.stringify([acct]));
localStorage.setItem('openvibe_active_account', String(u.id));
++ localStorage.setItem('ov_sso_hint', 'account');
} catch(e) {}
-- window.location.href = '/';
++ window.location.replace(${JSON.stringify(next)});
</script>
--<noscript><a href="/">Click here to continue</a></noscript>
++<noscript><a href="${next.replace(/"/g, '&quot;')}">Click here to continue</a></noscript>
</body></html>`);
} catch (err) {
console.error('[Auth/SSO] Callback error:', err);
@@@ -536,14 +566,31 @@ router.post('/refresh', async (req, res) => {
});
// ── Logout (clear all auth cookies) ──────────────────────────
--router.post('/logout', (req, res) => {
++function clearSessionCookies(res) {
const isSecure = (process.env.BASE_URL || '').startsWith('https');
res.clearCookie('token', { sameSite: 'Lax', secure: isSecure });
res.clearCookie('ov_token', { sameSite: 'Lax', secure: isSecure });
res.clearCookie('ov_refresh', { path: '/api/auth', sameSite: 'Lax', secure: isSecure });
++ res.cookie('ov_sso_hint', 'guest', { ...HINT_COOKIE, secure: isSecure });
++}
++
++router.post('/logout', (req, res) => {
++ clearSessionCookies(res);
res.json({ ok: true });
});
++// Sign-out-everywhere chain from openvibe.network: clear this site's cookies and hand the
++// browser back. The page-side localStorage copy is cleared by the tiny page below.
++router.get('/logout', (req, res) => {
++ clearSessionCookies(res);
++ const next = safeNext(req.query.next);
++ res.set('Cache-Control', 'no-store');
++ res.type('html').send(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Signing out…</title></head><body><script>
++try { ['token','ov_token','openvibe_accounts','openvibe_active_account','openvibe_anon_token'].forEach(function(k){ localStorage.removeItem(k); }); localStorage.setItem('ov_sso_hint','guest'); } catch (e) {}
++location.replace(${JSON.stringify(next)});
++</script><noscript><a href="${next.replace(/"/g, '&quot;')}">Continue</a></noscript></body></html>`);
++});
++
// ── SSO Status (for client-side detection) ───────────────────
router.get('/sso/status', (req, res) => {
res.json({
ddiff --git a/server/index.js b/server/index.js
iindex df083c4a..207660f3 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -1,7 +1,7 @@
/**
* ╔═══════════════════════════════════════════════════════════╗
* ║ OpenVibe.Live — Main Server ║
-- * ║ Free & Open Live Streaming · openvibe.live ║
++ * ║ Open Live Streaming, Community Run · openvibe.live ║
* ║ Part of the OpenVibe network — Open Source & Community ║
* ╚═══════════════════════════════════════════════════════════╝
*
@@@ -596,6 +596,14 @@ app.get('/data/pastes/screenshots/:filename', (req, res) => {
res.redirect(302, mediaClient.screenshotUrl(path.basename(req.params.filename)));
});
++// Pastes are moving to openvibe.community (PASTES_ON_COMMUNITY=1): the old Live URLs keep
++// working as permanent redirects, so links in chat, search results and clipboards survive.
++const COMMUNITY_URL = (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '');
++if (process.env.PASTES_ON_COMMUNITY === '1') {
++ app.get('/p/:slug', (req, res) => res.redirect(301, `${COMMUNITY_URL}/p/${encodeURIComponent(req.params.slug)}`));
++ app.get('/pastes', (req, res) => res.redirect(301, `${COMMUNITY_URL}/pastes`));
++}
++
// The SPA renders Media's relative paste URLs (/p/<slug>/screenshot, /raw)
// against THIS origin — bounce them to the Media public host, where the
// canonical paste page lives.
@@@ -1029,7 +1037,7 @@ async function start() {
console.log('');
console.log(' ╔══════════════════════════════════════════╗');
console.log(' ║ OpenVibe.Live v1.0.0 ║');
-- console.log(' ║ Free & Open Live Streaming ▶((( • ))) ║');
++ console.log(' ║ Open Live Streaming, Community Run ▶((( • ))) ║');
console.log(' ╚══════════════════════════════════════════╝');
console.log('');
ddiff --git a/server/seo/seo.js b/server/seo/seo.js
iindex b21f72f4..592c51c5 100644
---- a/server/seo/seo.js
++++ b/server/seo/seo.js
@@@ -419,10 +419,15 @@ function _base() {
}
return doc.html;
}
++// Pastes are moving to openvibe.community; when PASTES_ON_COMMUNITY=1 the SPA router sends
++// /p/<slug> there (server/index.js answers the same URL with a 301 for bots and direct hits).
++const PASTES_BASE = process.env.PASTES_ON_COMMUNITY === '1' ? (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '') : '';
++
function _headBlock(meta) {
const canonical = abs(meta.canonicalPath || '/');
const img = abs(meta.image || DEFAULT_OG_IMAGE);
const parts = [
++ PASTES_BASE ? `<meta name="ov-pastes-base" content="${esc(PASTES_BASE)}">` : '',
`<title>${esc(meta.title)}</title>`,
`<meta name="description" content="${esc(meta.description)}">`,
`<meta name="robots" content="${esc(meta.robots || 'index,follow')}">`,
ddiff --git a/vendor/openvibe-shared/brand.js b/vendor/openvibe-shared/brand.js
iindex 61ab011a..bfa2a1a8 100644
---- a/vendor/openvibe-shared/brand.js
++++ b/vendor/openvibe-shared/brand.js
@@@ -19,7 +19,7 @@ const BRAND_DEFAULTS = Object.freeze({
gamesServiceName: 'OpenVibe.Games',
mediaServiceName: 'OpenVibe.Media',
tagline: 'One Account. All of OpenVibe.',
-- campTagline: 'Free & Open Live Streaming',
++ campTagline: 'Open Live Streaming, Community Run',
toolsSubdomainBase: 'openvibe.tools',
discord: 'https://discord.gg/M6MuRUaeJj',
github: 'https://github.com/OpenVibe.Live',
@@@ -99,8 +99,8 @@ function buildBrandFromRegistry(registry = {}, env = process.env) {
// Build service list using registry URLs where available
const services = [
-- { id: 'live', name: streamerServiceName, url: liveUrl, description: 'Free & Open Live Streaming' },
-- { id: 'tools', name: toolsServiceName, url: toolsUrl, description: 'Free Online Tools' },
++ { id: 'live', name: streamerServiceName, url: liveUrl, description: 'Open live streaming, community run' },
++ { id: 'tools', name: toolsServiceName, url: toolsUrl, description: 'Online tools for everyone' },
{ id: 'games', name: gamesServiceName, url: gamesUrl, description: 'Games & Community Canvas' },
{ id: 'media', name: mediaServiceName, url: mediaUrl, description: 'VODs, Clips, Pastes & Files' },
{ id: 'network', name: BRAND_DEFAULTS.networkServiceName, url: networkUrl, description: 'Accounts, SSO & OpenCoins' },
@@@ -155,8 +155,8 @@ const BRAND = Object.freeze({
// Primary services in the network (for dashboard display, health checks, etc.)
// Static list with OpenVibe defaults — use buildBrandFromRegistry() for live values.
services: Object.freeze([
-- { id: 'live', name: 'OpenVibe.Live', url: 'https://openvibe.live', description: 'Free & Open Live Streaming' },
-- { id: 'tools', name: 'OpenVibe.Tools', url: 'https://openvibe.tools', description: 'Free Online Tools' },
++ { id: 'live', name: 'OpenVibe.Live', url: 'https://openvibe.live', description: 'Open live streaming, community run' },
++ { id: 'tools', name: 'OpenVibe.Tools', url: 'https://openvibe.tools', description: 'Online tools for everyone' },
{ id: 'games', name: 'OpenVibe.Games', url: 'https://openvibe.games', description: 'Games & Community Canvas' },
{ id: 'media', name: 'OpenVibe.Media', url: 'https://openvibe.media', description: 'VODs, Clips, Pastes & Files' },
{ id: 'network', name: 'OpenVibe.Network', url: 'https://openvibe.network', description: 'Accounts, SSO & OpenCoins' },
ddiff --git a/vendor/openvibe-shared/footer.js b/vendor/openvibe-shared/footer.js
iindex bccc6a11..ed13bbd1 100644
---- a/vendor/openvibe-shared/footer.js
++++ b/vendor/openvibe-shared/footer.js
@@@ -34,7 +34,7 @@
// 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: 'tools', name: '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' },
@@@ -42,7 +42,11 @@
{ 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: 'community', name: 'Community & pastes', url: 'https://openvibe.community', desc: 'Pastes, posts and the people of OpenVibe' },
++ { id: 'games', name: 'Games', url: 'https://openvibe.games', desc: 'Scraplandia and browser games' },
++ { id: 'media', name: 'Media', url: 'https://openvibe.media', desc: 'VODs, clips and files for every site' },
{ id: 'network', name: 'Account & themes', url: NETWORK_URL, desc: 'One account, every site' },
++ { id: 'more', name: 'The whole network →', url: NETWORK_URL + '/#network', desc: 'Every OpenVibe site, live and upcoming' },
];
const LEGAL = [
@@@ -59,7 +63,7 @@
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.',
++ tagline: '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? }] }]
@@@ -84,9 +88,17 @@
return sub === h ? 'tools' : (sub || 'tools');
}
++ const TLD_LABELS = { live: 'Live', tools: 'Tools', network: 'Network', media: 'Media', games: 'Games', community: 'Community', chat: 'Chat', codes: 'Codes', blog: 'Blog', wiki: 'Wiki', news: 'News', reviews: 'Reviews', tips: 'Tips', vip: 'VIP', trade: 'Trade', host: 'Host', deals: 'Deals', coupons: 'Coupons' };
++ /** The full site name from the hostname — the same spelling the navbar uses (Pastes.OpenVibe.Tools). */
function brandFor(service) {
++ const h = typeof location !== 'undefined' ? location.hostname.toLowerCase() : '';
++ const cap = (w) => w ? w.charAt(0).toUpperCase() + w.slice(1) : '';
++ let m = h.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/);
++ if (m) { const sub = m[1] && m[1] !== 'www' ? m[1] : null; return [sub ? (sub.length <= 4 ? sub.toUpperCase() : cap(sub)) : null, 'OpenVibe', TLD_LABELS[m[2]] || cap(m[2])].filter(Boolean).join('.'); }
++ if ((m = h.match(/^(?:(.+)\.)?openre\.stream$/))) return 'OpenRe.Stream';
const found = NETWORK.find(n => n.id === service);
-- if (found) return found.name === 'Account & themes' ? 'OpenVibe' : found.name;
++ if (service && TLD_LABELS[service]) return `OpenVibe.${TLD_LABELS[service]}`;
++ if (found && found.name !== 'Account & themes' && found.name !== 'The whole network →') return `${cap(service)}.OpenVibe.Tools`;
return 'OpenVibe';
}
ddiff --git a/vendor/openvibe-shared/history.js b/vendor/openvibe-shared/history.js
nnew file mode 100644
iindex 00000000..028c7f87
---- /dev/null
++++ b/vendor/openvibe-shared/history.js
@@@ -0,0 +1,111 @@
++/**
++ * openvibe-shared/history.js — cross-site history for a signed-in account.
++ *
++ * OpenVibeHistory.record({ type, title, url, icon, service, meta }, { token, apiBase })
++ * OpenVibeHistory.recent({ limit, service, type, token, apiBase }) → [{...}]
++ * OpenVibeHistory.clear({ token }) → wipes it
++ *
++ * Every property records what the account touched — a tool used, a stream watched, a paste
++ * opened, a game played — so "Recently used" in the shared navbar and the History tab on the
++ * Network show one timeline across the whole network instead of one per site. Writes are
++ * deduplicated server-side (same URL within ten minutes bumps the entry instead of adding one)
++ * and the browser side throttles too, so a page cannot flood it. Nothing is recorded for
++ * anonymous sessions, and the user can pause or clear the timeline from their account page.
++ *
++ * Loaded lazily by navbar.js from https://openvibe.network/shared/history.js; safe to include
++ * directly. Token resolution matches the navbar: ov_token cookie → localStorage.ov_token.
++ */
++(function (root) {
++ 'use strict';
++ if (root.OpenVibeHistory) return;
++
++ const DEFAULT_API = 'https://openvibe.network';
++ const SENT_KEY = 'ov_history_sent'; // sessionStorage: url → ts, to avoid re-posting on every re-render
++ const THROTTLE_MS = 5 * 60 * 1000;
++
++ function token(opts) {
++ if (opts && opts.token) return opts.token;
++ const m = (typeof document !== 'undefined' ? document.cookie : '').match(/(?:^|;\s*)ov_token=([^;]*)/);
++ if (m && m[1]) { try { return decodeURIComponent(m[1]); } catch { return m[1]; } }
++ try { return localStorage.getItem('ov_token'); } catch { return null; }
++ }
++
++ function api(opts) { return (opts && opts.apiBase) || DEFAULT_API; }
++
++ function sent() { try { return JSON.parse(sessionStorage.getItem(SENT_KEY) || '{}'); } catch { return {}; } }
++ function markSent(url) {
++ try {
++ const m = sent(); m[url] = Date.now();
++ const keys = Object.keys(m); if (keys.length > 60) for (const k of keys.slice(0, keys.length - 60)) delete m[k];
++ sessionStorage.setItem(SENT_KEY, JSON.stringify(m));
++ } catch { /* storage unavailable */ }
++ }
++
++ /** Guess an icon and service label from the URL when the caller gives none. */
++ function inferService(url) {
++ let host = '';
++ try { host = new URL(url, location.href).hostname; } catch { return { service: null }; }
++ const m = host.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/) || host.match(/^(?:(.+)\.)?(openre)\.stream$/);
++ if (!m) return { service: null };
++ return { service: m[2], sub: m[1] || null };
++ }
++
++ const ICONS = { tool: 'fa-screwdriver-wrench', stream: 'fa-tower-broadcast', vod: 'fa-clapperboard', clip: 'fa-scissors', paste: 'fa-paste', game: 'fa-gamepad', page: 'fa-file-lines', post: 'fa-comments', account: 'fa-user', theme: 'fa-palette', download: 'fa-download', chat: 'fa-comment' };
++
++ async function record(entry, opts) {
++ const t = token(opts);
++ if (!t || !entry) return false;
++ const url = String(entry.url || (typeof location !== 'undefined' ? location.href : '')).slice(0, 2000);
++ if (!url) return false;
++ const last = sent()[url] || 0;
++ if (!entry.force && Date.now() - last < THROTTLE_MS) return false;
++ markSent(url);
++ const inf = inferService(url);
++ const body = {
++ type: String(entry.type || 'page').slice(0, 32),
++ title: String(entry.title || (typeof document !== 'undefined' ? document.title : '') || url).slice(0, 200),
++ url,
++ icon: entry.icon || ICONS[entry.type] || null,
++ service: entry.service || inf.service || null,
++ sub: entry.sub || inf.sub || null,
++ meta: entry.meta && typeof entry.meta === 'object' ? entry.meta : null,
++ };
++ try {
++ const res = await fetch(`${api(opts)}/api/history`, {
++ method: 'POST', keepalive: true,
++ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
++ body: JSON.stringify(body),
++ });
++ return res.ok;
++ } catch { return false; }
++ }
++
++ async function recent(opts) {
++ const t = token(opts);
++ if (!t) return [];
++ const q = new URLSearchParams();
++ if (opts && opts.limit) q.set('limit', String(opts.limit));
++ if (opts && opts.service) q.set('service', opts.service);
++ if (opts && opts.type) q.set('type', opts.type);
++ try {
++ const res = await fetch(`${api(opts)}/api/history?${q}`, { headers: { Authorization: `Bearer ${t}` } });
++ if (!res.ok) return [];
++ const data = await res.json();
++ return (data && data.items) || [];
++ } catch { return []; }
++ }
++
++ async function clear(opts) {
++ const t = token(opts);
++ if (!t) return false;
++ try { const res = await fetch(`${api(opts)}/api/history`, { method: 'DELETE', headers: { Authorization: `Bearer ${t}` } }); return res.ok; } catch { return false; }
++ }
++
++ async function remove(id, opts) {
++ const t = token(opts);
++ if (!t) return false;
++ try { const res = await fetch(`${api(opts)}/api/history/${encodeURIComponent(id)}`, { method: 'DELETE', headers: { Authorization: `Bearer ${t}` } }); return res.ok; } catch { return false; }
++ }
++
++ root.OpenVibeHistory = { record, recent, clear, remove, ICONS };
++})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/vendor/openvibe-shared/navbar.js b/vendor/openvibe-shared/navbar.js
iindex ad00a31f..47afb984 100644
---- a/vendor/openvibe-shared/navbar.js
++++ b/vendor/openvibe-shared/navbar.js
@@@ -8,12 +8,39 @@
// onLogin/onLogout callbacks. When no `user` is passed the navbar resolves
// it itself: ov_token cookie → localStorage → token opt → sessionUrl, then
// GET {apiBase}/api/auth/me with `Authorization: Bearer <token>`.
++//
++// Brand: derived from the hostname — `pastes.openvibe.tools` renders as
++// Pastes · OpenVibe · Tools (three segments, the subdomain first so the
++// context reads left-to-right), `openvibe.live` as OpenVibe · Live. Pass
++// brand: { sub, tld, name, icon, variant } to override any part, or the
++// legacy brandName/brandIcon. compact: 'auto' (default — the brand shortens
++// to the subdomain on narrow viewports), 'always', 'never'.
++// Menus are modular: every site keeps the shared account menu and adds its own
++// pieces — links: [{label, href, icon?, active?}] replaces the service's top
++// links; menu: { before: [item], after: [item] } adds dropdown rows
++// ({label, href, icon, onClick, danger, external}); OpenVibeNavbar.addMenuItem()
++// / setLinks() do the same at runtime. Signed-in users also get a
++// "Recently used" row fed by the shared history module when it is loaded.
// ═══════════════════════════════════════════════════════════════
(function (root) {
'use strict';
-- let _config = { service: 'network', token: null, user: null, apiBase: 'https://openvibe.network', onLogin: null, onLogout: null, loginUrl: null, sessionUrl: null };
++ let _config = {
++ service: 'network', token: null, user: null, apiBase: 'https://openvibe.network',
++ onLogin: null, onLogout: null, loginUrl: null, sessionUrl: null,
++ brand: null, brandName: null, brandIcon: null, compact: 'auto',
++ links: null, menu: null, recent: true,
++ // history: { type: 'tool'|'stream'|'paste'|'page'|…, title, url, icon } — recorded for the
++ // signed-in user once auth resolves (cross-site "Recently used" / History on the Network).
++ history: null,
++ // silentLogin: 'https://site/auth/login?silent=1&next={url}' — when nobody is signed in here
++ // but this browser has signed in to the network before (ov_sso_hint), try one silent
++ // prompt=none round trip per tab so a session on one site becomes a session on all.
++ silentLogin: null,
++ };
++ const _runtimeMenu = { before: [], after: [] };
++ let _runtimeLinks = null;
let _navEl = null;
function injectStyles() {
@@@ -29,10 +56,44 @@
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
color: var(--text-primary, #e0e0e0);
}
-- .openvibe-navbar-brand { display: flex; align-items: center; gap: 8px; text-decoration: none; color: inherit; margin-right: 8px; }
-- .openvibe-navbar-brand .flame { font-size: 18px; color: var(--accent, #8b5cf6); }
-- .openvibe-navbar-brand .name { font-size: 15px; font-weight: 700; letter-spacing: -.3px; }
-- .openvibe-navbar-brand .service-name { font-size: 11px; color: var(--accent-light, #a78bfa); font-weight: 500; letter-spacing: .5px; text-transform: uppercase; }
++ .openvibe-navbar-brand { display: flex; align-items: center; gap: 9px; text-decoration: none; color: inherit; margin-right: 8px; min-width: 0; }
++ .openvibe-navbar-brand .flame { font-size: 18px; color: var(--accent, #8b5cf6); display: inline-grid; place-items: center; width: 28px; height: 28px; flex: none; }
++ .openvibe-navbar-brand .name { display: inline-flex; align-items: baseline; font-size: 15px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; line-height: 1; }
++ /* Segments: subdomain · OpenVibe · TLD. The part that names *this* site is the loud one. */
++ .openvibe-navbar-brand .b-sub { color: var(--text-primary, #e0e0e0); }
++ .openvibe-navbar-brand .b-core { color: var(--text-primary, #e0e0e0); }
++ .openvibe-navbar-brand .b-tld { color: var(--accent-light, var(--accent, #a78bfa)); }
++ .openvibe-navbar-brand.has-sub .b-core { color: var(--text-secondary, #b0b0b8); font-weight: 600; }
++ .openvibe-navbar-brand.has-sub .b-tld { color: var(--text-secondary, #b0b0b8); font-weight: 600; }
++ .openvibe-navbar-brand .b-dot { color: var(--accent, #8b5cf6); opacity: .75; margin: 0 1px; font-weight: 800; }
++ .openvibe-navbar-brand .b-sub, .openvibe-navbar-brand .b-core, .openvibe-navbar-brand .b-tld { transition: color .2s, opacity .2s; }
++ .openvibe-navbar-brand:hover .b-tld, .openvibe-navbar-brand:hover .b-sub { color: var(--accent-light, #a78bfa); }
++ .openvibe-navbar-brand:hover .b-dot { opacity: 1; }
++ .openvibe-navbar-brand .b-tag { font-size: 9px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase; color: var(--accent-light, #a78bfa); background: var(--accent-glow, rgba(139,92,246,.14)); border-radius: 4px; padding: 2px 5px; margin-left: 6px; align-self: center; }
++ /* Compact: drop the trailing segments on narrow viewports, keep what identifies the page. */
++ .openvibe-navbar[data-compact="always"] .openvibe-navbar-brand.has-sub .b-core,
++ .openvibe-navbar[data-compact="always"] .openvibe-navbar-brand.has-sub .b-tld,
++ .openvibe-navbar[data-compact="always"] .openvibe-navbar-brand.has-sub .b-dot { display: none; }
++ @media (max-width: 860px) {
++ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand.has-sub .b-core,
++ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand.has-sub .b-tld,
++ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand.has-sub .b-dot { display: none; }
++ }
++ @media (max-width: 420px) {
++ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand:not(.has-sub) .b-core,
++ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand:not(.has-sub) .b-dot { display: none; }
++ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand .b-tag { display: none; }
++ }
++ .openvibe-navbar-links a .icon { margin-right: 5px; opacity: .8; }
++ .openvibe-navbar-dropdown-recent { padding: 6px 8px 2px; border-bottom: 1px solid var(--border, #333340); }
++ .openvibe-navbar-dropdown-recent .label { font-size: 10px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase; color: var(--text-muted, #707080); padding: 2px 8px 4px; display: flex; justify-content: space-between; align-items: center; }
++ .openvibe-navbar-dropdown-recent .label a { color: var(--accent-light, #a78bfa); text-decoration: none; font-weight: 600; letter-spacing: 0; text-transform: none; }
++ .openvibe-navbar-dropdown-recent .item { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px; font-size: 12px; color: var(--text-secondary, #b0b0b8); text-decoration: none; transition: background .12s; min-width: 0; }
++ .openvibe-navbar-dropdown-recent .item:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }
++ .openvibe-navbar-dropdown-recent .item .icon { width: 18px; text-align: center; color: var(--accent-light, #a78bfa); flex: none; }
++ .openvibe-navbar-dropdown-recent .item .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++ .openvibe-navbar-dropdown-recent .item .s { margin-left: auto; font-size: 10px; color: var(--text-muted, #707080); flex: none; }
++ .openvibe-navbar-dropdown-menu .sep { height: 1px; background: var(--border, #333340); margin: 4px -8px; }
.openvibe-navbar-links { display: flex; align-items: center; gap: 4px; margin-left: 8px; }
.openvibe-navbar-links a {
@@@ -350,6 +411,113 @@
'colors.openvibe.tools': { name: 'OpenVibeColor', icon: 'fa-palette' },
};
++ // ─── Brand from hostname ───────────────────────────────────
++ // Every property is <sub?>.openvibe.<tld> (plus openre.stream). The navbar spells the
++ // whole name — Pastes.OpenVibe.Tools, not "Paste.OpenVibe" — because the subdomain and
++ // the TLD are what tell a visitor where they are in the network.
++ const TLD_LABELS = {
++ live: 'Live', tools: 'Tools', network: 'Network', media: 'Media', games: 'Games',
++ community: 'Community', chat: 'Chat', codes: 'Codes', blog: 'Blog', wiki: 'Wiki',
++ news: 'News', reviews: 'Reviews', tips: 'Tips', vip: 'VIP', trade: 'Trade', host: 'Host',
++ deals: 'Deals', coupons: 'Coupons',
++ };
++ const SUB_LABELS = {
++ json: 'JSON', yaml: 'YAML', xml: 'XML', csv: 'CSV', sql: 'SQL', html: 'HTML', jwt: 'JWT',
++ uuid: 'UUID', guid: 'GUID', url: 'URL', b64: 'B64', sha256: 'SHA256', og: 'OG', md: 'MD',
++ yt: 'YT', ip: 'IP', myip: 'MyIP', ipv4: 'IPv4', ipv6: 'IPv6', geoip: 'GeoIP', asn: 'ASN',
++ rdns: 'rDNS', dns: 'DNS', mx: 'MX', txt: 'TXT', ns: 'NS', spf: 'SPF', dkim: 'DKIM',
++ dmarc: 'DMARC', mtr: 'MTR', ssl: 'SSL', tls: 'TLS', ptr: 'PTR', smtp: 'SMTP', http: 'HTTP',
++ httpstatus: 'HTTPStatus', rdap: 'RDAP', isp: 'ISP', pdf: 'PDF', mergepdf: 'MergePDF',
++ splitpdf: 'SplitPDF', compresspdf: 'CompressPDF', rotatepdf: 'RotatePDF',
++ reorderpdf: 'ReorderPDF', watermarkpdf: 'WatermarkPDF', protectpdf: 'ProtectPDF',
++ unlockpdf: 'UnlockPDF', image2pdf: 'Image2PDF', jpg2pdf: 'JPG2PDF', png2pdf: 'PNG2PDF',
++ pdf2jpg: 'PDF2JPG', pdf2png: 'PDF2PNG', png: 'PNG', jpg: 'JPG', jpeg: 'JPEG', webp: 'WebP',
++ avif: 'AVIF', heic: 'HEIC', heif: 'HEIF', svg: 'SVG', gif: 'GIF', ico: 'ICO', tiff: 'TIFF',
++ bmp: 'BMP', mp3: 'MP3', wav: 'WAV', flac: 'FLAC', ogg: 'OGG', m4a: 'M4A', aac: 'AAC',
++ opus: 'Opus', wma: 'WMA', aiff: 'AIFF', ac3: 'AC3', eq: 'EQ', equalizer: 'EQ', mxn: 'MXN',
++ ascii: 'ASCII', smallcaps: 'SmallCaps', titlecase: 'TitleCase', textlogo: 'TextLogo',
++ textart: 'TextArt', channelart: 'ChannelArt', lowerthird: 'LowerThird', copypaste: 'CopyPaste',
++ dnspropagation: 'DNSPropagation', opengraph: 'OpenGraph', whip: 'WHIP', ingest: 'Ingest',
++ play: 'Play', my: 'My', auth: 'Auth', api: 'API', admin: 'Admin', docs: 'Docs', dev: 'Dev',
++ net: 'Net', img: 'Img', pastes: 'Pastes', paste: 'Pastes', maps: 'Maps', food: 'Food',
++ text: 'Text', logo: 'Logo', audio: 'Audio', ai: 'AI', cdn: 'CDN', status: 'Status',
++ };
++ const SERVICE_TLD = { live: 'live', tools: 'tools', games: 'games', media: 'media', network: 'network', community: 'community' };
++ const SERVICE_SUB = { net: 'net', dev: 'dev', paste: 'pastes', maps: 'maps', food: 'food', img: 'img', yt: 'yt', audio: 'audio', text: 'text', logo: 'logo', docs: 'docs' };
++
++ function titleCase(w) { return w ? w.charAt(0).toUpperCase() + w.slice(1) : ''; }
++ function subLabel(sub) { return SUB_LABELS[sub] || titleCase(sub); }
++
++ /**
++ * { sub, core, tld, name, short, icon, variant } for the current page.
++ * sub 'Pastes' | null tld 'Tools' core 'OpenVibe'
++ * name 'Pastes.OpenVibe.Tools' short 'Pastes' (what compact mode keeps)
++ * variant the ov-mark flavour: the TLD id ('live', 'tools', …) — every site gets its own twist
++ */
++ function resolveBrand() {
++ const b = Object.assign({}, _config.brand || {});
++ const host = currentHost().toLowerCase();
++ let sub = null, tld = null, core = 'OpenVibe';
++ let m = host.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/);
++ if (m) { sub = m[1] && m[1] !== 'www' ? m[1] : null; tld = m[2]; }
++ else if ((m = host.match(/^(?:(.+)\.)?openre\.stream$/))) { sub = m[1] && m[1] !== 'www' ? m[1] : null; core = 'OpenRe'; tld = 'stream'; }
++ // Off-network hosts (localhost, previews): fall back to the service id.
++ if (!tld) { tld = SERVICE_TLD[_config.service] || (SERVICE_SUB[_config.service] ? 'tools' : 'network'); sub = SERVICE_SUB[_config.service] || null; }
++ // Legacy brandName ("Paste.OpenVibe", "OpenVibe.Live") still steers the segments.
++ if (_config.brandName && !b.sub && !b.tld) {
++ const parts = String(_config.brandName).split('.');
++ if (parts.length >= 2 && /^openvibe$/i.test(parts[0])) tld = parts[1].toLowerCase();
++ else if (parts.length >= 2 && /^openvibe$/i.test(parts[1])) sub = parts[0].toLowerCase();
++ else if (parts.length === 1) sub = parts[0].toLowerCase();
++ }
++ if (b.sub !== undefined) sub = b.sub ? String(b.sub).toLowerCase() : null;
++ if (b.tld) tld = String(b.tld).toLowerCase();
++ const subText = b.subLabel || (sub ? subLabel(sub) : null);
++ const tldText = b.tldLabel || TLD_LABELS[tld] || titleCase(tld);
++ const name = b.name || [subText, core, tldText].filter(Boolean).join('.');
++ const icon = b.icon || _config.brandIcon || null;
++ const variant = b.variant || (core === 'OpenRe' ? 'stream' : tld);
++ return { sub, subText, core, tld, tldText, name, short: subText || `${core}.${tldText}`, icon, variant, tag: b.tag || null, href: b.href || '/' };
++ }
++
++ function brandHTML(brand) {
++ const seg = (cls, text) => `<span class="${cls}">${escapeAttr(text)}</span>`;
++ const dot = '<span class="b-dot">.</span>';
++ const text = brand.subText
++ ? seg('b-sub', brand.subText) + dot + seg('b-core', brand.core) + dot + seg('b-tld', brand.tldText)
++ : seg('b-core', brand.core) + dot + seg('b-tld', brand.tldText);
++ const mark = brand.icon
++ ? `<i class="fa-solid ${escapeAttr(brand.icon)}"></i>`
++ : `<span class="ov-mark" data-size="28" data-variant="${escapeAttr(brand.variant)}"></span>`;
++ return `<a class="openvibe-navbar-brand${brand.subText ? ' has-sub' : ''}" href="${escapeAttr(brand.href)}" title="${escapeAttr(brand.name)}" aria-label="${escapeAttr(brand.name)}">
++ <span class="flame">${mark}</span>
++ <span class="name">${text}${brand.tag ? `<span class="b-tag">${escapeAttr(brand.tag)}</span>` : ''}</span>
++ </a>`;
++ }
++
++ function menuItemHTML(item) {
++ if (!item) return '';
++ if (item.sep) return '<div class="sep"></div>';
++ const icon = item.icon ? `<span class="icon"><i class="fa-solid ${escapeAttr(item.icon)}"></i></span>` : '<span class="icon"></span>';
++ const cls = item.danger ? ' class="danger"' : '';
++ const id = item.id ? ` data-menu-id="${escapeAttr(item.id)}"` : '';
++ if (item.href) return `<a href="${escapeAttr(item.href)}"${cls}${id}${item.external ? ' target="_blank" rel="noopener"' : ''}>${icon} ${escapeAttr(item.label)}</a>`;
++ return `<button type="button"${cls}${id}>${icon} ${escapeAttr(item.label)}</button>`;
++ }
++
++ function bindMenuItems(container, items) {
++ for (const item of items) {
++ if (!item || !item.id || typeof item.onClick !== 'function') continue;
++ container.querySelector(`[data-menu-id="${item.id}"]`)?.addEventListener('click', (e) => { if (!item.href) e.preventDefault(); item.onClick(e); });
++ }
++ }
++
++ function currentLinks() {
++ const list = _runtimeLinks || _config.links || SERVICE_LINKS[_config.service] || [];
++ const path = (typeof location !== 'undefined' && location.pathname) || '/';
++ return list.map((l) => Object.assign({}, l, { active: l.active !== undefined ? l.active : (l.href === path && path !== '/') }));
++ }
++
const SERVICE_LINKS = {
live: [
{ label: 'Watch', href: '/' },
@@@ -610,6 +778,45 @@
let _authInFlight = null;
++ function ssoHint() {
++ const m = (typeof document !== 'undefined' ? document.cookie : '').match(/(?:^|;\s*)ov_sso_hint=([^;]*)/);
++ if (m) return m[1];
++ try { return localStorage.getItem('ov_sso_hint'); } catch { return null; }
++ }
++
++ /**
++ * One silent sign-in attempt per tab: only when this browser has signed in to the network
++ * before (the hint survives token expiry), never after an explicit sign-out ('guest'), and
++ * never for bots. The site's login route turns silent=1 into prompt=none and comes straight
++ * back on error=login_required, so a signed-out visitor sees one quick redirect at most.
++ */
++ function maybeSilentLogin() {
++ if (!_config.silentLogin || typeof location === 'undefined') return false;
++ if (ssoHint() !== 'account') return false;
++ if (/bot|crawl|spider|slurp|headless/i.test(navigator.userAgent || '')) return false;
++ try { if (sessionStorage.getItem('ov_silent_sso')) return false; sessionStorage.setItem('ov_silent_sso', '1'); } catch { return false; }
++ if (/[?&]sso=none\b/.test(location.search)) return false;
++ const url = String(_config.silentLogin).replace('{url}', encodeURIComponent(location.href));
++ location.replace(url);
++ return true;
++ }
++
++ function recordHistory() {
++ const h = _config.history;
++ if (!h || !_config.user || _config.user.is_anon) return;
++ const rec = Object.assign({ url: location.href, title: document.title, service: resolveBrand().tld }, h);
++ const H = root.OpenVibeHistory;
++ if (H && typeof H.record === 'function') { H.record(rec, { token: _config.token, apiBase: _config.apiBase }); return; }
++ if (!document.getElementById('ov-history-loader')) {
++ const sc = document.createElement('script'); sc.id = 'ov-history-loader'; sc.async = true;
++ sc.src = `${_config.apiBase}/shared/history.js`;
++ sc.onload = () => { try { root.OpenVibeHistory.record(rec, { token: _config.token, apiBase: _config.apiBase }); } catch { /* */ } };
++ document.head.appendChild(sc);
++ } else {
++ document.getElementById('ov-history-loader').addEventListener('load', () => { try { root.OpenVibeHistory.record(rec, { token: _config.token, apiBase: _config.apiBase }); } catch { /* */ } });
++ }
++ }
++
function refreshAuthState() {
if (_authInFlight) return _authInFlight;
_authInFlight = resolveSessionUser()
@@@ -619,6 +826,9 @@
_config.user = session.user;
if (session.token) _config.token = session.token;
render();
++ recordHistory();
++ } else {
++ maybeSilentLogin();
}
try {
document.dispatchEvent(new CustomEvent('openvibe-navbar-auth', {
@@@ -673,28 +883,37 @@
});
}
++ /**
++ * "Recently used" rows in the dropdown: the last few things this account touched anywhere
++ * on the network, from the shared history module (loaded lazily from the Network).
++ */
++ function renderRecent(el) {
++ if (!el || !_config.user || _config.user.is_anon) return;
++ const draw = (items) => {
++ if (!items || !items.length) { el.hidden = true; return; }
++ el.hidden = false;
++ el.innerHTML = `<div class="label"><span>Recently used</span><a href="https://openvibe.network/my#history">All history</a></div>` +
++ items.slice(0, 4).map(h => `<a class="item" href="${escapeAttr(h.url)}"><span class="icon"><i class="fa-solid ${escapeAttr(h.icon || 'fa-clock-rotate-left')}"></i></span><span class="t">${escapeAttr(h.title || h.url)}</span><span class="s">${escapeAttr(h.service_label || h.service || '')}</span></a>`).join('');
++ };
++ const H = root.OpenVibeHistory;
++ if (H && typeof H.recent === 'function') { H.recent({ limit: 4, token: _config.token, apiBase: _config.apiBase }).then(draw).catch(() => draw(null)); return; }
++ if (document.getElementById('ov-history-loader')) return;
++ const sc = document.createElement('script'); sc.id = 'ov-history-loader'; sc.async = true;
++ sc.src = `${_config.apiBase}/shared/history.js`;
++ sc.onload = () => { try { root.OpenVibeHistory.recent({ limit: 4, token: _config.token, apiBase: _config.apiBase }).then(draw).catch(() => draw(null)); } catch { /* */ } };
++ document.head.appendChild(sc);
++ }
++
function render() {
if (_navEl) _navEl.remove();
const nav = document.createElement('nav');
nav.className = 'openvibe-navbar';
const svc = _config.service;
-- const links = SERVICE_LINKS[svc] || [];
--
-- // Resolve brand name + icon: config override > subdomain lookup > service defaults
-- const host = (typeof location !== 'undefined' && location.hostname) || '';
-- // Exact match first (e.g., hostname.openvibe.tools), then fallback to service name
-- let subBrand = SUBDOMAIN_BRANDS[host];
-- if (!subBrand && host && host.includes('.openvibe.tools')) {
-- // Try matching against service names
-- const parts = host.split('.');
-- const potential = parts[0]; // e.g., 'json' from 'json.openvibe.tools'
-- subBrand = SUBDOMAIN_BRANDS[`${potential}.openvibe.tools`];
-- }
-- const svcName = _config.brandName || (subBrand && subBrand.name) || SERVICE_NAMES[svc] || 'OpenVibe';
-- const svcIcon = _config.brandIcon || (subBrand && subBrand.icon) || SERVICE_ICONS[svc] || 'fa-circle-nodes';
-- const brandHref = '/';
++ const brand = resolveBrand();
++ if (typeof nav.setAttribute === 'function') { nav.setAttribute('data-compact', _config.compact || 'auto'); nav.setAttribute('data-service', svc); }
++ const links = currentLinks();
const u = _config.user;
const accounts = getAccounts();
@@@ -702,20 +921,19 @@
const loginHref = resolveLoginHref(currentHost(), window.location.href);
const addAccountHref = `${_config.apiBase}/login?add_account=1&return=${encodeURIComponent(window.location.href)}`;
++ // The OV brand mark is a self-contained drop-in (mounts every .ov-mark it finds).
++ if (!window.__ovMark && !document.getElementById('ov-mark-loader')) {
++ try { const sc = document.createElement('script'); sc.id = 'ov-mark-loader'; sc.src = 'https://openvibe.network/shared/ov-mark.js'; sc.async = true; document.head.appendChild(sc); } catch { /* */ }
++ }
nav.innerHTML = `
-- <a class="openvibe-navbar-brand" href="${brandHref}">
-- <span class="flame"><i class="fa-solid ${svcIcon}"></i></span>
-- <div>
-- <div class="name">${svcName}</div>
-- </div>
-- </a>
++ ${brandHTML(brand)}
<div class="openvibe-navbar-links">
-- ${links.map(l => `<a href="${l.href}">${l.label}</a>`).join('')}
++ ${links.map(l => `<a href="${escapeAttr(l.href)}"${l.active ? ' class="active"' : ''}${l.external ? ' target="_blank" rel="noopener"' : ''}>${l.icon ? `<i class="fa-solid ${escapeAttr(l.icon)} icon"></i>` : ''}${escapeAttr(l.label)}</a>`).join('')}
${u && u.role === 'admin' ? `<a href="https://openvibe.network/admin"><i class="fa-solid fa-shield-halved"></i> Admin</a>` : ''}
</div>
<div class="openvibe-navbar-spacer"></div>
<div class="openvibe-navbar-right">
-- <a class="openvibe-network-badge" href="https://openvibe.network" title="Connected to OpenVibe"><i class="fa-solid fa-circle-nodes"></i> OpenVibe</a>
++ <a class="openvibe-network-badge" href="https://openvibe.network" title="Connected to OpenVibe"><span class="ov-mark" data-size="16" data-static="1"></span> OpenVibe</a>
<div id="openvibe-bell-mount"></div>
${u ? avatarImg(u, 64, 'openvibe-navbar-avatar', 'openvibe-avatar-btn') :
`<a class="openvibe-navbar-login" id="openvibe-login-btn" href="${escapeAttr(loginHref)}">Sign In</a>`}
@@@ -729,6 +947,8 @@
dropdown.id = 'openvibe-user-dropdown';
const otherAccounts = accounts.filter(a => isAnon ? !a.is_anon : String(a.id) !== String(u.id));
++ const before = ((_config.menu && _config.menu.before) || []).concat(_runtimeMenu.before);
++ const after = ((_config.menu && _config.menu.after) || []).concat(_runtimeMenu.after);
dropdown.innerHTML = `
<div class="openvibe-navbar-dropdown-header">
@@@ -755,17 +975,23 @@
<span>Add another account</span>
</a>
</div>
++ <div class="openvibe-navbar-dropdown-recent" id="openvibe-recent" hidden></div>
<div class="openvibe-navbar-dropdown-menu">
++ ${before.map(menuItemHTML).join('')}${before.length ? '<div class="sep"></div>' : ''}
<a href="https://openvibe.network/my"><span class="icon"><i class="fa-solid fa-user"></i></span> My Account</a>
-- <a href="https://openvibe.network/my#notifications"><span class="icon"><i class="fa-solid fa-bell"></i></span> Notification Settings</a>
++ <a href="https://openvibe.network/my#history"><span class="icon"><i class="fa-solid fa-clock-rotate-left"></i></span> History</a>
++ <a href="https://openvibe.network/my#notifications"><span class="icon"><i class="fa-solid fa-bell"></i></span> Notifications</a>
<a href="https://openvibe.network/themes"><span class="icon"><i class="fa-solid fa-palette"></i></span> Themes</a>
<a href="https://openvibe.network/my#linked"><span class="icon"><i class="fa-solid fa-link"></i></span> Linked Services</a>
${u.role === 'admin' ? `<a href="https://openvibe.network/admin"><span class="icon"><i class="fa-solid fa-screwdriver-wrench"></i></span> Admin Panel</a>` : ''}
-- <div style="height:1px;background:var(--border,#333340);margin:4px -8px"></div>
++ ${after.length ? '<div class="sep"></div>' : ''}${after.map(menuItemHTML).join('')}
++ <div class="sep"></div>
<button id="openvibe-logout-btn" class="danger"><span class="icon"><i class="fa-solid fa-right-from-bracket"></i></span> Sign Out</button>
</div>
`;
nav.appendChild(dropdown);
++ bindMenuItems(dropdown, before.concat(after));
++ if (_config.recent !== false) renderRecent(dropdown.querySelector('#openvibe-recent'));
// Avatar click toggles dropdown
nav.querySelector('#openvibe-avatar-btn').addEventListener('click', () => {
@@@ -834,9 +1060,13 @@
// the shared SSO state (ov_token cookie / localStorage / optional
// sessionUrl) and the navbar re-renders when it arrives.
if (!_config.user) refreshAuthState();
++ else recordHistory();
return el;
},
++ /** Record something the signed-in user did here ({type, title, url, icon}) in their network history. */
++ record(entry) { const h = _config.history; _config.history = Object.assign({}, h || {}, entry || {}); recordHistory(); _config.history = h; },
++
/** Re-resolve the signed-in user from the shared SSO state. */
refreshAuth() { return refreshAuthState(); },
@@@ -846,6 +1076,26 @@
render();
},
++ /** Replace this site's top links at runtime ([{label, href, icon?, active?, external?}]). */
++ setLinks(links) { _runtimeLinks = Array.isArray(links) ? links : null; if (_navEl) render(); },
++
++ /** Add a row to the account dropdown: {label, href|onClick, icon, danger, external, position:'before'|'after'}. */
++ addMenuItem(item) {
++ if (!item || !item.label) return;
++ const it = Object.assign({ id: item.id || `mi-${Math.random().toString(36).slice(2, 8)}` }, item);
++ (it.position === 'before' ? _runtimeMenu.before : _runtimeMenu.after).push(it);
++ if (_navEl && _config.user) render();
++ return it.id;
++ },
++
++ removeMenuItem(id) {
++ for (const k of ['before', 'after']) _runtimeMenu[k] = _runtimeMenu[k].filter(i => i.id !== id);
++ if (_navEl && _config.user) render();
++ },
++
++ /** The resolved brand for this page ({ sub, core, tld, name, short, variant }). */
++ brand() { return resolveBrand(); },
++
setToken(token) { _config.token = token; },
/** Get the bell mount point for OpenVibeNotifications. */
ddiff --git a/vendor/openvibe-shared/ov-mark.js b/vendor/openvibe-shared/ov-mark.js
nnew file mode 100644
iindex 00000000..5e5bf5be
---- /dev/null
++++ b/vendor/openvibe-shared/ov-mark.js
@@@ -0,0 +1,83 @@
++/**
++ * ov-mark.js — the OpenVibe "OV" brand mark as a drop-in.
++ * <span class="ov-mark"></span> → 32px animated mark
++ * <span class="ov-mark" data-size="56"> → any size; add data-static="1" for no motion
++ * <span class="ov-mark" data-variant="live"> → the site's own twist on the motion (see VARIANTS)
++ * Self-contained: injects its own CSS once, works on any page (Live, Network, static pages).
++ * The O is a ring with a comet arc running around it, the V is drawn inside with a light
++ * sweep, and a dot orbits the ring. Transform/opacity/stroke motion only.
++ */
++(function () {
++ 'use strict';
++ if (window.__ovMark) return; window.__ovMark = true;
++ let n = 0;
++ const CSS = `
++.ov-mark{display:inline-grid;place-items:center;width:32px;height:32px;color:var(--accent,#8b5cf6);flex:none;vertical-align:middle;line-height:0}
++.ov-mark svg{width:100%;height:100%;overflow:visible;transform-origin:50% 50%;animation:ovmFloat 5s ease-in-out infinite}
++.ov-mark .g{fill:currentColor;opacity:.1;transform-box:fill-box;transform-origin:center;animation:ovmGlow 3.4s ease-in-out infinite}
++.ov-mark .r{fill:none;stroke:currentColor;stroke-width:4;opacity:.28}
++.ov-mark .c{fill:none;stroke-width:4;stroke-linecap:round;stroke-dasharray:34 79;transform-box:fill-box;transform-origin:center;animation:ovmComet 3.6s linear infinite}
++.ov-mark .v{fill:none;stroke-width:4.6;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:46;animation:ovmDraw 1.1s cubic-bezier(.2,.8,.2,1) both}
++.ov-mark .d{fill:var(--ovm-dot,#fff);transform-box:fill-box;transform-origin:center;animation:ovmDot 2.4s ease-in-out infinite}
++/* Per-site variants: one family, each site with its own motion so the network reads as one brand
++ with many rooms. Colours stay with the theme (currentColor); only the dot and the rhythm change. */
++.ov-mark[data-variant=live]{--ovm-dot:#ef4444}
++.ov-mark[data-variant=live] .d{animation:ovmOnAir 1.4s ease-in-out infinite}
++.ov-mark[data-variant=tools] .c{animation-duration:2.2s;stroke-dasharray:22 91}
++.ov-mark[data-variant=tools] .o2{display:none}
++.ov-mark[data-variant=media] .c{stroke-dasharray:14 14 14 71;animation-duration:4.4s}
++.ov-mark[data-variant=games] svg{animation:ovmBounce 1.6s cubic-bezier(.3,1.4,.4,1) infinite}
++.ov-mark[data-variant=games] .d{animation-duration:1.6s}
++.ov-mark[data-variant=community] .o2{opacity:.9;r:1.8}
++.ov-mark[data-variant=community] .c{animation-direction:reverse}
++.ov-mark[data-variant=network] .g{animation-duration:2.4s}
++.ov-mark[data-variant=chat] .d{animation:ovmDot 1s ease-in-out infinite}
++.ov-mark[data-variant=stream] .c{stroke-dasharray:60 53;animation-duration:2.8s}
++.ov-mark[data-variant=codes] .v{animation:ovmDraw 1.1s cubic-bezier(.2,.8,.2,1) infinite alternate}
++.ov-mark[data-variant=blog] .c,.ov-mark[data-variant=wiki] .c,.ov-mark[data-variant=news] .c{animation-duration:6s}
++.ov-mark[data-variant=vip]{--ovm-dot:#facc15}
++.ov-mark[data-variant=tips]{--ovm-dot:#22c55e}
++.ov-mark[data-variant=deals],.ov-mark[data-variant=coupons]{--ovm-dot:#f59e0b}
++.ov-mark[data-variant=trade] .c{animation-timing-function:cubic-bezier(.4,0,.2,1)}
++.ov-mark[data-variant=host] svg{animation:none}
++@keyframes ovmOnAir{0%,100%{transform:scale(1);opacity:.7}50%{transform:scale(1.7);opacity:1}}
++@keyframes ovmBounce{0%,100%{transform:translateY(0)}50%{transform:translateY(-2.5px)}}
++.ov-mark .o{fill:#fff;filter:drop-shadow(0 0 3px currentColor)}
++.ov-mark .o2{opacity:.5}
++.ov-mark:hover svg,a:hover>.ov-mark svg,button:hover>.ov-mark svg{animation:ovmSpin .9s cubic-bezier(.2,1.5,.3,1) 1}
++.ov-mark:hover .c{animation-duration:.9s}
++.ov-mark[data-static] svg,.ov-mark[data-static] .g,.ov-mark[data-static] .c,.ov-mark[data-static] .v,.ov-mark[data-static] .d{animation:none!important}
++.ov-mark[data-static] .o{display:none}
++@keyframes ovmFloat{0%,100%{transform:translateY(0) rotate(0deg)}50%{transform:translateY(-1px) rotate(2deg)}}
++@keyframes ovmGlow{0%,100%{opacity:.08;transform:scale(.92)}50%{opacity:.2;transform:scale(1.06)}}
++@keyframes ovmComet{to{transform:rotate(360deg)}}
++@keyframes ovmDraw{from{stroke-dashoffset:46}to{stroke-dashoffset:0}}
++@keyframes ovmDot{0%,100%{transform:scale(1);opacity:.85}50%{transform:scale(1.5);opacity:1}}
++@keyframes ovmSpin{from{transform:rotate(0deg) scale(1)}40%{transform:rotate(200deg) scale(1.18)}to{transform:rotate(360deg) scale(1)}}
++@media (prefers-reduced-motion:reduce){.ov-mark svg,.ov-mark .g,.ov-mark .c,.ov-mark .v,.ov-mark .d{animation:none!important}.ov-mark .o{display:none}.ov-mark .c{stroke-dasharray:none;opacity:.9}}`;
++ function svg(id) {
++ return `<svg viewBox="0 0 48 48" aria-hidden="true"><defs>
++<linearGradient id="${id}g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="currentColor"/><stop offset="1" stop-color="currentColor" stop-opacity="0.45"/></linearGradient>
++<linearGradient id="${id}v" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fff"/><stop offset="0.5" stop-color="currentColor"/><stop offset="1" stop-color="#fff" stop-opacity="0.9"/><animateTransform attributeName="gradientTransform" type="translate" from="-1 0" to="1 0" dur="3.2s" repeatCount="indefinite"/></linearGradient>
++<path id="${id}p" d="M24,6 A18,18 0 1,1 23.99,6 Z"/></defs>
++<circle class="g" cx="24" cy="24" r="21"/><circle class="r" cx="24" cy="24" r="18"/><circle class="c" cx="24" cy="24" r="18" stroke="url(#${id}g)"/>
++<path class="v" d="M14.5,17 L24,34 L33.5,17" stroke="url(#${id}v)"/><circle class="d" cx="24" cy="34" r="2.6"/>
++<circle class="o" r="2"><animateMotion dur="3.6s" repeatCount="indefinite"><mpath href="#${id}p"/></animateMotion></circle>
++<circle class="o o2" r="1.4"><animateMotion dur="3.6s" begin="0.14s" repeatCount="indefinite"><mpath href="#${id}p"/></animateMotion></circle></svg>`;
++ }
++ function mount(root) {
++ (root || document).querySelectorAll('.ov-mark:not([data-ov])').forEach(el => {
++ el.setAttribute('data-ov', '1');
++ const size = parseInt(el.getAttribute('data-size'), 10);
++ if (size) { el.style.width = size + 'px'; el.style.height = size + 'px'; }
++ el.innerHTML = svg('ovm' + (++n) + '_');
++ });
++ }
++ function init() {
++ if (!document.getElementById('ov-mark-css')) { const st = document.createElement('style'); st.id = 'ov-mark-css'; st.textContent = CSS; document.head.appendChild(st); }
++ mount();
++ try { new MutationObserver(() => mount()).observe(document.body, { childList: true, subtree: true }); } catch { /* */ }
++ }
++ window.ovMarkMount = mount;
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
++})();
ddiff --git a/vendor/openvibe-shared/scripts/build-theme-loader.js b/vendor/openvibe-shared/scripts/build-theme-loader.js
nnew file mode 100644
iindex 00000000..1e10ea12
---- /dev/null
++++ b/vendor/openvibe-shared/scripts/build-theme-loader.js
@@@ -0,0 +1,51 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Regenerates the built-in theme map inside theme-loader.js from builtin-themes.js.
++ *
++ * theme-loader.js runs in the browser before the first paint and cannot require() the catalog, so
++ * it carries a copy. That copy used to be maintained by hand and drifted. Now it is generated:
++ *
++ * node packages/openvibe-shared/scripts/build-theme-loader.js # rewrite
++ * node packages/openvibe-shared/scripts/build-theme-loader.js --check # exit 1 if stale
++ *
++ * Every entry carries the FULL variable set (the theme's own values over the default theme's), so
++ * switching themes replaces every token — a theme that does not name --success no longer keeps the
++ * previous theme's --success on <html>.
++ */
++const fs = require('fs');
++const path = require('path');
++const { BUILTIN_THEMES, DEFAULT_VARS } = require('../builtin-themes');
++
++const LOADER = path.join(__dirname, '..', 'theme-loader.js');
++const BEGIN = ' // THEMES:BEGIN — generated by scripts/build-theme-loader.js from builtin-themes.js; do not edit by hand';
++const END = ' // THEMES:END';
++
++function render() {
++ const lines = BUILTIN_THEMES.map((t) => {
++ const vars = { ...DEFAULT_VARS, ...t.variables };
++ const body = Object.entries(vars).map(([k, v]) => `'${k}':'${String(v).replace(/'/g, "\\'")}'`).join(',');
++ return ` '${t.id}': { ${body} },`;
++ });
++ return `${BEGIN}\n const THEMES = {\n${lines.join('\n')}\n };\n${END}`;
++}
++
++function currentBlock(src) {
++ const a = src.indexOf(BEGIN);
++ const b = src.indexOf(END);
++ if (a >= 0 && b > a) return { a, b: b + END.length };
++ // First run: the hand-written block.
++ const start = src.indexOf(' const THEMES = {');
++ if (start < 0) throw new Error('theme-loader.js: THEMES block not found');
++ const close = src.indexOf('\n };', start);
++ return { a: start, b: close + '\n };'.length };
++}
++
++const src = fs.readFileSync(LOADER, 'utf8');
++const { a, b } = currentBlock(src);
++const next = src.slice(0, a) + render() + src.slice(b);
++const check = process.argv.includes('--check');
++if (next === src) { if (!check) console.log('theme-loader.js is up to date'); process.exit(0); }
++if (check) { console.error('theme-loader.js is stale: run node packages/openvibe-shared/scripts/build-theme-loader.js'); process.exit(1); }
++fs.writeFileSync(LOADER, next);
++console.log(`theme-loader.js regenerated (${BUILTIN_THEMES.length} themes)`);