openvibers/openvibe.live Commits

App icons redrawn from the shared OV mark (rounded + maskable sets), generated manifest; vendor sync

OP OpenVibers committed 18 Sept 2026 · e371ed199e · 18 files, +479 −95


Changes

ddiff --git a/public/assets/logo-192.png b/public/assets/logo-192.png
iindex 9e203673..66927ec5 100644
BBinary files a/public/assets/logo-192.png and b/public/assets/logo-192.png differ
ddiff --git a/public/assets/logo-512.png b/public/assets/logo-512.png
iindex 23d13381..43fa2607 100644
BBinary files a/public/assets/logo-512.png and b/public/assets/logo-512.png differ
ddiff --git a/public/assets/logo-72.png b/public/assets/logo-72.png
iindex 31709a72..310920cf 100644
BBinary files a/public/assets/logo-72.png and b/public/assets/logo-72.png differ
ddiff --git a/public/assets/logo-maskable-192.png b/public/assets/logo-maskable-192.png
nnew file mode 100644
iindex 00000000..2843580c
BBinary files /dev/null and b/public/assets/logo-maskable-192.png differ
ddiff --git a/public/assets/logo-maskable-512.png b/public/assets/logo-maskable-512.png
nnew file mode 100644
iindex 00000000..5b2df8dc
BBinary files /dev/null and b/public/assets/logo-maskable-512.png differ
ddiff --git a/public/assets/logo.svg b/public/assets/logo.svg
iindex 7ff8e08b..63c22cd8 100644
---- a/public/assets/logo.svg
++++ b/public/assets/logo.svg
@@@ -1,24 +1,8 @@
--<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
-- <!-- OpenVibe.Live mark: play glyph inside a broadcast ring, riding a vibe wave -->
-- <defs>
-- <linearGradient id="ovl-accent" x1="0" y1="0" x2="1" y2="1">
-- <stop offset="0" stop-color="#a78bfa"/>
-- <stop offset="1" stop-color="#6d28d9"/>
-- </linearGradient>
-- </defs>
-- <!-- Soft glow -->
-- <circle cx="64" cy="58" r="40" fill="#8b5cf6" opacity="0.12"/>
-- <!-- Broadcast ring -->
-- <circle cx="64" cy="58" r="32" fill="none" stroke="url(#ovl-accent)" stroke-width="5"/>
-- <!-- Play triangle -->
-- <path d="M56 42 L82 58 L56 74 Z" fill="#8b5cf6"/>
-- <!-- Broadcast waves (top right) -->
-- <path d="M94 26 C99 21, 106 26, 101 32" stroke="#8b5cf6" stroke-width="3" fill="none" stroke-linecap="round"/>
-- <path d="M100 18 C108 11, 118 21, 110 29" stroke="#a78bfa" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.7"/>
-- <!-- Live dot -->
-- <circle cx="91" cy="31" r="3" fill="#e74c3c"/>
-- <!-- Vibe wave underline -->
-- <path d="M26 98 Q38 90, 50 98 T74 98 T98 98" stroke="#22d3ee" stroke-width="3.5" fill="none" stroke-linecap="round" opacity="0.9"/>
-- <!-- Wordmark -->
-- <text x="64" y="120" text-anchor="middle" font-family="'Segoe UI', system-ui, sans-serif" font-size="15" font-weight="700" fill="#a78bfa">OpenVibe.Live</text>
--</svg>
++<svg xmlns="http://www.w3.org/2000/svg" viewBox="-32 -32 64 64"><defs><linearGradient id="ring" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#60a5fa"/><stop offset="1" stop-color="#2563eb"/></linearGradient>
++<radialGradient id="glow"><stop offset="0" stop-color="#60a5fa" stop-opacity=".34"/><stop offset="1" stop-color="#60a5fa" stop-opacity="0"/></radialGradient>
++<radialGradient id="bg" cx=".5" cy=".38" r=".8"><stop offset="0" stop-color="#16223a"/><stop offset=".6" stop-color="#0a0f1c"/><stop offset="1" stop-color="#060912"/></radialGradient></defs><rect x="-32" y="-32" width="64" height="64" rx="15" fill="url(#bg)"/><g transform="scale(1.22)">
++<circle r="21" fill="url(#glow)"/>
++<circle r="18" fill="none" stroke="url(#ring)" stroke-width="4" opacity=".38"/>
++<path d="M0,-18 A18,18 0 0,1 17.4,4.6" fill="none" stroke="url(#ring)" stroke-width="4.4" stroke-linecap="round"/>
++<path d="M-9.5,-7 L0,10 L9.5,-7" fill="none" stroke="#fff" stroke-width="4.8" stroke-linecap="round" stroke-linejoin="round"/>
++<circle cx="17.4" cy="4.6" r="3.2" fill="#ef4444"/></g></svg>
\\ No newline at end of file
ddiff --git a/public/manifest.webmanifest b/public/manifest.webmanifest
iindex 777193f4..74c6c30d 100644
---- a/public/manifest.webmanifest
++++ b/public/manifest.webmanifest
@@@ -1,30 +1,48 @@
{
"name": "OpenVibe.Live",
-- "short_name": "OpenVibe.Live",
-- "description": "Live streaming on OpenVibe",
++ "short_name": "OpenVibe",
++ "description": "Live streams, clips and chat on the open network",
"start_url": "/",
"scope": "/",
"display": "standalone",
-- "background_color": "#1a1a24",
++ "background_color": "#0a0f1c",
"theme_color": "#3b82f6",
"icons": [
{
"src": "/assets/logo-72.png",
"sizes": "72x72",
"type": "image/png",
-- "purpose": "any maskable"
++ "purpose": "any"
},
{
"src": "/assets/logo-192.png",
"sizes": "192x192",
"type": "image/png",
-- "purpose": "any maskable"
++ "purpose": "any"
},
{
"src": "/assets/logo-512.png",
"sizes": "512x512",
"type": "image/png",
-- "purpose": "any maskable"
++ "purpose": "any"
++ },
++ {
++ "src": "/assets/logo-maskable-192.png",
++ "sizes": "192x192",
++ "type": "image/png",
++ "purpose": "maskable"
++ },
++ {
++ "src": "/assets/logo-maskable-512.png",
++ "sizes": "512x512",
++ "type": "image/png",
++ "purpose": "maskable"
++ },
++ {
++ "src": "/assets/logo.svg",
++ "sizes": "any",
++ "type": "image/svg+xml",
++ "purpose": "any"
}
]
--}
\\ No newline at end of file
++}
ddiff --git a/vendor/openvibe-shared/account-switcher.js b/vendor/openvibe-shared/account-switcher.js
iindex 04e9dbc1..6e6ec628 100644
---- a/vendor/openvibe-shared/account-switcher.js
++++ b/vendor/openvibe-shared/account-switcher.js
@@@ -378,7 +378,7 @@
.openvibe-switcher-item.active { background: rgba(192,150,92,0.08); }
.openvibe-switcher-item.active::after {
content: '✓'; position: absolute; right: 12px; top: 50%; transform: translateY(-50%);
-- color: var(--accent, #8b5cf6); font-weight: 700; font-size: 16px;
++ color: var(--accent, #3b82f6); font-weight: 700; font-size: 16px;
}
.openvibe-switcher-item img { width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0; }
.openvibe-switcher-item .info { flex: 1; line-height: 1.3; }
@@@ -411,7 +411,7 @@
transition: all .15s;
}
.openvibe-switcher-actions .btn-add {
-- background: var(--accent, #8b5cf6); color: #fff;
++ background: var(--accent, #3b82f6); color: #fff;
}
.openvibe-switcher-actions .btn-add:hover { background: var(--accent-dark, #a07840); }
.openvibe-switcher-actions .btn-signout {
ddiff --git a/vendor/openvibe-shared/app-icon.js b/vendor/openvibe-shared/app-icon.js
nnew file mode 100644
iindex 00000000..b9160322
---- /dev/null
++++ b/vendor/openvibe-shared/app-icon.js
@@@ -0,0 +1,74 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// openvibe-shared/app-icon — one app icon design for every OpenVibe site.
++//
++// The icon is the OV mark as it appears in the navbar (ring, comet, white V, dot) on the network's
++// deep navy, tinted per site. No text: a home-screen icon is a few millimetres wide.
++// svg({ site, maskable }) full-bleed square SVG. maskable keeps the mark inside the 40 % radius
++// safe zone, so round, squircle and teardrop masks never clip it.
++// favicon({ site }) the same mark on a rounded tile, tuned for 16–32 px.
++// manifest({ site, name, shortName, description, startUrl }) web app manifest with shared colours.
++// SITES accent + dot colour per site (the dot is the one thing that differs,
++// matching ov-mark.js variants: Live is "on air" red).
++// PNG sets are rendered from these by scripts/build-app-icons.js.
++// ═══════════════════════════════════════════════════════════════
++const BG = '#0a0f1c', BG_EDGE = '#060912';
++const SITES = {
++ network: { accent: ['#60a5fa', '#2563eb'], dot: '#ffffff' },
++ live: { accent: ['#60a5fa', '#2563eb'], dot: '#ef4444' },
++ tools: { accent: ['#38bdf8', '#2563eb'], dot: '#ffffff' },
++ community: { accent: ['#34d399', '#2563eb'], dot: '#ffffff' },
++ games: { accent: ['#a78bfa', '#2563eb'], dot: '#facc15' },
++ media: { accent: ['#fbbf24', '#2563eb'], dot: '#ffffff' },
++};
++const of = (site) => SITES[site] || SITES.network;
++
++function mark(site, scale) {
++ // Drawn in a 48-unit box centred on 0,0 (same geometry as ov-mark.js), then scaled.
++ const c = of(site);
++ return `<g transform="scale(${scale})">
++<circle r="21" fill="url(#glow)"/>
++<circle r="18" fill="none" stroke="url(#ring)" stroke-width="4" opacity=".38"/>
++<path d="M0,-18 A18,18 0 0,1 17.4,4.6" fill="none" stroke="url(#ring)" stroke-width="4.4" stroke-linecap="round"/>
++<path d="M-9.5,-7 L0,10 L9.5,-7" fill="none" stroke="#fff" stroke-width="4.8" stroke-linecap="round" stroke-linejoin="round"/>
++<circle cx="17.4" cy="4.6" r="3.2" fill="${c.dot}"/></g>`;
++}
++
++function defs(site) {
++ const [a, b] = of(site).accent;
++ return `<defs><linearGradient id="ring" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${a}"/><stop offset="1" stop-color="${b}"/></linearGradient>
++<radialGradient id="glow"><stop offset="0" stop-color="${a}" stop-opacity=".34"/><stop offset="1" stop-color="${a}" stop-opacity="0"/></radialGradient>
++<radialGradient id="bg" cx=".5" cy=".38" r=".8"><stop offset="0" stop-color="#16223a"/><stop offset=".6" stop-color="${BG}"/><stop offset="1" stop-color="${BG_EDGE}"/></radialGradient></defs>`;
++}
++
++/** Full-bleed square. `maskable`: mark radius ≤ 40 % of the half-width; otherwise it fills more of the tile. */
++function svg({ site = 'network', maskable = true, size = 512 } = {}) {
++ const scale = maskable ? 3.7 : 4.6; // 21u glow radius × 3.7 ≈ 78u of the 256u half-width… well inside 40 %
++ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-256 -256 512 512" width="${size}" height="${size}">${defs(site)}
++<rect x="-256" y="-256" width="512" height="512" fill="url(#bg)"${maskable ? '' : ' rx="112"'}/>${mark(site, scale * (maskable ? 1.55 : 1.6))}</svg>`;
++}
++
++/** Small sizes: thicker strokes read better, and the tile is rounded because nothing masks a favicon. */
++function favicon({ site = 'network' } = {}) {
++ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-32 -32 64 64">${defs(site)}<rect x="-32" y="-32" width="64" height="64" rx="15" fill="url(#bg)"/>${mark(site, 1.22)}</svg>`;
++}
++
++function manifest({ site = 'network', name, shortName, description, startUrl = '/', iconBase = '/assets' }) {
++ const sizes = [72, 192, 512];
++ return {
++ name, short_name: shortName || name, description, start_url: startUrl, scope: '/', display: 'standalone',
++ background_color: BG, theme_color: '#3b82f6',
++ icons: [...sizes.map(s => ({ src: `${iconBase}/logo-${s}.png`, sizes: `${s}x${s}`, type: 'image/png', purpose: 'any' })),
++ ...[192, 512].map(s => ({ src: `${iconBase}/logo-maskable-${s}.png`, sizes: `${s}x${s}`, type: 'image/png', purpose: 'maskable' })),
++ { src: `${iconBase}/logo.svg`, sizes: 'any', type: 'image/svg+xml', purpose: 'any' }],
++ };
++}
++
++/** <head> tags every site shares: SVG favicon (inline, no request), theme colour, and the PNG touch icon when the site ships one. */
++function headTags({ site = 'network', iconBase = null } = {}) {
++ const uri = 'data:image/svg+xml,' + encodeURIComponent(favicon({ site })).replace(/'/g, '%27');
++ return [`<link rel="icon" type="image/svg+xml" href="${uri}">`, '<meta name="theme-color" content="#3b82f6">', `<meta name="color-scheme" content="dark light">`,
++ iconBase ? `<link rel="apple-touch-icon" href="${iconBase}/logo-192.png">` : '', iconBase ? `<link rel="manifest" href="/manifest.webmanifest">` : ''].filter(Boolean).join('\n');
++}
++
++module.exports = { svg, favicon, manifest, headTags, SITES, BG };
ddiff --git a/vendor/openvibe-shared/footer.js b/vendor/openvibe-shared/footer.js
iindex b86da573..20aa601f 100644
---- a/vendor/openvibe-shared/footer.js
++++ b/vendor/openvibe-shared/footer.js
@@@ -27,6 +27,42 @@
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
++ const root = typeof globalThis !== 'undefined' ? globalThis : (typeof window !== 'undefined' ? window : {});
++ // ── Shared chrome data (https://openvibe.network/api/chrome) ─────────────────────────────
++ // Sites ordered by real use, footer copy and per-site legal links. Cached per host for 30
++ // minutes in localStorage and refreshed in the background, so pages paint from cache and the
++ // order never jumps while someone is looking at it. Defined once, shared by navbar + footer.
++ const OVChrome = root.OpenVibeChrome || (root.OpenVibeChrome = (function () {
++ const KEY = 'ov_chrome_v1', TTL = 30 * 60000;
++ let inflight = null;
++ const host = () => (typeof location !== 'undefined' ? location.hostname : '');
++ const https = (u) => { try { return new URL(u).protocol === 'https:'; } catch { return false; } };
++ function clean(d) {
++ if (!d || !Array.isArray(d.nav)) return null;
++ const link = (l) => (l && typeof l.name === 'string' && https(l.url) ? { id: String(l.id || ''), name: l.name.slice(0, 60), url: l.url, icon: String(l.icon || ''), tagline: String(l.tagline || '').slice(0, 80) } : null);
++ const f = d.footer || {}, lg = f.legal || {};
++ return { nav: d.nav.map(link).filter(Boolean).slice(0, 12), soon: (d.soon || []).map(link).filter(Boolean).slice(0, 24),
++ footer: { blurb: String(f.blurb || '').slice(0, 200), discover: (f.discover || []).map(link).filter(Boolean).slice(0, 6), popular: (f.popular || []).map(link).filter(Boolean).slice(0, 10),
++ legal: https(lg.terms) && https(lg.privacy) && https(lg.dmca) ? { terms: lg.terms, privacy: lg.privacy, dmca: lg.dmca } : null } };
++ }
++ function cached() { try { const c = JSON.parse(localStorage.getItem(KEY) || 'null'); return c && c.host === host() && c.data ? c : null; } catch { return null; } }
++ function refresh() {
++ if (inflight || typeof fetch === 'undefined') return inflight || Promise.resolve(null);
++ inflight = fetch('https://openvibe.network/api/chrome?host=' + encodeURIComponent(host()), { credentials: 'omit' })
++ .then(r => (r.ok ? r.json() : null)).then(clean)
++ .then(d => { if (d) { try { localStorage.setItem(KEY, JSON.stringify({ at: Date.now(), host: host(), data: d })); } catch { /* */ } } return d; })
++ .catch(() => null);
++ return inflight;
++ }
++ /** Cached data now (or null); `onFirst` fires once when a first-ever fetch lands. */
++ function get(onFirst) {
++ const c = cached();
++ if (!c || Date.now() - c.at > TTL) { const p = refresh(); if (!c && onFirst) p.then(d => { if (d) onFirst(d); }); }
++ return c ? c.data : null;
++ }
++ return { get, refresh };
++ })());
++
const NETWORK_URL = 'https://openvibe.network';
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
@@@ -50,9 +86,9 @@
];
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' },
++ { key: 'terms', name: 'Terms of Service', path: '/tos', fallback: 'https://openvibe.live/tos' },
++ { key: 'privacy', name: 'Privacy Policy', path: '/privacy', fallback: 'https://openvibe.live/privacy' },
++ { key: 'dmca', name: 'DMCA', path: '/dmca', fallback: 'https://openvibe.live/dmca' },
];
const SOCIAL = [
@@@ -106,6 +142,9 @@
}
function legalHref(item, cfg) {
++ // Each site answers for itself: the chrome service names this domain's own documents.
++ const chrome = cfg._chrome;
++ if (chrome && chrome.footer.legal && !cfg.legalBase) return chrome.footer.legal[item.key];
// 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;
@@@ -114,7 +153,9 @@
// ── Markup ─────────────────────────────────────────────────
function linkTag(item) {
-- const rel = /^https?:\/\//.test(item.url) ? ' target="_blank" rel="noopener"' : '';
++ // Our own sites open in this tab (the sign-in hand-off follows the click); the rest of the web in a new one.
++ const own = /^https:\/\/([a-z0-9-]+\.)*(openvibe\.[a-z]+|openre\.stream)(\/|$)/i.test(item.url);
++ const rel = /^https?:\/\//.test(item.url) && !own ? ' 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>`;
@@@ -124,7 +165,14 @@
const c = { ...DEFAULTS, ...(cfg || {}) };
const service = c.service || detectService();
const brand = c.brandName || brandFor(service);
-- const network = NETWORK.filter(n => n.id !== service);
++ const chrome = c._chrome = OVChrome.get(() => { try { render(); } catch { /* */ } });
++ // Most used sites first when the chrome service has spoken; the built-in list otherwise.
++ const network = chrome && chrome.nav.length
++ ? chrome.nav.filter(n => n.id !== service && !(typeof location !== 'undefined' && new URL(n.url).hostname === location.hostname)).map(n => ({ name: 'OpenVibe.' + n.name, url: n.url, desc: n.tagline }))
++ : NETWORK.filter(n => n.id !== service);
++ const tagline = (cfg && cfg.tagline) || (chrome && chrome.footer.blurb) || c.tagline;
++ const popular = chrome ? chrome.footer.popular : [];
++ const discover = chrome ? chrome.footer.discover.filter(d => !network.slice(0, 6).some(n => n.url === d.url)) : [];
const year = new Date().getFullYear();
if (c.variant === 'compact') {
@@@ -149,7 +197,7 @@
<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>
++ <p class="ovf-tagline">${esc(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>
@@@ -161,6 +209,11 @@
${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>` : ''}
++ ${popular.length ? `<nav class="ovf-col" aria-label="Popular tools">
++ <h3>Popular tools</h3>
++ ${popular.slice(0, 6).concat(discover.slice(0, 2)).map(linkTag).join('')}
++ <a class="ovf-more" href="https://openvibe.tools/all-tools">All tools <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('')}
@@@ -229,7 +282,7 @@
.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-col a:hover{color:var(--accent,#60a5fa);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}
@@@ -237,23 +290,23 @@
.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-social a:hover{color:var(--accent,#60a5fa);border-color:var(--accent,#60a5fa);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)));
++ background:color-mix(in srgb, var(--accent,#60a5fa) 8%, var(--bg-tertiary,#1a1b28));
++ border:1px solid color-mix(in srgb, var(--accent,#60a5fa) 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}
++ background:var(--accent,#60a5fa);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 a:hover{color:var(--accent,#60a5fa);border-color:var(--accent,#60a5fa);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));
@@@ -261,7 +314,7 @@
.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-legal-inline a:hover,.ovf-compact-links a:hover{color:var(--accent,#60a5fa)}
.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){
ddiff --git a/vendor/openvibe-shared/legal.js b/vendor/openvibe-shared/legal.js
nnew file mode 100644
iindex 00000000..e9286434
---- /dev/null
++++ b/vendor/openvibe-shared/legal.js
@@@ -0,0 +1,146 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// openvibe-shared/legal — Terms, Privacy and DMCA for every OpenVibe site.
++//
++// Each domain answers for itself: /terms, /privacy and /dmca live on the site's own apex and say
++// what THAT site does. One template per document, with clauses switched by the site's profile:
++// streaming live video, chat, payments tools files processed, then deleted
++// ugc posts, pastes, comments games accounts and game state
++// hosting stored user media account the identity provider
++// info read-only / placeholder site
++//
++// const legal = require('openvibe-shared/legal');
++// app.get(legal.PATHS, legal.handler({ host: 'openvibe.tools', name: 'OpenVibe.Tools', profile: 'tools' }));
++// legal.page('privacy', site) → complete HTML document (static generators use this)
++//
++// Plain language on purpose. These are templates maintained by the project, not legal advice;
++// the owner reviews them before relying on them. Facts stated here (what is stored, for how
++// long) must match the code — change both together.
++// ═══════════════════════════════════════════════════════════════
++const UPDATED = '2026-09-18';
++const UPDATED_TEXT = 'September 18, 2026';
++const CONTACT = '[email protected]';
++const PATHS = ['/terms', '/privacy', '/dmca'];
++const TITLES = { terms: 'Terms of Service', privacy: 'Privacy Policy', dmca: 'DMCA & Copyright' };
++
++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++const has = (site, ...profiles) => profiles.includes(site.profile);
++const userContent = (site) => has(site, 'streaming', 'ugc', 'hosting', 'games');
++
++function terms(site) {
++ const n = esc(site.name);
++ const S = [];
++ S.push(['The short version', `<p>${n} is part of OpenVibe, an open source, community-run network. Use it for lawful things, be decent to other people, and do not try to break it. We can remove content or accounts that break these terms. The software is provided as it is, without warranties.</p>`]);
++ S.push(['Who can use it', `<p>You must be at least 13 years old, or the minimum age of digital consent where you live. If you are under 18, a parent or guardian must agree to these terms for you. You may not use ${n} if you were previously banned from the network.</p>`]);
++ if (!has(site, 'info')) S.push(['Your account', `<p>Most of ${n} works without signing in. An OpenVibe account, managed at <a href="https://openvibe.network/">openvibe.network</a>, adds things that need to remember you. You are responsible for what happens under your account; keep your password to yourself and tell us if you think someone else has it.</p>`]);
++ if (has(site, 'tools')) S.push(['Files and links you give the tools', `<p>The tools process what you upload or paste and hand you the result. Uploads and results are temporary: they are deleted automatically, normally within one hour for visitors and within 24 hours for signed-in users. Do not rely on ${n} to store anything.</p><p>Only process material you have the right to use. Downloaders are for your own uploads and for content you are allowed to keep; respecting the terms of the site the content comes from is your responsibility. Lookup tools (DNS, WHOIS, ping, port checks) may only be pointed at systems you are allowed to test.</p>`]);
++ if (userContent(site)) S.push(['What you post', `<p>You keep ownership of what you ${has(site, 'streaming') ? 'stream, upload, clip or write' : 'upload or write'}. You give OpenVibe a worldwide, non-exclusive, royalty-free licence to host, display, transcode and distribute it so the service can work, including on other OpenVibe sites where your content is meant to appear. The licence ends when you delete the content, except for copies other people made while it was public and backups that expire on their own.</p><p>You are responsible for having the rights to everything you post.</p>`]);
++ S.push(['Rules', `<p>OpenVibe leans toward open expression. These limits apply everywhere:</p><ul><li>Nothing illegal where you are or where our servers are (United States).</li><li>No sexual content involving minors, and no sexualisation of minors, ever.</li><li>No credible threats of violence, and no publishing of someone's private information to harm them.</li><li>No content you do not have the rights to.</li><li>No malware, phishing, spam, scraping that ignores robots.txt rate limits, or attempts to disrupt or gain unauthorised access to the service or to other people's systems.</li>${has(site, 'streaming') ? '<li>Streams must be labelled honestly; adult or graphic content belongs behind the mature flag.</li>' : ''}</ul>`]);
++ if (has(site, 'streaming')) S.push(['Money', `<p>Donations, tips and purchases of virtual currency are voluntary and final except where the law requires a refund. Virtual currencies have no cash value outside the payout rules shown on the site. Streamers are responsible for their own taxes.</p>`]);
++ if (has(site, 'games')) S.push(['Game items and progress', `<p>Items, currency and progress in games have no cash value and may be changed, reset or removed as the games evolve. Cheating, automation that gives an unfair advantage and exploiting bugs can lead to a reset or a ban.</p>`]);
++ S.push(['Moderation and ending accounts', `<p>We may remove content, limit features, or suspend accounts that break these terms or put other people or the service at risk. You can stop using ${n} at any time and delete your account from your account settings.</p>`]);
++ S.push(['Open source', `<p>OpenVibe's code is public. The code is licensed under the terms in each repository; those licences, not this page, govern your use of the code. These terms cover your use of the hosted service at ${esc(site.host)}.</p>`]);
++ S.push(['No warranty, limited liability', `<p>The service is provided "as is" and "as available". To the extent the law allows, OpenVibe and the people who run it are not liable for indirect or consequential losses, lost data, or lost profits arising from your use of ${n}. Nothing here limits liability that cannot be limited by law.</p>`]);
++ S.push(['Governing law', `<p>These terms are governed by the laws of the United States and the State of Washington, without regard to conflicts of law. Disputes are handled in the federal or state courts located in Washington State. If part of these terms cannot be enforced, the rest still applies.</p>`]);
++ S.push(['Changes and contact', `<p>We may update these terms. Material changes are announced on the site before they take effect. Questions: <a href="mailto:${CONTACT}">${CONTACT}</a>.</p>`]);
++ return S;
++}
++
++function privacy(site) {
++ const n = esc(site.name);
++ const S = [];
++ S.push(['The short version', `<p>${n} collects what it needs to work and to keep abuse out, and no more. We do not sell personal information and we do not run advertising trackers.</p>`]);
++ const collect = [
++ '<li><b>Request logs.</b> Like every web server, ours record your IP address, browser type, the page requested and the time. We use them for security, rate limiting and aggregate traffic statistics.</li>',
++ '<li><b>Usage statistics.</b> Page views and a random session identifier, so we can see which parts of the network people use. These are first-party statistics kept on our own servers.</li>',
++ ];
++ if (!has(site, 'info')) collect.push(`<li><b>Account information, if you sign in.</b> Your OpenVibe account (username, email address, password hash, profile and preferences) is held by <a href="https://openvibe.network/privacy">openvibe.network</a>. ${n} receives your user id, username, avatar and role so it can show you as signed in.</li>`);
++ if (has(site, 'tools')) collect.push('<li><b>What you give a tool.</b> Files, text, links, domain names and IP addresses you submit are processed to produce your result. Uploaded files and generated results are deleted automatically, normally within one hour for visitors and 24 hours for signed-in users. Many text and developer tools run entirely in your browser and send nothing to us.</li>');
++ if (has(site, 'streaming')) collect.push('<li><b>Streams, clips, VODs and chat.</b> What you broadcast and write is stored so it can be shown, and is public unless you choose otherwise.</li><li><b>Payments.</b> Payment processors handle card details; we receive a record of the transaction, not your card number.</li><li><b>AI features.</b> Public streams and chat may be transcribed and summarised by AI services to create captions, highlights and overviews.</li>');
++ if (has(site, 'ugc')) collect.push('<li><b>What you post.</b> Pastes, posts and comments, with the account that made them. Public items are visible to everyone and may be indexed by search engines.</li>');
++ if (has(site, 'hosting')) collect.push('<li><b>Media you upload or record.</b> Videos, clips, images and files, with their metadata, stored so the sites that use them can show them.</li>');
++ if (has(site, 'games')) collect.push('<li><b>Game state.</b> Your characters, items, progress and in-game chat.</li>');
++ if (has(site, 'account')) collect.push('<li><b>Your account.</b> Username, email address, a salted hash of your password, profile, theme and notification preferences, linked services, sign-in history and, if you keep it switched on, your cross-site history.</li>');
++ S.push(['What we collect', `<ul>${collect.join('')}</ul>`]);
++ S.push(['Cookies and local storage', `<p>We use a small number of first-party cookies: a sign-in token (<code>ov_token</code>), a refresh token, and a hint that remembers whether you were signed in so other OpenVibe sites can sign you in quietly. Your theme, recent pages and similar preferences are kept in your browser's local storage. There are no third-party advertising cookies. Our sites sit behind Cloudflare, which may set its own security cookies.</p>`]);
++ S.push(['Who we share with', `<p>Only the services needed to run the network: our hosting and network providers (OVH, Cloudflare), storage providers for media${has(site, 'streaming') ? ', payment processors, AI providers used for transcription and summaries' : ''}, and email delivery for account messages. We disclose information when the law requires it, and we tell you when we are allowed to.</p>`]);
++ S.push(['Across OpenVibe sites', `<p>OpenVibe sites share one account. When you are signed in, the site you visit learns who you are from openvibe.network; it does not receive your password or email address. You can see and clear your cross-site history, and switch it off, at <a href="https://openvibe.network/history">openvibe.network/history</a>.</p>`]);
++ S.push(['How long we keep things', `<p>Request logs and raw usage events are kept for a limited period and then reduced to totals. ${has(site, 'tools') ? 'Tool uploads and results are deleted within hours. ' : ''}${userContent(site) ? 'Content you post stays until you delete it or your account. ' : ''}Account data stays until you delete your account; backups expire on their own shortly afterwards.</p>`]);
++ S.push(['Your choices and rights', `<p>You can use most of ${n} without an account. With an account you can view, change, export and delete your data from your account settings, or ask us to do it. Depending on where you live (for example the EEA, the UK or California) you may have additional rights to access, correct, delete or restrict the use of your data; write to us and we will honour them.</p>`]);
++ S.push(['Children', `<p>${n} is not for children under 13, and we do not knowingly collect their information. If you believe a child has given us personal information, contact us and we will delete it.</p>`]);
++ S.push(['Changes and contact', `<p>We will post changes here and update the date above. Questions and requests: <a href="mailto:${CONTACT}">${CONTACT}</a>.</p>`]);
++ return S;
++}
++
++function dmca(site) {
++ const n = esc(site.name);
++ const S = [];
++ S.push(['Our position', `<p>${n} respects copyright and responds to clear notices of infringement under the Digital Millennium Copyright Act (17 U.S.C. § 512).${has(site, 'tools') ? ` The tools here process material at a user's request and do not keep a library of it: results are deleted automatically within hours.` : ''}${has(site, 'info') ? ` ${n} publishes its own material and does not host uploads from the public.` : ''}</p>`]);
++ S.push(['Send a takedown notice', `<p>Email <a href="mailto:${CONTACT}">${CONTACT}</a> with all of the following:</p><ol><li>Your name, address, phone number and email address, and whom you represent.</li><li>The copyrighted work you say was infringed.</li><li>The exact URL on ${esc(site.host)} of the material you want removed. General descriptions are not enough to find it.</li><li>A statement that you have a good-faith belief the use is not authorised by the copyright owner, its agent or the law.</li><li>A statement, under penalty of perjury, that the information is accurate and that you are the owner or authorised to act for the owner.</li><li>Your physical or electronic signature.</li></ol>`]);
++ S.push(['What happens next', `<p>We remove or disable access to the material, tell the person who posted it, and give them your notice. We act on complete notices as quickly as we can, normally within a few business days.</p>`]);
++ if (!has(site, 'info')) S.push(['Counter-notice', `<p>If your material was removed by mistake or misidentification, send a counter-notice to the same address with: your contact details, the material and where it appeared, a statement under penalty of perjury that you believe it was removed by mistake, your consent to the jurisdiction of the federal court for your district (or for Washington State if you are outside the United States), and your signature. We forward it to the complainant and may restore the material after 10 to 14 business days unless they tell us they have filed a court action.</p>`]);
++ S.push(['Repeat infringers and false claims', `<p>Accounts that repeatedly infringe are terminated. Knowingly false notices and counter-notices carry legal liability under § 512(f); we also refuse notices that are clearly abusive.</p>`]);
++ return S;
++}
++
++const BUILDERS = { terms, privacy, dmca };
++
++/** { title, description, sections: [[heading, html]], updated } for one document of one site. */
++function build(kind, site) {
++ if (!BUILDERS[kind]) throw new Error('Unknown legal document: ' + kind);
++ const s = Object.assign({ profile: 'info' }, site);
++ return { kind, title: `${TITLES[kind]} — ${s.name}`, heading: TITLES[kind],
++ description: { terms: `The rules for using ${s.name}, in plain language.`, privacy: `What ${s.name} collects, why, and the choices you have.`, dmca: `How to report copyright infringement on ${s.name}, and how to respond to a report.` }[kind],
++ updated: UPDATED, sections: BUILDERS[kind](s) };
++}
++
++/** Article markup only, for sites that wrap it in their own layout. */
++function article(kind, site) {
++ const d = build(kind, site);
++ const others = Object.keys(TITLES).filter(k => k !== kind);
++ return `<article class="ov-legal"><p class="ov-legal-kicker">${esc(site.name)}</p><h1>${esc(d.heading)}</h1><p class="ov-legal-date">Last updated <time datetime="${d.updated}">${UPDATED_TEXT}</time></p>
++${d.sections.map(([h, html], i) => `<section><h2 id="s${i + 1}">${esc(h)}</h2>${html}</section>`).join('\n')}
++<nav class="ov-legal-more" aria-label="Other policies">${others.map(k => `<a href="/${k}">${esc(TITLES[k])}</a>`).join('')}<a href="https://openvibe.network/">About OpenVibe</a></nav></article>`;
++}
++
++const CSS = `*{box-sizing:border-box}body{margin:0;background:var(--bg-primary,#0a0f18);color:var(--text-primary,#e6edf7);font:400 16px/1.65 Inter,system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
++.ov-legal{max-width:760px;margin:0 auto;padding:40px 20px 24px}.ov-legal-kicker{margin:0;font-size:13px;font-weight:700;letter-spacing:.6px;text-transform:uppercase;color:var(--accent-light,var(--accent,#60a5fa))}
++.ov-legal h1{font-size:clamp(28px,4.5vw,40px);letter-spacing:-.03em;line-height:1.1;margin:6px 0 4px}.ov-legal-date{color:var(--text-muted,#7d8aa0);font-size:14px;margin:0 0 28px}
++.ov-legal h2{font-size:19px;margin:30px 0 8px;letter-spacing:-.01em}.ov-legal p,.ov-legal li{color:var(--text-secondary,#a8b3c4)}.ov-legal li{margin:6px 0}.ov-legal b{color:var(--text-primary,#e6edf7)}
++.ov-legal a{color:var(--accent-light,var(--accent,#60a5fa))}.ov-legal code{font-size:13.5px;background:var(--bg-secondary,#111826);padding:1px 6px;border-radius:5px}
++.ov-legal-more{display:flex;flex-wrap:wrap;gap:10px;margin-top:40px;padding-top:20px;border-top:1px solid var(--border,rgba(255,255,255,.1))}.ov-legal-more a{padding:8px 14px;border:1px solid var(--border,rgba(255,255,255,.12));border-radius:10px;text-decoration:none;font-weight:600;font-size:14px}`;
++
++/** A complete page: shared theme, navbar and footer, readable without JavaScript. */
++function page(kind, site) {
++ const d = build(kind, site);
++ const url = `https://${site.host}/${kind}`;
++ let head;
++ try { head = require('./seo').headTags({ title: d.title, description: d.description, canonical: url, siteName: site.name, type: 'article' }); }
++ catch { head = `<title>${esc(d.title)}</title><meta name="description" content="${esc(d.description)}"><link rel="canonical" href="${url}">`; }
++ return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
++${head}
++${require('./app-icon').headTags({ site: site.id })}
++<script src="https://openvibe.network/shared/theme-loader.js"></script><style>${CSS}</style></head><body>
++<noscript><p style="padding:12px 20px;margin:0"><a href="/">${esc(site.name)}</a></p></noscript>
++<main>${article(kind, site)}</main><div id="ov-footer"></div>
++<script src="https://openvibe.network/shared/navbar.js" defer></script><script src="https://openvibe.network/shared/footer.js" defer></script>
++<script>addEventListener('DOMContentLoaded',function(){var t=null;try{t=(document.cookie.match(/(?:^|; )ov_token=([^;]*)/)||[])[1]||localStorage.getItem('ov_token')}catch(e){}
++try{OpenVibeNavbar.init({service:${JSON.stringify(site.service || site.id || 'network')},apiBase:'https://openvibe.network',token:t?decodeURIComponent(t):null})}catch(e){}
++try{OpenVibeFooter.init({service:${JSON.stringify(site.service || site.id || 'network')},variant:'compact',mount:'#ov-footer',apiBase:'https://openvibe.network'})}catch(e){}});</script>
++</body></html>`;
++}
++
++/** Express handler for legal.PATHS. */
++function handler(site) {
++ const cache = new Map();
++ return function ovLegal(req, res, next) {
++ const kind = String(req.path || '').replace(/^\/+|\/+$/g, '');
++ if (!BUILDERS[kind]) return next();
++ if (!cache.has(kind)) cache.set(kind, page(kind, site));
++ res.set('Content-Type', 'text/html; charset=utf-8');
++ res.set('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400');
++ res.send(cache.get(kind));
++ };
++}
++
++module.exports = { PATHS, TITLES, UPDATED, CONTACT, build, article, page, handler, CSS };
ddiff --git a/vendor/openvibe-shared/navbar.js b/vendor/openvibe-shared/navbar.js
iindex a52db683..0285bf35 100644
---- a/vendor/openvibe-shared/navbar.js
++++ b/vendor/openvibe-shared/navbar.js
@@@ -61,11 +61,28 @@
color: var(--text-primary, #e0e0e0);
}
.openvibe-navbar-brand { display: flex; align-items: center; gap: 9px; text-decoration: none; color: inherit; margin-right: 8px; min-width: 0; }
-- .openvibe-navbar-brand a { color: inherit; text-decoration: none; }
-- .openvibe-navbar-brand a.b-core:hover, .openvibe-navbar-brand a.b-tld:hover, .openvibe-navbar-brand a.b-sub:hover { color: var(--accent-light, var(--accent, #60a5fa)); text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1.5px; }
-- .openvibe-navbar-brand a:focus-visible, .ovnav-launch:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; border-radius: 6px; }
-- .ovnav-launch { appearance: none; border: 0; background: transparent; color: var(--text-secondary, #a8b3c4); width: 32px; height: 32px; border-radius: 9px; display: inline-grid; place-items: center; cursor: pointer; flex: none; margin-right: 6px; transition: background .15s, color .15s; }
-- .ovnav-launch:hover, .ovnav-launch[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.14)); color: var(--accent-light, var(--accent, #60a5fa)); }
++ /* Brand group: mark + linked wordmark + launcher read as one control. Every property a host
++ page might set on bare "nav a" / "a" is reset here, so the brand looks the same everywhere. */
++ .openvibe-navbar .openvibe-navbar-brand a, .openvibe-navbar .openvibe-navbar-brand a:hover { all: unset; cursor: pointer; color: inherit; font: inherit; }
++ .openvibe-navbar .openvibe-navbar-brand { gap: 8px; padding: 3px 4px 3px 3px; border-radius: 12px; margin-right: 2px; }
++ .openvibe-navbar .openvibe-navbar-brand a.flame { display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; transition: background .15s; }
++ .openvibe-navbar .openvibe-navbar-brand a.flame:hover { background: var(--accent-glow, rgba(59,130,246,.14)); }
++ .openvibe-navbar .openvibe-navbar-brand .name { align-items: center; gap: 0; font-size: 15.5px; letter-spacing: -.25px; }
++ .openvibe-navbar .openvibe-navbar-brand .name a { padding: 3px 2px; border-radius: 6px; transition: color .15s, background .15s; }
++ .openvibe-navbar .openvibe-navbar-brand .name a.b-sub { color: var(--text-primary, #e6edf7); font-weight: 750; }
++ .openvibe-navbar .openvibe-navbar-brand .name a.b-core { color: var(--text-primary, #e6edf7); font-weight: 700; }
++ .openvibe-navbar .openvibe-navbar-brand .name a.b-tld { color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 700; }
++ .openvibe-navbar .openvibe-navbar-brand.has-sub .name a.b-core, .openvibe-navbar .openvibe-navbar-brand.has-sub .name a.b-tld { color: var(--text-secondary, #a8b3c4); font-weight: 600; }
++ .openvibe-navbar .openvibe-navbar-brand .name a:hover { color: var(--accent-light, var(--accent, #60a5fa)); background: var(--accent-glow, rgba(59,130,246,.12)); }
++ .openvibe-navbar .openvibe-navbar-brand .b-dot { margin: 0; padding: 0 .5px; opacity: .55; }
++ .openvibe-navbar .openvibe-navbar-brand a:focus-visible, .ovnav-launch:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
++ .ovnav-launch { all: unset; box-sizing: border-box; cursor: pointer; color: var(--text-secondary, #a8b3c4); width: 32px; height: 32px; border-radius: 10px; display: inline-grid; place-items: center; flex: none; margin-right: 10px; border: 1px solid transparent; transition: background .15s, color .15s, border-color .15s; }
++ .ovnav-launch:hover, .ovnav-launch[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.14)); color: var(--accent-light, var(--accent, #60a5fa)); border-color: color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent); }
++ .ovnav-launch svg { display: block; transition: transform .25s cubic-bezier(.2,1.4,.3,1); }
++ .ovnav-launch[aria-expanded="true"] svg { transform: rotate(45deg) scale(.92); }
++ .ovnav-launcher a { all: unset; cursor: pointer; box-sizing: border-box; }
++ .openvibe-navbar .ovnav-sep { width: 1px; height: 18px; background: var(--border, rgba(255,255,255,.12)); margin: 0 6px; flex: none; align-self: center; }
++ @media (max-width: 1180px) { .openvibe-navbar .ovnav-net, .openvibe-navbar .ovnav-sep { display: none; } }
.ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; width: min(440px, calc(100vw - 24px)); max-height: min(560px, calc(100vh - 80px)); overflow: auto; background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 16px; box-shadow: 0 24px 60px rgba(0,0,0,.5); padding: 12px; z-index: 1000; opacity: 0; transform: translateY(-6px) scale(.98); transform-origin: top left; pointer-events: none; transition: opacity .16s, transform .2s cubic-bezier(.2,1.2,.3,1); }
.ovnav-launcher.open { opacity: 1; transform: none; pointer-events: auto; }
.ovnav-launcher .ovl-q { width: 100%; box-sizing: border-box; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-primary, #0a0f18); color: var(--text-primary, #e6edf7); font: 500 14px/1.2 inherit; outline: none; }
@@@ -79,22 +96,25 @@
.ovnav-launcher .ovl-site:hover, .ovnav-launcher .ovl-fam:hover, .ovnav-launcher a:focus-visible { background: var(--accent-glow, rgba(59,130,246,.14)); outline: none; }
.ovnav-launcher b { display: block; font-size: 13px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ovnav-launcher small { display: block; font-size: 11px; color: var(--text-muted, #7d8aa0); margin-top: 1px; }
++ .ovnav-launcher .ovl-soon { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 4px 4px; }
++ .ovnav-launcher .ovl-soon a { font-size: 12px; font-weight: 600; padding: 4px 9px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.1)); color: var(--text-secondary, #a8b3c4); }
++ .ovnav-launcher .ovl-soon a:hover { border-color: var(--accent, #3b82f6); color: var(--text-primary, #e6edf7); background: none; }
.ovnav-launcher .ovl-empty { padding: 18px 8px; color: var(--text-secondary, #a8b3c4); font-size: 13px; }
@media (max-width: 480px) { .ovnav-launcher { left: 8px; } .ovnav-launcher .ovl-fams { grid-template-columns: 1fr; } }
@media (prefers-reduced-motion: reduce) { .ovnav-launcher { transition: none; } }
-- .openvibe-navbar-brand .flame { font-size: 18px; text-decoration: none; color: var(--accent, #8b5cf6); display: inline-grid; place-items: center; width: 28px; height: 28px; flex: none; }
++ .openvibe-navbar-brand .flame { font-size: 18px; text-decoration: none; color: var(--accent, #3b82f6); 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 .b-tld { color: var(--accent-light, var(--accent, #60a5fa)); }
.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-dot { color: var(--accent, #3b82f6); 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-tld, .openvibe-navbar-brand:hover .b-sub { color: var(--accent-light, #60a5fa); }
.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; }
++ .openvibe-navbar-brand .b-tag { font-size: 9px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase; color: var(--accent-light, #60a5fa); background: var(--accent-glow, rgba(59,130,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,
@@@ -112,10 +132,10 @@
.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 .label a { color: var(--accent-light, #60a5fa); 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 .icon { width: 18px; text-align: center; color: var(--accent-light, #60a5fa); 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; }
@@@ -127,7 +147,7 @@
transition: all .15s;
}
.openvibe-navbar-links a:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }
-- .openvibe-navbar-links a.active { background: var(--bg-tertiary, #2a2a38); color: var(--accent-light, #a78bfa); }
++ .openvibe-navbar-links a.active { background: var(--bg-tertiary, #2a2a38); color: var(--accent-light, #60a5fa); }
.openvibe-navbar-spacer { flex: 1; }
@@@ -138,14 +158,14 @@
border: 2px solid var(--border, #333340); transition: border-color .2s;
object-fit: cover;
}
-- .openvibe-navbar-avatar:hover { border-color: var(--accent, #8b5cf6); }
++ .openvibe-navbar-avatar:hover { border-color: var(--accent, #3b82f6); }
.openvibe-navbar-login {
padding: 6px 16px; border-radius: 6px; font-size: 13px; font-weight: 600;
-- background: var(--accent, #8b5cf6); color: #fff; border: none; cursor: pointer;
++ background: var(--accent, #3b82f6); color: #fff; border: none; cursor: pointer;
transition: background .15s; text-decoration: none; display: inline-flex; align-items: center;
}
-- .openvibe-navbar-login:hover { background: var(--accent-dark, #6d28d9); }
++ .openvibe-navbar-login:hover { background: var(--accent-dark, #2563eb); }
.openvibe-navbar-dropdown {
position: absolute; top: 48px; right: 8px;
@@@ -166,7 +186,7 @@
.openvibe-navbar-dropdown-header .info { line-height: 1.3; }
.openvibe-navbar-dropdown-header .info .name { font-size: 14px; font-weight: 600; }
.openvibe-navbar-dropdown-header .info .email { font-size: 11px; color: var(--text-muted, #707080); }
-- .openvibe-navbar-dropdown-header .info .anon-tag { font-size: 10px; color: var(--accent-light, #a78bfa); }
++ .openvibe-navbar-dropdown-header .info .anon-tag { font-size: 10px; color: var(--accent-light, #60a5fa); }
.openvibe-navbar-dropdown-accounts {
padding: 6px 8px; border-bottom: 1px solid var(--border, #333340);
@@@ -179,7 +199,7 @@
}
.openvibe-navbar-dropdown-accounts .account-item:hover { background: var(--bg-hover, #2f2f3d); }
.openvibe-navbar-dropdown-accounts .account-item img { width: 24px; height: 24px; border-radius: 50%; }
-- .openvibe-navbar-dropdown-accounts .account-item.active { color: var(--accent-light, #a78bfa); font-weight: 600; }
++ .openvibe-navbar-dropdown-accounts .account-item.active { color: var(--accent-light, #60a5fa); font-weight: 600; }
.openvibe-navbar-dropdown-accounts .add-account {
display: flex; align-items: center; gap: 8px; padding: 6px 8px;
border-radius: 6px; cursor: pointer; font-size: 12px;
@@@ -202,11 +222,11 @@
.openvibe-navbar .openvibe-network-badge {
font-size: 10px; padding: 2px 8px; border-radius: 4px;
-- background: rgba(139,92,246,0.1); color: var(--accent-light, #a78bfa);
++ background: rgba(59,130,246,0.1); color: var(--accent-light, #60a5fa);
font-weight: 500; cursor: pointer; border: 1px solid transparent;
transition: all .15s;
}
-- .openvibe-navbar .openvibe-network-badge:hover { border-color: var(--accent-dark, #6d28d9); }
++ .openvibe-navbar .openvibe-network-badge:hover { border-color: var(--accent-dark, #2563eb); }
@media (max-width: 600px) {
.openvibe-navbar-links { display: none; }
@@@ -529,6 +549,42 @@
${_config.launcher === false ? '' : '<button type="button" class="ovnav-launch" id="openvibe-launcher-btn" aria-label="All OpenVibe sites and tools" aria-haspopup="true" aria-expanded="false" title="All OpenVibe sites and tools"><svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><g fill="currentColor"><circle cx="5" cy="5" r="2"/><circle cx="12" cy="5" r="2"/><circle cx="19" cy="5" r="2"/><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="12" cy="19" r="2"/><circle cx="19" cy="19" r="2"/></g></svg></button>'}`;
}
++
++ // ── Shared chrome data (https://openvibe.network/api/chrome) ─────────────────────────────
++ // Sites ordered by real use, footer copy and per-site legal links. Cached per host for 30
++ // minutes in localStorage and refreshed in the background, so pages paint from cache and the
++ // order never jumps while someone is looking at it. Defined once, shared by navbar + footer.
++ const OVChrome = root.OpenVibeChrome || (root.OpenVibeChrome = (function () {
++ const KEY = 'ov_chrome_v1', TTL = 30 * 60000;
++ let inflight = null;
++ const host = () => (typeof location !== 'undefined' ? location.hostname : '');
++ const https = (u) => { try { return new URL(u).protocol === 'https:'; } catch { return false; } };
++ function clean(d) {
++ if (!d || !Array.isArray(d.nav)) return null;
++ const link = (l) => (l && typeof l.name === 'string' && https(l.url) ? { id: String(l.id || ''), name: l.name.slice(0, 60), url: l.url, icon: String(l.icon || ''), tagline: String(l.tagline || '').slice(0, 80) } : null);
++ const f = d.footer || {}, lg = f.legal || {};
++ return { nav: d.nav.map(link).filter(Boolean).slice(0, 12), soon: (d.soon || []).map(link).filter(Boolean).slice(0, 24),
++ footer: { blurb: String(f.blurb || '').slice(0, 200), discover: (f.discover || []).map(link).filter(Boolean).slice(0, 6), popular: (f.popular || []).map(link).filter(Boolean).slice(0, 10),
++ legal: https(lg.terms) && https(lg.privacy) && https(lg.dmca) ? { terms: lg.terms, privacy: lg.privacy, dmca: lg.dmca } : null } };
++ }
++ function cached() { try { const c = JSON.parse(localStorage.getItem(KEY) || 'null'); return c && c.host === host() && c.data ? c : null; } catch { return null; } }
++ function refresh() {
++ if (inflight || typeof fetch === 'undefined') return inflight || Promise.resolve(null);
++ inflight = fetch('https://openvibe.network/api/chrome?host=' + encodeURIComponent(host()), { credentials: 'omit' })
++ .then(r => (r.ok ? r.json() : null)).then(clean)
++ .then(d => { if (d) { try { localStorage.setItem(KEY, JSON.stringify({ at: Date.now(), host: host(), data: d })); } catch { /* */ } } return d; })
++ .catch(() => null);
++ return inflight;
++ }
++ /** Cached data now (or null); `onFirst` fires once when a first-ever fetch lands. */
++ function get(onFirst) {
++ const c = cached();
++ if (!c || Date.now() - c.at > TTL) { const p = refresh(); if (!c && onFirst) p.then(d => { if (d) onFirst(d); }); }
++ return c ? c.data : null;
++ }
++ return { get, refresh };
++ })());
++
// ── Network launcher ─────────────────────────────────────
const LAUNCHER_SITES = [
{ name: 'Live', desc: 'Streams, clips and chat', icon: 'live', url: 'https://openvibe.live/' },
@@@ -575,8 +631,12 @@
body.innerHTML = hits.length ? `<div class="ovl-h">Tools</div><div class="ovl-fams">${hits.map(t => tile(t, 'ovl-fam')).join('')}</div>` : '<div class="ovl-empty">No tool matches. <a href="https://openvibe.tools/">Browse them all</a></div>';
return;
}
-- body.innerHTML = `<div class="ovl-h">Sites</div><div class="ovl-sites">${LAUNCHER_SITES.map(x => tile(x, 'ovl-site')).join('')}</div>
-- <div class="ovl-h">Tools <a href="https://openvibe.tools/">See all</a></div><div class="ovl-fams">${fams.map(x => tile(x, 'ovl-fam')).join('')}</div>`;
++ const chrome = OVChrome.get();
++ const sites = chrome && chrome.nav.length ? chrome.nav.map(n => ({ name: n.name, desc: n.tagline, icon: n.icon || n.id, url: n.url })) : LAUNCHER_SITES;
++ const soon = chrome ? chrome.soon : [];
++ body.innerHTML = `<div class="ovl-h">Sites</div><div class="ovl-sites">${sites.map(x => tile(x, 'ovl-site')).join('')}</div>
++ <div class="ovl-h">Tools <a href="https://openvibe.tools/">See all</a></div><div class="ovl-fams">${fams.map(x => tile(x, 'ovl-fam')).join('')}</div>
++ ${soon.length ? `<div class="ovl-h">Opening soon</div><div class="ovl-soon">${soon.map(x => `<a href="${escapeAttr(x.url)}">${escapeAttr(x.name)}</a>`).join('')}</div>` : ''}`;
}
btn.addEventListener('click', async (e) => {
e.stopPropagation();
@@@ -605,6 +665,32 @@
});
}
++ /** One anonymous page-view count per page load (host only), which is what orders the network's
++ * navigation. Skipped when the browser asks not to be tracked. */
++ let _counted = false;
++ function countView() {
++ if (_counted || _config.countViews === false) return; _counted = true;
++ try {
++ if (navigator.globalPrivacyControl || navigator.doNotTrack === '1') return;
++ if (!/^https:$/.test(location.protocol)) return;
++ const url = 'https://openvibe.network/api/chrome/hit';
++ if (navigator.sendBeacon) navigator.sendBeacon(url);
++ } catch { /* */ }
++ }
++
++ /** The network's most used sites, after the page's own links (networkLinks: false turns it off). */
++ function networkLinksHTML(pageLinks) {
++ if (_config.networkLinks === false) return '';
++ const chrome = OVChrome.get((d) => { if (d && _navEl && !_navEl.querySelector('.ovnav-net')) { try { render(); } catch { /* */ } } });
++ if (!chrome) return '';
++ const here = currentHost().toLowerCase();
++ const taken = new Set((pageLinks || []).map(l => { try { return new URL(l.href, location.href).hostname; } catch { return ''; } }));
++ const max = typeof _config.networkLinks === 'number' ? _config.networkLinks : 4;
++ const pick = chrome.nav.filter(n => { try { const h = new URL(n.url).hostname; return h !== here && !here.endsWith('.' + h) && !taken.has(h); } catch { return false; } }).slice(0, max);
++ if (!pick.length) return '';
++ return `<span class="ovnav-sep" aria-hidden="true"></span>` + pick.map(n => `<a class="ovnav-net" href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}">${escapeAttr(n.name)}</a>`).join('');
++ }
++
// ── Notification bell (mounted by the navbar itself) ─────
function mountBell(nav, u) {
if (_config.notifications === false || !u || u.is_anon || !_config.token) return;
@@@ -775,7 +861,7 @@
/** Where "Sign In" goes on this host. */
function resolveLoginHref(host, returnUrl) {
-- if (_config.loginUrl) return _config.loginUrl;
++ if (_config.loginUrl) return String(_config.loginUrl).replace('{url}', encodeURIComponent(returnUrl || '/'));
if (onToolsDomain(host)) {
// Every *.openvibe.tools host signs in through the gateway apex: it
// holds the OAuth state cookie + redirect_uri (both apex-host-only)
@@@ -1045,7 +1131,7 @@
function makeAvatarPlaceholder(user, size = 64) {
const initial = getAvatarInitial(user);
-- const bg = user?.profile_color || '#8b5cf6';
++ const bg = user?.profile_color || '#3b82f6';
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
<rect width="100%" height="100%" rx="${Math.round(size / 2)}" fill="${bg}"/>
@@@ -1122,17 +1208,18 @@
${brandHTML(brand)}
<div class="openvibe-navbar-links">
${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('')}
++ ${networkLinksHTML(links)}
${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"><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>`}
</div>
`;
bindLauncher(nav);
++ countView();
// Pages that wire the bell themselves run right after init(); give them the first go.
setTimeout(() => mountBell(nav, u), 0);
ddiff --git a/vendor/openvibe-shared/notification-ui.js b/vendor/openvibe-shared/notification-ui.js
iindex 5ce5f219..0b76b0fb 100644
---- a/vendor/openvibe-shared/notification-ui.js
++++ b/vendor/openvibe-shared/notification-ui.js
@@@ -55,26 +55,26 @@
.openvibe-toast-container { position: fixed; top: 82px; right: 14px; z-index: 100000; display: flex; flex-direction: column; gap: 6px; pointer-events: none; max-width: 340px; width: 100%; }
.openvibe-toast { pointer-events: all; background: color-mix(in srgb, var(--bg-card, #22222c) 94%, transparent); border: 1px solid color-mix(in srgb, var(--border, #333340) 82%, transparent); border-radius: 10px; padding: 11px 12px; display: flex; gap: 10px; align-items: flex-start; box-shadow: 0 10px 24px rgba(0,0,0,0.28); animation: openvibe-toast-in .24s cubic-bezier(.34,1.56,.64,1); cursor: pointer; position: relative; overflow: hidden; font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; color: var(--text-primary, #e0e0e0); transition: border-color .2s; }
-- .openvibe-toast:hover { border-color: var(--accent, #8b5cf6); }
++ .openvibe-toast:hover { border-color: var(--accent, #3b82f6); }
.openvibe-toast.removing { animation: openvibe-toast-out .3s ease forwards; }
.openvibe-toast-icon { font-size: 18px; flex-shrink: 0; line-height: 1; margin-top: 1px; }
.openvibe-toast-icon img { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; display: block; }
.openvibe-toast-body { flex: 1; min-width: 0; }
.openvibe-toast-title { font-weight: 600; font-size: 12px; margin-bottom: 2px; display: flex; align-items: center; gap: 6px; }
-- .openvibe-toast-title .service-badge { font-size: 9px; padding: 1px 6px; border-radius: 3px; background: color-mix(in srgb, var(--accent, #8b5cf6) 18%, transparent); color: var(--accent-light, #a78bfa); font-weight: 500; }
++ .openvibe-toast-title .service-badge { font-size: 9px; padding: 1px 6px; border-radius: 3px; background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); color: var(--accent-light, #60a5fa); font-weight: 500; }
.openvibe-toast-msg { font-size: 11px; color: var(--text-secondary, #b0b0b8); line-height: 1.35; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.openvibe-toast-close { position: absolute; top: 8px; right: 10px; background: none; border: none; color: var(--text-muted, #707080); cursor: pointer; font-size: 14px; padding: 2px; line-height: 1; }
.openvibe-toast-close:hover { color: var(--text-primary, #e0e0e0); }
-- .openvibe-toast-progress { position: absolute; bottom: 0; left: 0; height: 2px; background: var(--accent, #8b5cf6); }
++ .openvibe-toast-progress { position: absolute; bottom: 0; left: 0; height: 2px; background: var(--accent, #3b82f6); }
.openvibe-toast.priority-high { border-left: 3px solid var(--warning, #f39c12); }
.openvibe-toast.priority-critical { border-left: 3px solid var(--live-red, #e74c3c); background: rgba(231,76,60,0.06); }
.openvibe-toast-actions { display: flex; gap: 6px; margin-top: 8px; }
.openvibe-toast-actions button { padding: 5px 12px; border-radius: 5px; border: 1px solid var(--border, #333340); background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); font-size: 11px; font-weight: 600; cursor: pointer; }
-- .openvibe-toast-actions button.primary { background: var(--accent, #8b5cf6); color: #fff; border-color: var(--accent, #8b5cf6); }
++ .openvibe-toast-actions button.primary { background: var(--accent, #3b82f6); color: #fff; border-color: var(--accent, #3b82f6); }
.openvibe-bell { position: relative; cursor: pointer; padding: 6px; display: inline-flex; align-items: center; justify-content: center; }
.openvibe-bell svg { width: 22px; height: 22px; fill: var(--text-secondary, #b0b0b8); transition: fill .2s; }
-- .openvibe-bell:hover svg, .openvibe-bell.open svg { fill: var(--accent-light, #a78bfa); }
++ .openvibe-bell:hover svg, .openvibe-bell.open svg { fill: var(--accent-light, #60a5fa); }
.openvibe-bell .badge { position: absolute; top: 0; right: 0; min-width: 16px; height: 16px; padding: 0 4px; background: var(--live-red, #e74c3c); color: #fff; border-radius: 8px; font-size: 10px; font-weight: 700; display: flex; align-items: center; justify-content: center; line-height: 1; pointer-events: none; animation: openvibe-badge-pulse .4s ease; }
.openvibe-bell .badge.hidden { display: none; }
.openvibe-bell.ringing svg { animation: openvibe-bell-ring .6s ease; }
@@@ -90,27 +90,27 @@
.openvibe-nv-actions { display: flex; gap: 4px; align-items: center; }
.openvibe-nv-actions button, .openvibe-nv-actions a { background: none; border: 1px solid transparent; color: var(--text-muted, #707080); cursor: pointer; font-size: 12px; padding: 5px 8px; border-radius: 6px; text-decoration: none; display: inline-flex; align-items: center; gap: 5px; line-height: 1; }
.openvibe-nv-actions button:hover, .openvibe-nv-actions a:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }
-- .openvibe-nv-actions button.on { color: var(--accent-light, #a78bfa); border-color: color-mix(in srgb, var(--accent, #8b5cf6) 40%, transparent); }
++ .openvibe-nv-actions button.on { color: var(--accent-light, #60a5fa); border-color: color-mix(in srgb, var(--accent, #3b82f6) 40%, transparent); }
.openvibe-nv-actions button:disabled { opacity: .45; cursor: default; }
.openvibe-nv-actions svg { width: 14px; height: 14px; fill: currentColor; }
.openvibe-nv-tools { padding: 8px 12px; border-bottom: 1px solid var(--border, #333340); display: flex; flex-direction: column; gap: 8px; }
.openvibe-nv-search { position: relative; }
.openvibe-nv-search input { width: 100%; box-sizing: border-box; padding: 7px 28px 7px 30px; border-radius: 8px; border: 1px solid var(--border, #333340); background: var(--bg-input, var(--bg-tertiary, #1e1e28)); color: var(--text-primary, #e0e0e0); font-size: 12px; outline: none; }
-- .openvibe-nv-search input:focus { border-color: var(--accent, #8b5cf6); }
++ .openvibe-nv-search input:focus { border-color: var(--accent, #3b82f6); }
.openvibe-nv-search svg { position: absolute; left: 9px; top: 50%; transform: translateY(-50%); width: 14px; height: 14px; fill: var(--text-muted, #707080); pointer-events: none; }
.openvibe-nv-search .clear { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); background: none; border: none; color: var(--text-muted, #707080); cursor: pointer; font-size: 14px; display: none; }
.openvibe-nv-search.has-q .clear { display: block; }
.openvibe-nv-chips { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
.openvibe-nv-chips button { border: 1px solid var(--border, #333340); background: transparent; color: var(--text-secondary, #b0b0b8); font-size: 11px; padding: 4px 10px; border-radius: 999px; cursor: pointer; line-height: 1.2; }
-- .openvibe-nv-chips button:hover { border-color: var(--accent, #8b5cf6); color: var(--text-primary, #e0e0e0); }
-- .openvibe-nv-chips button.active { background: color-mix(in srgb, var(--accent, #8b5cf6) 18%, transparent); border-color: var(--accent, #8b5cf6); color: var(--accent-light, #a78bfa); }
++ .openvibe-nv-chips button:hover { border-color: var(--accent, #3b82f6); color: var(--text-primary, #e0e0e0); }
++ .openvibe-nv-chips button.active { background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); border-color: var(--accent, #3b82f6); color: var(--accent-light, #60a5fa); }
.openvibe-nv-chips select { margin-left: auto; border: 1px solid var(--border, #333340); background: var(--bg-input, var(--bg-tertiary, #1e1e28)); color: var(--text-secondary, #b0b0b8); font-size: 11px; padding: 4px 8px; border-radius: 8px; cursor: pointer; }
.openvibe-nv-list { flex: 1; overflow-y: auto; padding: 4px 0; overscroll-behavior: contain; }
.openvibe-notif-item { padding: 10px 12px 10px 14px; display: flex; gap: 10px; align-items: flex-start; cursor: pointer; transition: background .15s; border-left: 3px solid transparent; position: relative; }
.openvibe-notif-item:hover { background: var(--bg-hover, #2f2f3d); }
-- .openvibe-notif-item.unread { background: color-mix(in srgb, var(--accent, #8b5cf6) 7%, transparent); border-left-color: var(--accent, #8b5cf6); }
++ .openvibe-notif-item.unread { background: color-mix(in srgb, var(--accent, #3b82f6) 7%, transparent); border-left-color: var(--accent, #3b82f6); }
.openvibe-notif-item .icon { font-size: 20px; flex-shrink: 0; margin-top: 2px; width: 32px; text-align: center; }
.openvibe-notif-item .icon img { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; display: block; }
.openvibe-notif-item .content { flex: 1; min-width: 0; }
@@@ -126,9 +126,9 @@
.openvibe-nv-empty .icon { font-size: 34px; margin-bottom: 8px; display: block; opacity: .5; }
.openvibe-nv-more { display: flex; justify-content: center; padding: 10px; }
.openvibe-nv-more button { border: 1px solid var(--border, #333340); background: transparent; color: var(--text-secondary, #b0b0b8); font-size: 12px; padding: 6px 14px; border-radius: 8px; cursor: pointer; }
-- .openvibe-nv-more .spinner { width: 16px; height: 16px; border: 2px solid var(--border, #333340); border-top-color: var(--accent, #8b5cf6); border-radius: 50%; animation: openvibe-spin .8s linear infinite; }
++ .openvibe-nv-more .spinner { width: 16px; height: 16px; border: 2px solid var(--border, #333340); border-top-color: var(--accent, #3b82f6); border-radius: 50%; animation: openvibe-spin .8s linear infinite; }
.openvibe-nv-foot { padding: 8px 12px; border-top: 1px solid var(--border, #333340); font-size: 11px; color: var(--text-muted, #707080); display: flex; justify-content: space-between; align-items: center; gap: 8px; }
-- .openvibe-nv-foot a { color: var(--accent-light, #a78bfa); text-decoration: none; }
++ .openvibe-nv-foot a { color: var(--accent-light, #60a5fa); text-decoration: none; }
.openvibe-nv-note { font-size: 11px; color: var(--text-muted, #707080); padding: 6px 12px; background: color-mix(in srgb, var(--warning, #f39c12) 10%, transparent); border-bottom: 1px solid var(--border, #333340); }
@media (max-width: 460px) {
ddiff --git a/vendor/openvibe-shared/ov-icons.js b/vendor/openvibe-shared/ov-icons.js
iindex d4208ad7..5010b496 100644
---- a/vendor/openvibe-shared/ov-icons.js
++++ b/vendor/openvibe-shared/ov-icons.js
@@@ -87,6 +87,8 @@
.ov-icon .ovi-prog{fill:none;stroke:currentColor;stroke-width:3;stroke-linecap:round;transform:rotate(-90deg);transform-origin:24px 24px;stroke-dasharray:125.7;stroke-dashoffset:125.7;transition:stroke-dashoffset .35s cubic-bezier(.2,.8,.2,1),opacity .2s;opacity:0}
.ov-icon .ovi-glyph{fill:none;stroke:var(--ovi-glyph,var(--text-primary,#e6edf7));stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;transform-origin:24px 24px;transition:transform .25s cubic-bezier(.2,1.4,.3,1),stroke .2s}
.ov-icon .ovi-glyph circle[r=".6"],.ov-icon .ovi-glyph circle[r=".7"]{fill:currentColor;stroke:none}
++.ov-icon[style*="--ovi-size:2"] .ovi-glyph,.ov-icon[data-size^="2"] .ovi-glyph{stroke-width:2.3}
++.ov-icon[style*="--ovi-size:2"] .ovi-ring,.ov-icon[data-size^="2"] .ovi-ring{stroke-width:3;opacity:.34}
.ov-icon:hover .ovi-glyph,a:hover>.ov-icon .ovi-glyph,button:hover>.ov-icon .ovi-glyph{transform:scale(1.1)}
.ov-icon:hover .ovi-bg,a:hover>.ov-icon .ovi-bg{opacity:.18}
.ov-icon[data-fx=none] .ovi-comet{display:none}
ddiff --git a/vendor/openvibe-shared/package.json b/vendor/openvibe-shared/package.json
iindex b63c7c24..c6c8b794 100644
---- a/vendor/openvibe-shared/package.json
++++ b/vendor/openvibe-shared/package.json
@@@ -13,7 +13,9 @@
"./notifications": "./notifications.js",
"./url-resolver": "./url-resolver.js",
"./seo": "./seo.js",
-- "./icons": "./ov-icons.js"
++ "./icons": "./ov-icons.js",
++ "./legal": "./legal.js",
++ "./app-icon": "./app-icon.js"
},
"license": "MIT",
"dependencies": {
ddiff --git a/vendor/openvibe-shared/scripts/build-app-icons.js b/vendor/openvibe-shared/scripts/build-app-icons.js
nnew file mode 100644
iindex 00000000..fcb49024
---- /dev/null
++++ b/vendor/openvibe-shared/scripts/build-app-icons.js
@@@ -0,0 +1,18 @@
++#!/usr/bin/env node
++'use strict';
++// Render a site's icon set from openvibe-shared/app-icon.
++// node build-app-icons.js <site> <outDir> [--sharp <path-to-sharp-module>]
++// Writes logo.svg (favicon), logo-72/192/512.png (rounded tile) and logo-maskable-192/512.png.
++const fs = require('fs'); const path = require('path');
++const icon = require('../app-icon');
++const [site, outDir] = process.argv.slice(2);
++if (!site || !outDir) { console.error('usage: build-app-icons.js <site> <outDir> [--sharp <module path>]'); process.exit(1); }
++const i = process.argv.indexOf('--sharp');
++const sharp = require(i > 0 ? path.resolve(process.argv[i + 1]) : 'sharp');
++(async () => {
++ fs.mkdirSync(outDir, { recursive: true });
++ fs.writeFileSync(path.join(outDir, 'logo.svg'), icon.favicon({ site }));
++ for (const s of [72, 192, 512]) await sharp(Buffer.from(icon.svg({ site, maskable: false })), { density: 300 }).resize(s, s).png({ compressionLevel: 9 }).toFile(path.join(outDir, `logo-${s}.png`));
++ for (const s of [192, 512]) await sharp(Buffer.from(icon.svg({ site, maskable: true })), { density: 300 }).resize(s, s).png({ compressionLevel: 9 }).toFile(path.join(outDir, `logo-maskable-${s}.png`));
++ console.log(`icons for ${site} → ${outDir}`);
++})().catch(e => { console.error(e); process.exit(1); });
ddiff --git a/vendor/openvibe-shared/tooltip.js b/vendor/openvibe-shared/tooltip.js
iindex 21fa61ab..0344c8e9 100644
---- a/vendor/openvibe-shared/tooltip.js
++++ b/vendor/openvibe-shared/tooltip.js
@@@ -24,33 +24,33 @@
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);
++ border:1px solid color-mix(in srgb, var(--ovtip-accent,var(--accent,#60a5fa)) 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,#60a5fa)) 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%)}
++ background:radial-gradient(120% 90% at 0% 0%, color-mix(in srgb, var(--ovtip-accent,var(--accent,#60a5fa)) 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)}
++ background:linear-gradient(100deg,transparent,color-mix(in srgb, var(--ovtip-accent,var(--accent,#60a5fa)) 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)}
++ background:linear-gradient(135deg,var(--ovtip-accent,var(--accent,#60a5fa)),color-mix(in srgb,var(--ovtip-accent,var(--accent,#60a5fa)) 55%,#000));
++ box-shadow:0 4px 12px color-mix(in srgb, var(--ovtip-accent,var(--accent,#60a5fa)) 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 em{color:var(--ovtip-accent,var(--accent,#60a5fa));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)))}
++ background:var(--bg-card,#171826);border:1px solid color-mix(in srgb, var(--ovtip-accent,var(--accent,#60a5fa)) 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}
@@@ -59,8 +59,8 @@
padding:9px 12px;border-radius:10px;font:inherit;font-size:.8rem;font-weight:750;cursor:pointer;
transition:transform .15s,filter .15s}
.ovtip-go{color:#fff;border:0;
-- background:linear-gradient(135deg,var(--ovtip-accent,var(--accent,#a78bfa)),color-mix(in srgb,var(--ovtip-accent,var(--accent,#a78bfa)) 60%,#000));
-- box-shadow:0 6px 16px color-mix(in srgb,var(--ovtip-accent,var(--accent,#a78bfa)) 40%,transparent)}
++ background:linear-gradient(135deg,var(--ovtip-accent,var(--accent,#60a5fa)),color-mix(in srgb,var(--ovtip-accent,var(--accent,#60a5fa)) 60%,#000));
++ box-shadow:0 6px 16px color-mix(in srgb,var(--ovtip-accent,var(--accent,#60a5fa)) 40%,transparent)}
.ovtip-dismiss{flex:0 0 auto;color:var(--text-muted,#8b93ad);background:none;border:1px solid var(--border,rgba(255,255,255,.12))}
.ovtip-go:active,.ovtip-dismiss:active{transform:scale(.975)}
.ovtip-go i{font-size:.72rem}
ddiff --git a/vendor/openvibe-shared/user-card.js b/vendor/openvibe-shared/user-card.js
iindex c0d258eb..c271648e 100644
---- a/vendor/openvibe-shared/user-card.js
++++ b/vendor/openvibe-shared/user-card.js
@@@ -30,7 +30,7 @@
animation: openvibe-card-in .2s ease; overflow: hidden;
}
.openvibe-usercard-banner {
-- height: 60px; background: linear-gradient(135deg, var(--accent-dark, #a07840), var(--accent, #8b5cf6));
++ height: 60px; background: linear-gradient(135deg, var(--accent-dark, #a07840), var(--accent, #3b82f6));
position: relative;
}
.openvibe-usercard-avatar {