openvibers/openvibe.media Commits

vendor: sync openvibe-shared (icons, island, ui, seo, navbar launcher)

OP OpenVibers committed 18 Sept 2026 · 2e00149223 · 14 files, +2027 −36


Changes

ddiff --git a/vendor/openvibe-shared/account-switcher.js b/vendor/openvibe-shared/account-switcher.js
iindex cc16ff3..04e9dbc 100644
---- a/vendor/openvibe-shared/account-switcher.js
++++ b/vendor/openvibe-shared/account-switcher.js
@@@ -308,7 +308,14 @@
}
}
++ /** Drop the server-held cookies (ov_sso is httpOnly, so only the server can). Best effort. */
++ function serverLogout() {
++ try { fetch(`${_config.apiBase}/api/auth/logout`, { method: 'POST', credentials: 'include', keepalive: true }).catch(() => {}); } catch { /* */ }
++ try { localStorage.setItem('ov_sso_hint', 'guest'); } catch { /* */ }
++ }
++
function logout() {
++ serverLogout();
const activeId = getActiveId();
if (activeId) removeAccount(activeId);
localStorage.removeItem(TOKEN_KEY);
@@@ -319,6 +326,7 @@
}
function logoutAll() {
++ serverLogout();
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(ACTIVE_KEY);
ddiff --git a/vendor/openvibe-shared/brand.js b/vendor/openvibe-shared/brand.js
iindex 61ab011..bfa2a1a 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
nnew file mode 100644
iindex 0000000..b86da57
---- /dev/null
++++ b/vendor/openvibe-shared/footer.js
@@@ -0,0 +1,314 @@
++/**
++ * openvibe-shared/footer.js — the cross-site footer, the counterpart to navbar.js.
++ *
++ * OpenVibeFooter.init({ service, variant, links, apiBase, mount })
++ * OpenVibeFooter.setVariant('full' | 'compact') switch at runtime (SPA route changes)
++ * OpenVibeFooter.buildHTML(cfg) markup only — usable from Node for SSR
++ *
++ * Why this exists: every property was hand-rolling its own footer, so the network links, the
++ * legal links and the styling drifted apart, and a site that gained a new sibling had to be
++ * edited by hand. Here the site passes only the links that are *its own*; the Network column,
++ * the Legal column, the signed-in row and all the styling come from this module, so adding a
++ * property or changing a policy link updates every site at once.
++ *
++ * Variants:
++ * full the multi-column footer, for home and browse pages
++ * compact one quiet line, for pages where the footer is not the point — a streamer's
++ * channel, a player, a tool someone is mid-task in
++ *
++ * Signed-in visitors get a cross-site row: their account, their channel, themes and the tools
++ * hub, so the network behaves like one product instead of a ring of separate sites. It reads
++ * the shared ov_token the same way navbar.js does and never blocks rendering on it.
++ */
++(function (root, factory) {
++ const api = factory();
++ if (typeof module === 'object' && module.exports) module.exports = api; // Node (SSR)
++ if (typeof window !== 'undefined') window.OpenVibeFooter = api; // browser
++})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
++ 'use strict';
++
++ const NETWORK_URL = 'https://openvibe.network';
++ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++
++ // One place that knows what the network contains. Adding a property here gives every site
++ // the link, with no edit anywhere else.
++ const NETWORK = [
++ { id: 'live', name: 'Live streaming', url: 'https://openvibe.live', desc: 'Go live, restream and clip' },
++ { id: 'tools', name: 'Online tools', url: 'https://openvibe.tools', desc: 'The whole toolbox' },
++ { id: 'dev', name: 'Developer tools', url: 'https://dev.openvibe.tools', desc: 'Formatters, encoders, validators' },
++ { id: 'net', name: 'Network diagnostics', url: 'https://net.openvibe.tools', desc: 'Ping, DNS, Whois, SSL' },
++ { id: 'img', name: 'Image converter', url: 'https://img.openvibe.tools', desc: 'Convert, resize, compress' },
++ { id: 'audio', name: 'Audio converter', url: 'https://audio.openvibe.tools', desc: 'Convert, trim, pitch-shift' },
++ { id: 'docs', name: 'PDF & document tools', url: 'https://docs.openvibe.tools', desc: 'Merge, split, compress' },
++ { id: 'text', name: 'Text tools', url: 'https://text.openvibe.tools', desc: 'Fancy text, case, ASCII' },
++ { id: 'yt', name: 'YouTube downloader', url: 'https://yt.openvibe.tools', desc: 'Video and audio' },
++ { id: '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 = [
++ { name: 'Terms of Service', path: '/tos', fallback: 'https://openvibe.live/tos' },
++ { name: 'Privacy Policy', path: '/privacy', fallback: 'https://openvibe.live/privacy' },
++ { name: 'DMCA', path: '/dmca', fallback: 'https://openvibe.live/dmca' },
++ ];
++
++ const SOCIAL = [
++ { name: 'GitHub', icon: 'fa-brands fa-github', url: 'https://github.com/OpenVibers' },
++ { name: 'Discord', icon: 'fa-brands fa-discord', url: 'https://discord.gg/M6MuRUaeJj' },
++ ];
++
++ const DEFAULTS = {
++ service: null, // 'live' | 'tools' | 'dev' | … (auto-detected when omitted)
++ brandName: null,
++ tagline: 'Open source and community run. Built in the open by the people using it.',
++ variant: 'full',
++ mount: '#ov-footer',
++ links: [], // [{ heading, items: [{ name, url, onclick? }] }]
++ legalBase: null, // origin that hosts /tos, /privacy, /dmca for this site
++ apiBase: NETWORK_URL,
++ user: null,
++ showNetwork: true,
++ showAccount: true,
++ sitemap: '/sitemap.xml',
++ };
++
++ let _cfg = { ...DEFAULTS };
++ let _mounted = null;
++
++ function detectService() {
++ if (typeof location === 'undefined') return 'network';
++ const h = location.hostname;
++ if (h.endsWith('openvibe.live')) return 'live';
++ if (h.endsWith('openvibe.network')) return 'network';
++ if (h.endsWith('openvibe.games')) return 'games';
++ const sub = h.replace(/\.openvibe\.tools$/, '');
++ return sub === h ? 'tools' : (sub || 'tools');
++ }
++
++ 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) {
++ // The navbar already resolved the spelling (MergePDF, JSON, Pastes…): reuse it so the two
++ // never disagree on the same page.
++ try { const nb = typeof window !== 'undefined' && window.OpenVibeNavbar && window.OpenVibeNavbar.brand && window.OpenVibeNavbar.brand(); if (nb && nb.name) return nb.name; } catch { /* */ }
++ 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 (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';
++ }
++
++ function legalHref(item, cfg) {
++ // Policies live on whichever site owns them; a tool subdomain links out rather than 404ing.
++ if (cfg.legalBase) return cfg.legalBase.replace(/\/$/, '') + item.path;
++ if (typeof location !== 'undefined' && location.hostname.endsWith('openvibe.live')) return item.path;
++ return item.fallback;
++ }
++
++ // ── Markup ─────────────────────────────────────────────────
++ function linkTag(item) {
++ const rel = /^https?:\/\//.test(item.url) ? ' target="_blank" rel="noopener"' : '';
++ const onclick = item.onclick ? ` onclick="${esc(item.onclick)}"` : '';
++ const title = item.desc ? ` title="${esc(item.desc)}"` : '';
++ return `<a href="${esc(item.url)}"${rel}${onclick}${title}>${esc(item.name)}</a>`;
++ }
++
++ function buildHTML(cfg) {
++ const c = { ...DEFAULTS, ...(cfg || {}) };
++ const service = c.service || detectService();
++ const brand = c.brandName || brandFor(service);
++ const network = NETWORK.filter(n => n.id !== service);
++ const year = new Date().getFullYear();
++
++ if (c.variant === 'compact') {
++ return `<div class="ovf-inner ovf-inner--compact">
++ <a class="ovf-brand ovf-brand--sm" href="/"><span class="ov-mark" data-size="20"></span><span>${esc(brand)}</span></a>
++ <nav class="ovf-compact-links" aria-label="Site links">
++ ${(c.links[0] ? c.links[0].items.slice(0, 3) : []).map(linkTag).join('')}
++ <a href="${NETWORK_URL}" target="_blank" rel="noopener">Network</a>
++ ${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name.replace(' of Service', '').replace(' Policy', ''))}</a>`).join('')}
++ </nav>
++ <span class="ovf-compact-meta">Open source · ${year}</span>
++ </div>`;
++ }
++
++ const cols = (c.links || []).map(group => `
++ <nav class="ovf-col" aria-label="${esc(group.heading)}">
++ <h3>${esc(group.heading)}</h3>
++ ${group.items.map(linkTag).join('')}
++ </nav>`).join('');
++
++ return `<div class="ovf-inner">
++ <div class="ovf-cols">
++ <div class="ovf-col ovf-col--brand">
++ <a class="ovf-brand" href="/"><span class="ov-mark" data-size="26"></span><span>${esc(brand)}</span></a>
++ <p class="ovf-tagline">${esc(c.tagline)}</p>
++ <div class="ovf-social">
++ ${SOCIAL.map(s => `<a href="${s.url}" target="_blank" rel="noopener" aria-label="${s.name}" title="${s.name}"><i class="${s.icon}"></i></a>`).join('')}
++ <a href="${esc(c.sitemap)}" aria-label="Sitemap" title="Sitemap"><i class="fa-solid fa-sitemap"></i></a>
++ </div>
++ </div>
++ ${cols}
++ ${c.showNetwork ? `<nav class="ovf-col" aria-label="The OpenVibe network">
++ <h3>Network</h3>
++ ${network.slice(0, 6).map(linkTag).join('')}
++ <a class="ovf-more" href="${NETWORK_URL}" target="_blank" rel="noopener">Everything else <i class="fa-solid fa-arrow-right"></i></a>
++ </nav>` : ''}
++ <nav class="ovf-col" aria-label="Legal">
++ <h3>Legal</h3>
++ ${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name)}</a>`).join('')}
++ </nav>
++ </div>
++ <div class="ovf-account" id="ovf-account" hidden></div>
++ <div class="ovf-bar">
++ <span class="ovf-copy">Open source &amp; community driven · built in the open</span>
++ <span class="ovf-legal-inline">
++ ${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name.replace(' of Service', '').replace(' Policy', ''))}</a>`).join('')}
++ </span>
++ </div>
++ </div>`;
++ }
++
++ // ── Signed-in row: the same account, wherever you are ──────
++ function accountHTML(user, service) {
++ const name = esc(user.display_name || user.username || 'you');
++ const avatar = user.avatar_url
++ ? `<img src="${esc(user.avatar_url)}" alt="">`
++ : `<span>${esc(String(name).charAt(0).toUpperCase())}</span>`;
++ const quick = [
++ service !== 'live' ? { name: 'My channel', url: `https://openvibe.live/@${esc(user.username || '')}`, icon: 'fa-tower-broadcast' } : { name: 'My dashboard', url: '/dashboard', icon: 'fa-gauge-high' },
++ { name: 'Account', url: `${NETWORK_URL}/my`, icon: 'fa-user' },
++ { name: 'Themes', url: `${NETWORK_URL}/themes`, icon: 'fa-palette' },
++ { name: 'Tools', url: 'https://openvibe.tools', icon: 'fa-screwdriver-wrench' },
++ ];
++ return `<span class="ovf-account-who"><span class="ovf-avatar">${avatar}</span> Signed in as <strong>${name}</strong> across OpenVibe</span>
++ <span class="ovf-account-links">${quick.map(q => `<a href="${esc(q.url)}"${/^https?:/.test(q.url) ? ' target="_blank" rel="noopener"' : ''}><i class="fa-solid ${q.icon}"></i> ${esc(q.name)}</a>`).join('')}</span>`;
++ }
++
++ function storedToken() {
++ try {
++ const m = document.cookie.match(/(?:^|; )ov_token=([^;]*)/);
++ return (m && decodeURIComponent(m[1])) || localStorage.getItem('ov_token') || localStorage.getItem('token') || null;
++ } catch { return null; }
++ }
++
++ async function fillAccount(cfg) {
++ const host = document.getElementById('ovf-account');
++ if (!host || !cfg.showAccount || cfg.variant === 'compact') return;
++ let user = cfg.user;
++ if (!user) {
++ const token = storedToken();
++ if (!token) return;
++ try {
++ const res = await fetch(`${cfg.apiBase}/api/auth/me`, { headers: { Authorization: 'Bearer ' + token }, credentials: 'include' });
++ if (!res.ok) return;
++ const data = await res.json();
++ user = data.user || data;
++ } catch { return; }
++ }
++ if (!user || !user.username) return;
++ host.innerHTML = accountHTML(user, cfg.service || detectService());
++ host.hidden = false;
++ }
++
++ // ── Styles (injected once) ─────────────────────────────────
++ const CSS = `
++.ovf{background:var(--bg-secondary,#12131c);border-top:1px solid var(--border,rgba(255,255,255,.08));margin-top:40px;
++ padding:clamp(24px,4vw,40px) clamp(14px,4vw,32px) calc(clamp(20px,3vw,28px) + env(safe-area-inset-bottom,0px));}
++.ovf[data-variant="compact"]{margin-top:24px;padding:14px clamp(12px,3vw,24px) calc(14px + env(safe-area-inset-bottom,0px));}
++.ovf-inner{max-width:1240px;margin:0 auto}
++.ovf-inner--compact{display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:8px 18px;text-align:center}
++.ovf-cols{display:grid;gap:clamp(20px,3vw,36px);grid-template-columns:minmax(230px,1.6fr) repeat(auto-fit,minmax(120px,1fr));align-items:start}
++.ovf-col{display:flex;flex-direction:column;gap:8px;min-width:0}
++.ovf-col h3{margin:0 0 2px;font-size:.72rem;font-weight:800;letter-spacing:.1em;text-transform:uppercase;color:var(--text-muted,#8b93ad)}
++.ovf-col a{color:var(--text-secondary,#b6bdd2);font-size:.86rem;text-decoration:none;line-height:1.4;width:fit-content;transition:color .15s,transform .15s}
++.ovf-col a:hover{color:var(--accent,#a78bfa);transform:translateX(2px)}
++.ovf-more{font-weight:600;opacity:.85}.ovf-more i{font-size:.7rem;transition:transform .15s}.ovf-more:hover i{transform:translateX(3px)}
++.ovf-brand{display:inline-flex;align-items:center;gap:9px;color:var(--text-primary,#f1f4fb);font-size:1.05rem;font-weight:700;text-decoration:none}
++.ovf-brand--sm{font-size:.9rem}
++.ovf-tagline{margin:0;font-size:.82rem;line-height:1.55;color:var(--text-muted,#8b93ad);max-width:46ch}
++.ovf-social{display:flex;gap:8px;margin-top:4px}
++.ovf-social a{width:36px;height:36px;border-radius:10px;display:grid;place-items:center;color:var(--text-secondary,#b6bdd2);
++ background:var(--bg-tertiary,#1a1b28);border:1px solid var(--border,rgba(255,255,255,.08));font-size:.95rem;transition:color .15s,border-color .15s,transform .15s}
++.ovf-social a:hover{color:var(--accent,#a78bfa);border-color:var(--accent,#a78bfa);transform:translateY(-2px)}
++.ovf-account{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px 18px;
++ margin-top:clamp(18px,2.5vw,26px);padding:10px 14px;border-radius:14px;
++ background:color-mix(in srgb, var(--accent,#a78bfa) 8%, var(--bg-tertiary,#1a1b28));
++ border:1px solid color-mix(in srgb, var(--accent,#a78bfa) 26%, var(--border,rgba(255,255,255,.08)));
++ animation:ovfIn .4s ease-out both}
++@keyframes ovfIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
++.ovf-account-who{display:inline-flex;align-items:center;gap:8px;font-size:.84rem;color:var(--text-secondary,#b6bdd2)}
++.ovf-account-who strong{color:var(--text-primary,#f1f4fb)}
++.ovf-avatar{width:24px;height:24px;border-radius:50%;overflow:hidden;display:grid;place-items:center;
++ background:var(--accent,#a78bfa);color:#fff;font-size:.7rem;font-weight:800;flex:none}
++.ovf-avatar img{width:100%;height:100%;object-fit:cover}
++.ovf-account-links{display:flex;flex-wrap:wrap;gap:6px}
++.ovf-account-links a{display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border-radius:999px;font-size:.78rem;font-weight:600;
++ color:var(--text-secondary,#b6bdd2);background:var(--bg-secondary,#12131c);border:1px solid var(--border,rgba(255,255,255,.08));
++ text-decoration:none;transition:color .15s,border-color .15s,transform .15s}
++.ovf-account-links a:hover{color:var(--accent,#a78bfa);border-color:var(--accent,#a78bfa);transform:translateY(-1px)}
++.ovf-account-links i{font-size:.72rem;opacity:.8}
++.ovf-bar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px;
++ margin-top:clamp(20px,3vw,30px);padding-top:16px;border-top:1px solid var(--border,rgba(255,255,255,.08));
++ padding-inline:clamp(0px, calc(84px - (100vw - 1240px) / 2), 84px)}
++.ovf-copy{color:var(--text-muted,#8b93ad);font-size:.78rem}
++.ovf-legal-inline,.ovf-compact-links{display:flex;flex-wrap:wrap;gap:14px}
++.ovf-legal-inline a,.ovf-compact-links a{color:var(--text-muted,#8b93ad);font-size:.78rem;text-decoration:none;transition:color .15s}
++.ovf-legal-inline a:hover,.ovf-compact-links a:hover{color:var(--accent,#a78bfa)}
++.ovf-compact-meta{color:var(--text-muted,#8b93ad);font-size:.74rem}
++@media (max-width:900px){.ovf-cols{grid-template-columns:repeat(2,minmax(0,1fr))}.ovf-col--brand{grid-column:1/-1}}
++@media (max-width:560px){
++ .ovf{padding-bottom:calc(96px + env(safe-area-inset-bottom,0px))}
++ .ovf[data-variant="compact"]{padding-bottom:calc(84px + env(safe-area-inset-bottom,0px))}
++ .ovf-cols{gap:22px 16px}
++ .ovf-bar{justify-content:center;text-align:center;padding-inline:0}
++ .ovf-legal-inline{justify-content:center}
++ .ovf-account{flex-direction:column;align-items:flex-start}
++}
++@media (prefers-reduced-motion:reduce){.ovf-account{animation:none}.ovf-col a:hover,.ovf-social a:hover,.ovf-account-links a:hover{transform:none}}`;
++
++ function injectCSS() {
++ if (typeof document === 'undefined' || document.getElementById('ovf-css')) return;
++ const st = document.createElement('style');
++ st.id = 'ovf-css';
++ st.textContent = CSS;
++ document.head.appendChild(st);
++ }
++
++ // ── Mount ──────────────────────────────────────────────────
++ function render() {
++ if (!_mounted) return;
++ _mounted.className = 'ovf ' + (_mounted.dataset.extraClass || '');
++ _mounted.setAttribute('data-variant', _cfg.variant);
++ _mounted.innerHTML = buildHTML(_cfg);
++ try { if (typeof window !== 'undefined' && typeof window.ovMarkMount === 'function') window.ovMarkMount(_mounted); } catch { /* */ }
++ fillAccount(_cfg);
++ }
++
++ function init(cfg = {}) {
++ _cfg = { ...DEFAULTS, ..._cfg, ...cfg };
++ _cfg.service = _cfg.service || detectService();
++ injectCSS();
++ const el = typeof _cfg.mount === 'string' ? document.querySelector(_cfg.mount) : _cfg.mount;
++ if (!el) return null;
++ _mounted = el;
++ render();
++ return el;
++ }
++
++ function setVariant(variant) {
++ if (variant !== 'full' && variant !== 'compact') return;
++ if (_cfg.variant === variant) return;
++ _cfg.variant = variant;
++ render();
++ }
++
++ return { init, setVariant, buildHTML, render, CSS, NETWORK, LEGAL, detectService, get config() { return { ..._cfg }; } };
++});
ddiff --git a/vendor/openvibe-shared/history.js b/vendor/openvibe-shared/history.js
nnew file mode 100644
iindex 0000000..028c7f8
---- /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/island.js b/vendor/openvibe-shared/island.js
nnew file mode 100644
iindex 0000000..2c3a418
---- /dev/null
++++ b/vendor/openvibe-shared/island.js
@@@ -0,0 +1,162 @@
++/**
++ * openvibe-shared/island.js — the activity island.
++ *
++ * A page tells the navbar what it is doing. The brand mark becomes the indicator (progress ring,
++ * state colour) and a pill grows out of the brand; hover, focus or tap expands it into a rich card
++ * (image, title, subtitle, progress bar, detail line, actions).
++ *
++ * OpenVibeIsland.start({ id, title, subtitle, icon, image, progress, state, detail, actions })
++ * OpenVibeIsland.update(id, patch) progress: 0–1 | null (indeterminate)
++ * OpenVibeIsland.finish(id, { state: 'ok', title, actions, ttl })
++ * OpenVibeIsland.fail(id, { title, detail })
++ * OpenVibeIsland.remove(id) · .list()
++ *
++ * Works without the navbar (fixed pill, top centre). All text goes through textContent; links are
++ * http(s) or same-origin paths only. Motion respects prefers-reduced-motion.
++ */
++(function (root) {
++ 'use strict';
++ if (root.OpenVibeIsland) return;
++ if (typeof document === 'undefined') { const n = () => {}; const stub = { start: () => ({ update: n, finish: n, fail: n, remove: n }), update: n, finish: n, fail: n, remove: n, list: () => [] }; if (typeof module !== 'undefined') module.exports = stub; return; }
++
++ const CSS = `
++.ov-island{position:relative;display:inline-flex;align-items:center;margin-left:10px;z-index:60;font:500 12.5px/1.2 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:var(--text-primary,#e6edf7)}
++.ov-island.is-floating{position:fixed;top:10px;left:50%;transform:translateX(-50%);margin:0;z-index:2147482000}
++.ov-island[hidden]{display:none}
++.ovi-pill{appearance:none;border:1px solid color-mix(in srgb,var(--ovis-c) 45%,transparent);background:color-mix(in srgb,var(--ovis-c) 12%,var(--bg-elevated,var(--bg-secondary,#0d131d)));color:inherit;font:inherit;display:inline-flex;align-items:center;gap:8px;height:30px;padding:0 12px 0 5px;border-radius:999px;cursor:pointer;max-width:min(300px,46vw);animation:ovisGrow .4s cubic-bezier(.2,1.3,.3,1);box-shadow:0 4px 18px rgba(0,0,0,.28)}
++.ov-island{--ovis-c:var(--accent,#3b82f6)}
++.ov-island[data-state=ok]{--ovis-c:var(--success,#22c55e)}
++.ov-island[data-state=error]{--ovis-c:var(--danger,#ef4444)}
++.ovi-pill .ov-icon{--ovi-size:22px;--ovi-accent:var(--ovis-c)}
++.ovi-pill-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:650}
++.ovi-pill-pct{font-variant-numeric:tabular-nums;color:var(--text-secondary,#a8b3c4)}
++.ovi-count{background:var(--ovis-c);color:var(--on-accent,#fff);border-radius:999px;font-size:10.5px;font-weight:800;padding:1px 6px}
++.ovi-card{position:absolute;top:calc(100% + 8px);left:0;width:min(340px,calc(100vw - 24px));background:var(--bg-elevated,var(--bg-secondary,#111826));border:1px solid var(--border,rgba(255,255,255,.12));border-radius:18px;box-shadow:0 24px 60px rgba(0,0,0,.5);padding:8px;opacity:0;transform:translateY(-6px) scale(.97);transform-origin:top left;pointer-events:none;transition:opacity .18s,transform .22s cubic-bezier(.2,1.2,.3,1)}
++.ov-island.is-floating .ovi-card{left:50%;translate:-50% 0;transform-origin:top center}
++.ov-island.is-open .ovi-card{opacity:1;transform:none;pointer-events:auto}
++.ovi-item{display:grid;grid-template-columns:auto 1fr;gap:10px;padding:8px;border-radius:12px;--ovis-c:var(--accent,#3b82f6)}
++.ovi-item[data-state=ok]{--ovis-c:var(--success,#22c55e)}
++.ovi-item[data-state=error]{--ovis-c:var(--danger,#ef4444)}
++.ovi-item+.ovi-item{border-top:1px solid var(--border,rgba(255,255,255,.08));border-radius:0}
++.ovi-thumb{width:64px;height:44px;border-radius:8px;object-fit:cover;background:#0006}
++.ovi-item .ov-icon{--ovi-size:40px;--ovi-accent:var(--ovis-c)}
++.ovi-t{font-weight:700;font-size:13.5px}
++.ovi-s{color:var(--text-secondary,#a8b3c4);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
++.ovi-body{min-width:0}
++.ovi-bar{height:4px;border-radius:4px;background:color-mix(in srgb,var(--ovis-c) 18%,transparent);margin-top:8px;overflow:hidden}
++.ovi-bar i{display:block;height:100%;width:0;background:var(--ovis-c);border-radius:4px;transition:width .35s cubic-bezier(.2,.8,.2,1)}
++.ovi-bar.is-ind i{width:35%;animation:ovisInd 1.2s ease-in-out infinite}
++.ovi-d{color:var(--text-muted,#7d8aa0);font-size:11.5px;margin-top:6px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}
++.ovi-acts{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}
++.ovi-act{appearance:none;border:0;cursor:pointer;text-decoration:none;font:700 12px/1 system-ui,sans-serif;padding:7px 11px;border-radius:8px;color:var(--ovis-c);background:color-mix(in srgb,var(--ovis-c) 14%,transparent)}
++.ovi-act:first-child{background:var(--ovis-c);color:var(--on-accent,#fff)}
++.ovi-act:focus-visible,.ovi-pill:focus-visible{outline:2px solid var(--ovis-c);outline-offset:2px}
++@keyframes ovisGrow{from{max-width:30px;opacity:0}to{opacity:1}}
++@keyframes ovisInd{0%{transform:translateX(-110%)}100%{transform:translateX(300%)}}
++@media (max-width:640px){.ov-island{margin-left:6px}.ovi-pill{max-width:40vw;padding-right:9px}.ovi-pill-pct{display:none}.ovi-card{position:fixed;top:56px;left:12px;right:12px;width:auto}}
++@media (prefers-reduced-motion:reduce){.ovi-pill,.ovi-bar.is-ind i{animation:none}.ovi-card,.ovi-bar i{transition:none}}`;
++
++ const items = new Map(); // id → activity (insertion order = age)
++ const timers = new Map();
++ let host = null, pill = null, card = null, seq = 0;
++
++ const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = String(text); return e; };
++ const safeUrl = (u) => { try { const x = new URL(u, location.href); return /^(https?|blob):$/.test(x.protocol) ? x.href : null; } catch { return null; } };
++ const clamp = (p) => (typeof p === 'number' && isFinite(p) ? Math.max(0, Math.min(1, p)) : null);
++
++ function ensure() {
++ if (host && host.isConnected) return;
++ if (!document.getElementById('ov-island-css')) { const s = el('style'); s.id = 'ov-island-css'; s.textContent = CSS; document.head.appendChild(s); }
++ if (!root.OpenVibeIcons && !document.getElementById('ov-icons-loader')) { const sc = el('script'); sc.id = 'ov-icons-loader'; sc.src = 'https://openvibe.network/shared/ov-icons.js'; sc.async = true; document.head.appendChild(sc); }
++ host = el('div', 'ov-island'); host.hidden = true;
++ pill = el('button', 'ovi-pill'); pill.type = 'button'; pill.setAttribute('aria-haspopup', 'true'); pill.setAttribute('aria-expanded', 'false');
++ card = el('div', 'ovi-card'); card.setAttribute('role', 'region'); card.setAttribute('aria-label', 'Activity');
++ host.append(pill, card);
++ // Dock after the brand (and its launcher button); sites with their own navbar mark the spot with [data-ov-island].
++ const brand = document.querySelector('[data-ov-island], .openvibe-navbar #openvibe-launcher-btn, .openvibe-navbar .openvibe-navbar-brand, .navbar .nav-brand');
++ if (brand && brand.parentNode) brand.parentNode.insertBefore(host, brand.nextSibling);
++ else { host.classList.add('is-floating'); document.body.appendChild(host); }
++ const open = (v) => { host.classList.toggle('is-open', v); pill.setAttribute('aria-expanded', String(v)); };
++ pill.addEventListener('click', () => open(!host.classList.contains('is-open')));
++ if (matchMedia('(hover:hover)').matches) { host.addEventListener('mouseenter', () => open(true)); host.addEventListener('mouseleave', () => open(false)); }
++ document.addEventListener('click', (e) => { if (!host.contains(e.target)) open(false); });
++ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') open(false); });
++ }
++
++ function top() { let t = null; for (const a of items.values()) t = a; return t; }
++
++ function brandMark(a) {
++ // The navbar's own mark mirrors the top activity: progress ring + state colour.
++ document.querySelectorAll('.openvibe-navbar-brand .ov-mark, .navbar .nav-brand .ov-mark').forEach((m) => {
++ if (m.closest('.openvibe-network-badge')) return;
++ if (!a) { m.removeAttribute('data-state'); m.removeAttribute('data-progress'); m.style.removeProperty('--ovm-p'); return; }
++ m.setAttribute('data-state', a.state === 'info' ? 'idle' : a.state);
++ if (a.progress == null || a.state !== 'busy') m.removeAttribute('data-progress'); else { m.setAttribute('data-progress', String(a.progress)); m.style.setProperty('--ovm-p', String(a.progress)); }
++ });
++ try { root.dispatchEvent(new CustomEvent('ov:activity', { detail: a ? { id: a.id, state: a.state, progress: a.progress, title: a.title } : null })); } catch { /* */ }
++ }
++
++ function iconEl(a, size) { const i = el('span', 'ov-icon'); i.dataset.icon = a.icon || 'ov'; i.dataset.size = String(size); i.dataset.state = a.state === 'info' ? 'idle' : a.state; if (a.progress != null && a.state === 'busy') i.dataset.progress = String(a.progress); i.dataset.fx = a.state === 'busy' ? 'orbit' : 'none'; return i; }
++
++ function render() {
++ ensure();
++ const a = top();
++ brandMark(a);
++ if (!a) { host.hidden = true; host.classList.remove('is-open'); return; }
++ host.hidden = false; host.dataset.state = a.state;
++ pill.replaceChildren(iconEl(a, 22), el('span', 'ovi-pill-text', a.title || 'Working'));
++ if (a.state === 'busy' && a.progress != null) pill.appendChild(el('span', 'ovi-pill-pct', Math.round(a.progress * 100) + '%'));
++ if (items.size > 1) pill.appendChild(el('span', 'ovi-count', items.size));
++ pill.setAttribute('aria-label', `${a.title || 'Activity'}${a.progress != null ? ', ' + Math.round(a.progress * 100) + ' percent' : ''}`);
++
++ card.replaceChildren(...[...items.values()].reverse().map((it) => {
++ const row = el('div', 'ovi-item'); row.dataset.state = it.state;
++ const img = it.image && safeUrl(it.image);
++ if (img) { const im = el('img', 'ovi-thumb'); im.src = img; im.alt = ''; im.loading = 'lazy'; im.referrerPolicy = 'no-referrer'; row.appendChild(im); } else row.appendChild(iconEl(it, 40));
++ const body = el('div', 'ovi-body'); body.appendChild(el('div', 'ovi-t', it.title || ''));
++ if (it.subtitle) body.appendChild(el('div', 'ovi-s', it.subtitle));
++ if (it.state === 'busy') { const bar = el('div', 'ovi-bar' + (it.progress == null ? ' is-ind' : '')); const fill = el('i'); if (it.progress != null) fill.style.width = (it.progress * 100).toFixed(1) + '%'; bar.appendChild(fill); bar.setAttribute('role', 'progressbar'); if (it.progress != null) bar.setAttribute('aria-valuenow', String(Math.round(it.progress * 100))); body.appendChild(bar); }
++ if (it.detail) body.appendChild(el('div', 'ovi-d', it.detail));
++ if (it.actions && it.actions.length) {
++ const acts = el('div', 'ovi-acts');
++ it.actions.slice(0, 3).forEach((ac) => {
++ if (!ac || !ac.label) return;
++ const href = ac.href && safeUrl(ac.href);
++ const b = el(href ? 'a' : 'button', 'ovi-act', ac.label);
++ if (href) { b.href = href; if (ac.download) b.setAttribute('download', typeof ac.download === 'string' ? ac.download : ''); } else b.type = 'button';
++ if (ac.onClick) b.addEventListener('click', (ev) => { try { ac.onClick(ev); } catch { /* */ } });
++ acts.appendChild(b);
++ });
++ body.appendChild(acts);
++ }
++ row.appendChild(body);
++ return row;
++ }));
++ }
++
++ function put(id, patch, defaults) {
++ const prev = items.get(id) || Object.assign({ id, title: '', subtitle: '', icon: 'ov', image: '', progress: null, state: 'busy', detail: '', actions: [] }, defaults);
++ const next = Object.assign({}, prev, patch || {});
++ if (patch && 'progress' in patch) next.progress = clamp(patch.progress);
++ if (!['busy', 'ok', 'error', 'info'].includes(next.state)) next.state = 'busy';
++ items.set(id, next);
++ clearTimeout(timers.get(id));
++ if (next.ttl) timers.set(id, setTimeout(() => remove(id), next.ttl));
++ render();
++ return next;
++ }
++
++ function handle(id) { return { id, update: (p) => update(id, p), finish: (p) => finish(id, p), fail: (p) => fail(id, p), remove: () => remove(id) }; }
++ function start(o) { const id = (o && o.id) || 'act-' + (++seq); items.delete(id); put(id, Object.assign({ state: 'busy' }, o, { id })); return handle(id); }
++ function update(id, patch) { if (!items.has(id)) return; const p = Object.assign({}, patch); delete p.id; put(id, p); }
++ function finish(id, patch) { if (!items.has(id)) return; put(id, Object.assign({ state: 'ok', progress: 1, actions: [], ttl: 8000 }, patch)); if (host && top() && top().id === id && (patch && patch.actions && patch.actions.length)) { host.classList.add('is-open'); pill.setAttribute('aria-expanded', 'true'); setTimeout(() => { if (host && !host.matches(':hover')) host.classList.remove('is-open'); }, 5000); } }
++ function fail(id, patch) { if (!items.has(id)) return; put(id, Object.assign({ state: 'error', progress: null, actions: [], ttl: 12000 }, patch)); }
++ function remove(id) { clearTimeout(timers.get(id)); timers.delete(id); if (items.delete(id)) render(); }
++
++ const api = { start, update, finish, fail, remove, list: () => [...items.values()] };
++ root.OpenVibeIsland = api;
++ // The navbar exposes the same thing as OpenVibeNavbar.activity, whichever loads first.
++ const bind = () => { if (root.OpenVibeNavbar && !root.OpenVibeNavbar.activity) { try { root.OpenVibeNavbar.activity = api; } catch { /* */ } } };
++ bind(); root.addEventListener('load', bind);
++ if (typeof module !== 'undefined' && module.exports) module.exports = api;
++})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/vendor/openvibe-shared/navbar.js b/vendor/openvibe-shared/navbar.js
iindex ad00a31..a52db68 100644
---- a/vendor/openvibe-shared/navbar.js
++++ b/vendor/openvibe-shared/navbar.js
@@@ -8,12 +8,43 @@
// 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,
++ // fedcm: false to opt out; 'optional' (default) shows the browser's native chip the first
++ // time and re-authenticates silently afterwards; 'silent' only re-authenticates.
++ fedcm: 'optional',
++ fedcmLogin: null, // POST target for the assertion (default: this site's /auth/fedcm)
++ };
++ const _runtimeMenu = { before: [], after: [] };
++ let _runtimeLinks = null;
let _navEl = null;
function injectStyles() {
@@@ -29,10 +60,65 @@
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 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)); }
++ .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; }
++ .ovnav-launcher .ovl-q:focus { border-color: var(--accent, #3b82f6); }
++ .ovnav-launcher .ovl-h { display: flex; justify-content: space-between; font-size: 11px; font-weight: 700; letter-spacing: .7px; text-transform: uppercase; color: var(--text-muted, #7d8aa0); margin: 12px 4px 6px; }
++ .ovnav-launcher .ovl-h a { color: var(--accent-light, var(--accent, #60a5fa)); text-decoration: none; text-transform: none; letter-spacing: 0; }
++ .ovnav-launcher .ovl-sites { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
++ .ovnav-launcher .ovl-fams { display: grid; grid-template-columns: repeat(2, 1fr); gap: 2px; }
++ .ovnav-launcher .ovl-site, .ovnav-launcher .ovl-fam { display: flex; align-items: center; gap: 9px; padding: 8px; border-radius: 10px; color: var(--text-primary, #e6edf7); text-decoration: none; min-width: 0; }
++ .ovnav-launcher .ovl-site { flex-direction: column; text-align: center; gap: 6px; padding: 12px 6px; }
++ .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-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 .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 +436,211 @@
'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 || '/' };
++ }
++
++ /** Where each brand segment goes: sub → this tool, OpenVibe → the network, TLD → the site's apex. */
++ function brandLinks(brand) {
++ const apex = brand.core === 'OpenRe' ? 'https://openre.stream/' : `https://openvibe.${brand.tld}/`;
++ if (!brand.subText) return { sub: brand.href, core: brand.href, tld: brand.href, apex };
++ return { sub: brand.href, core: 'https://openvibe.network/', tld: apex, apex };
++ }
++
++ function brandHTML(brand) {
++ const to = brandLinks(brand);
++ const seg = (cls, text, href, title) => `<a href="${escapeAttr(href)}"${title ? ` title="${escapeAttr(title)}"` : ''} class="${cls}">${escapeAttr(text)}</a>`;
++ const dot = '<span class="b-dot">.</span>';
++ const text = brand.subText
++ ? seg('b-sub', brand.subText, to.sub, brand.name) + dot + seg('b-core', brand.core, to.core, 'OpenVibe Network') + dot + seg('b-tld', brand.tldText, to.tld, `All of ${brand.core}.${brand.tldText}`)
++ : seg('b-core', brand.core, to.core, brand.name) + dot + seg('b-tld', brand.tldText, to.tld, brand.name);
++ 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 `<div class="openvibe-navbar-brand${brand.subText ? ' has-sub' : ''}">
++ <a class="flame" href="${escapeAttr(brand.href)}" aria-label="${escapeAttr(brand.name)} home">${mark}</a>
++ <span class="name">${text}${brand.tag ? `<span class="b-tag">${escapeAttr(brand.tag)}</span>` : ''}</span>
++ </div>
++ ${_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>'}`;
++ }
++
++ // ── Network launcher ─────────────────────────────────────
++ const LAUNCHER_SITES = [
++ { name: 'Live', desc: 'Streams, clips and chat', icon: 'live', url: 'https://openvibe.live/' },
++ { name: 'Tools', desc: 'Every online tool', icon: 'tools', url: 'https://openvibe.tools/' },
++ { name: 'Community', desc: 'Pastes and posts', icon: 'community', url: 'https://openvibe.community/' },
++ { name: 'Games', desc: 'Browser games', icon: 'games', url: 'https://openvibe.games/' },
++ { name: 'Media', desc: 'VODs, clips, files', icon: 'media', url: 'https://openvibe.media/' },
++ { name: 'Network', desc: 'Account and themes', icon: 'network', url: 'https://openvibe.network/' },
++ ];
++ const LAUNCHER_FAMILIES = [
++ { name: 'Media Tools', icon: 'youtube', url: 'https://yt.openvibe.tools/' },
++ { name: 'Image Tools', icon: 'image', url: 'https://img.openvibe.tools/' },
++ { name: 'Audio Tools', icon: 'audio', url: 'https://audio.openvibe.tools/' },
++ { name: 'PDF & Documents', icon: 'pdf', url: 'https://docs.openvibe.tools/' },
++ { name: 'Text Tools', icon: 'text', url: 'https://text.openvibe.tools/' },
++ { name: 'Developer Tools', icon: 'code', url: 'https://dev.openvibe.tools/' },
++ { name: 'Network Tools', icon: 'dns', url: 'https://net.openvibe.tools/' },
++ ];
++ const CATALOG_URL = 'https://openvibe.tools/api/catalog.json';
++ const okUrl = (u) => { try { const x = new URL(u); return x.protocol === 'https:' ? x.href : null; } catch { return null; } };
++
++ async function launcherCatalog() {
++ try { const c = JSON.parse(sessionStorage.getItem('ov_catalog') || 'null'); if (c && Date.now() - c.at < 30 * 60000) return c.data; } catch { /* */ }
++ try {
++ const r = await fetch(CATALOG_URL, { credentials: 'omit' }); if (!r.ok) return null;
++ const j = await r.json();
++ const data = { families: (j.families || []).slice(0, 12).map(f => ({ name: String(f.name || ''), icon: String(f.icon || 'tools'), url: okUrl(f.url) })).filter(f => f.name && f.url),
++ tools: (j.tools || []).slice(0, 400).map(t => ({ name: String(t.name || ''), icon: String(t.icon || 'tools'), url: okUrl(t.url), k: [t.name, t.tagline].concat(t.keywords || []).join(' ').toLowerCase().slice(0, 400) })).filter(t => t.name && t.url) };
++ try { sessionStorage.setItem('ov_catalog', JSON.stringify({ at: Date.now(), data })); } catch { /* */ }
++ return data;
++ } catch { return null; }
++ }
++
++ function bindLauncher(nav) {
++ const btn = nav.querySelector('#openvibe-launcher-btn'); if (!btn) return;
++ let panel = null;
++ const tile = (it, cls) => `<a class="${cls}" href="${escapeAttr(it.url)}"><span class="ov-icon" data-icon="${escapeAttr(it.icon)}" data-size="${cls === 'ovl-site' ? 34 : 24}" data-fx="none"></span><span><b>${escapeAttr(it.name)}</b>${it.desc ? `<small>${escapeAttr(it.desc)}</small>` : ''}</span></a>`;
++ const close = () => { if (panel) panel.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); };
++ function paint(cat, q) {
++ const fams = (cat && cat.families.length ? cat.families : LAUNCHER_FAMILIES);
++ const body = panel.querySelector('.ovl-body');
++ if (q && cat) {
++ const hits = cat.tools.filter(t => t.k.includes(q)).slice(0, 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>`;
++ }
++ btn.addEventListener('click', async (e) => {
++ e.stopPropagation();
++ if (panel && panel.classList.contains('open')) return close();
++ if (!panel) {
++ panel = document.createElement('div'); panel.className = 'ovnav-launcher'; panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'OpenVibe sites and tools');
++ panel.innerHTML = '<input type="search" class="ovl-q" placeholder="Find a tool or site" aria-label="Find a tool or site"><div class="ovl-body"></div>';
++ nav.appendChild(panel);
++ if (!root.OpenVibeIcons && !document.getElementById('ov-icons-loader')) { const sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = 'https://openvibe.network/shared/ov-icons.js'; sc.async = true; document.head.appendChild(sc); }
++ let cat = null; paint(null, '');
++ const input = panel.querySelector('.ovl-q');
++ input.addEventListener('input', () => paint(cat, input.value.trim().toLowerCase()));
++ input.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { const first = panel.querySelector('.ovl-body a'); if (first) location.href = first.href; } if (ev.key === 'ArrowDown') { const first = panel.querySelector('.ovl-body a'); if (first) { ev.preventDefault(); first.focus(); } } });
++ panel.addEventListener('keydown', (ev) => {
++ if (ev.key === 'Escape') { close(); btn.focus(); return; }
++ if (ev.key !== 'ArrowDown' && ev.key !== 'ArrowUp') return;
++ const links = [...panel.querySelectorAll('.ovl-body a')]; const i = links.indexOf(document.activeElement); if (i < 0) return;
++ ev.preventDefault(); const n = i + (ev.key === 'ArrowDown' ? 1 : -1); (links[n] || (n < 0 ? input : links[0])).focus();
++ });
++ panel.addEventListener('click', (ev) => ev.stopPropagation());
++ document.addEventListener('click', close);
++ launcherCatalog().then((c) => { if (c) { cat = c; paint(cat, input.value.trim().toLowerCase()); } });
++ }
++ panel.classList.add('open'); btn.setAttribute('aria-expanded', 'true');
++ try { panel.querySelector('.ovl-q').focus({ preventScroll: true }); } catch { /* */ }
++ });
++ }
++
++ // ── Notification bell (mounted by the navbar itself) ─────
++ function mountBell(nav, u) {
++ if (_config.notifications === false || !u || u.is_anon || !_config.token) return;
++ const mount = nav.querySelector('#openvibe-bell-mount'); if (!mount || mount.childElementCount) return;
++ const go = () => {
++ const N = root.OpenVibeNotifications; if (!N || mount.childElementCount) return;
++ try { if (!N.__ovNavInit) { N.init({ token: _config.token, apiBase: 'https://openvibe.network' }); N.__ovNavInit = true; } N.createBell(mount); } catch { /* */ }
++ };
++ if (root.OpenVibeNotifications) return go();
++ if (document.getElementById('ov-notify-loader')) return;
++ const sc = document.createElement('script'); sc.id = 'ov-notify-loader'; sc.src = 'https://openvibe.network/shared/notification-ui.js'; sc.async = true; sc.onload = () => setTimeout(go, 0); document.head.appendChild(sc);
++ }
++
++ 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 +901,114 @@
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.
++ */
++ let _ssoClientLoading = null;
++ function loadSsoClient() {
++ if (root.OpenVibeSSO) return Promise.resolve(root.OpenVibeSSO);
++ if (_ssoClientLoading) return _ssoClientLoading;
++ _ssoClientLoading = new Promise((resolve) => {
++ const sc = document.createElement('script'); sc.async = true; sc.src = `${_config.apiBase}/shared/sso-client.js`;
++ sc.onload = () => resolve(root.OpenVibeSSO || null); sc.onerror = () => resolve(null);
++ document.head.appendChild(sc);
++ });
++ return _ssoClientLoading;
++ }
++
++ /** Signed in here: cross-site links carry the session along (see sso-client.js). */
++ function enableHandoff() {
++ loadSsoClient().then((sso) => { try { sso && sso.handoffLinks({ signedIn: !!_config.user && !_config.user.is_anon }); } catch { /* */ } });
++ }
++
++ function silentLoginNow() {
++ const url = String(_config.silentLogin).replace('{url}', encodeURIComponent(location.href));
++ location.replace(url);
++ return true;
++ }
++
++ /**
++ * Two ways to find out that this browser is signed in to the network without a session here:
++ * 1. the hint cookie this site set on an earlier sign-in ('account') — go straight to the
++ * silent sign-in (one quick redirect, back where you were);
++ * 2. otherwise ask the network in a hidden iframe (GET /sso/check) — invisible, no
++ * redirect unless the answer is yes. Browsers that partition third-party cookies answer
++ * no and nothing happens, which is the same as before.
++ * At most once per tab per 10 minutes; never after an explicit sign-out ('guest'); never for bots.
++ */
++ function maybeSilentLogin() {
++ if (!_config.silentLogin || typeof location === 'undefined') return false;
++ const hint = ssoHint();
++ if (hint === 'guest') return false;
++ if (/bot|crawl|spider|slurp|headless/i.test(navigator.userAgent || '')) return false;
++ if (/[?&]sso=none\b/.test(location.search)) return false;
++ try {
++ const last = +sessionStorage.getItem('ov_silent_sso_at') || 0;
++ if (Date.now() - last < 10 * 60 * 1000) return false;
++ sessionStorage.setItem('ov_silent_sso_at', String(Date.now()));
++ } catch { return false; }
++ if (hint === 'account') return silentLoginNow();
++ checkNetworkSession().then((state) => {
++ if (state && state.signedIn) return silentLoginNow();
++ // No answer or "not signed in" — either a guest, or a browser that keeps the network's
++ // cookie away from iframes. FedCM asks the browser itself; the network's login status
++ // makes it a no-op for guests, a native chip (then silent re-auth) for signed-in users.
++ if (_config.fedcm === false) return;
++ loadSsoClient().then(async (sso) => {
++ if (!sso || !sso.fedcmAvailable()) return;
++ const r = await sso.fedcm({ apiBase: _config.apiBase, fedcmLogin: _config.fedcmLogin || undefined, mediation: _config.fedcm === 'silent' ? 'silent' : 'optional' });
++ if (r && r.ok) { try { sessionStorage.removeItem('ov_silent_sso_at'); } catch { /* */ } location.reload(); }
++ });
++ });
++ return false;
++ }
++
++ /** Ask the network (hidden iframe + postMessage) whether this browser is signed in there. */
++ function checkNetworkSession(timeoutMs = 4000) {
++ return new Promise((resolve) => {
++ let done = false, frame = null, timer = null;
++ const finish = (v) => { if (done) return; done = true; clearTimeout(timer); window.removeEventListener('message', onMsg); try { frame?.remove(); } catch { /* */ } resolve(v); };
++ const onMsg = (e) => {
++ if (e.origin !== _config.apiBase || !e.data || e.data.type !== 'ov-sso') return;
++ finish({ signedIn: !!e.data.signedIn, username: e.data.username || null });
++ };
++ try {
++ window.addEventListener('message', onMsg);
++ frame = document.createElement('iframe');
++ frame.setAttribute('aria-hidden', 'true'); frame.setAttribute('tabindex', '-1');
++ frame.style.cssText = 'position:absolute;width:0;height:0;border:0;opacity:0;pointer-events:none';
++ frame.src = `${_config.apiBase}/sso/check?origin=${encodeURIComponent(location.origin)}`;
++ (document.body || document.documentElement).appendChild(frame);
++ timer = setTimeout(() => finish(null), timeoutMs);
++ } catch { finish(null); }
++ });
++ }
++
++ 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 +1018,10 @@
_config.user = session.user;
if (session.token) _config.token = session.token;
render();
++ recordHistory();
++ enableHandoff();
++ } else {
++ maybeSilentLogin();
}
try {
document.dispatchEvent(new CustomEvent('openvibe-navbar-auth', {
@@@ -673,28 +1076,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,25 +1114,27 @@
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>`}
</div>
`;
++ bindLauncher(nav);
++ // Pages that wire the bell themselves run right after init(); give them the first go.
++ setTimeout(() => mountBell(nav, u), 0);
// Dropdown
if (u) {
@@@ -729,6 +1143,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 +1171,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', () => {
@@@ -793,6 +1215,8 @@
clearAuthState();
_config.user = null;
_config.token = null;
++ try { root.OpenVibeSSO && root.OpenVibeSSO.preventSilent(); } catch { /* */ }
++ try { localStorage.setItem('ov_sso_hint', 'guest'); } catch { /* */ }
if (onToolsDomain()) {
// The gateway also clears the Domain=.openvibe.tools cookie
// and the httpOnly refresh cookie, then sends us back here.
@@@ -834,9 +1258,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(); enableHandoff(); }
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 +1274,33 @@
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();
++ },
++
++ /** Is this browser signed in to the network? ({ signedIn, username } or null when unknown). */
++ checkNetworkSession,
++
++ /** The resolved brand for this page ({ sub, core, tld, name, short, variant }). */
++ brand() { return resolveBrand(); },
++
++ /** The activity island (island.js), loaded on first use: OpenVibeNavbar.activity.start({...}). */
++ get activity() { return root.OpenVibeIsland || null; },
++ set activity(_v) { /* island.js binds itself; nothing to store */ },
++
setToken(token) { _config.token = token; },
/** Get the bell mount point for OpenVibeNotifications. */
ddiff --git a/vendor/openvibe-shared/ov-icons.js b/vendor/openvibe-shared/ov-icons.js
nnew file mode 100644
iindex 0000000..d4208ad
---- /dev/null
++++ b/vendor/openvibe-shared/ov-icons.js
@@@ -0,0 +1,184 @@
++/**
++ * openvibe-shared/ov-icons.js — the OpenVibe animated icon family.
++ *
++ * <span class="ov-icon" data-icon="youtube" data-size="40"></span>
++ * <span class="ov-icon" data-icon="pdf" data-state="busy" data-progress="0.4" data-fx="orbit"></span>
++ *
++ * One construction for every icon so the network reads as one brand: the OV ring (with its comet)
++ * around a glyph drawn in the theme accent. The ring is also a progress meter (data-progress 0–1)
++ * and a status light (data-state idle|busy|ok|error), so the same icon that names a tool can show
++ * what the tool is doing. Glyphs are 24×24 stroke paths centred in the ring.
++ *
++ * OpenVibeIcons.names() every registered icon
++ * OpenVibeIcons.register(name, { glyph, fx?, accent? })
++ * OpenVibeIcons.mount(root?) (re)scan; new nodes are picked up automatically
++ * OpenVibeIcons.set(el, { progress, state, icon })
++ * OpenVibeIcons.svg(name, size) markup string, for server-side / templates
++ *
++ * Effects (data-fx): orbit (default) · pulse · draw · bounce · none. Motion is transform/opacity/
++ * stroke only and stops under prefers-reduced-motion. Unknown names fall back to the OV mark "V".
++ */
++(function (root) {
++ 'use strict';
++ if (root.OpenVibeIcons) return;
++
++ const P = (d) => `<path d="${d}"/>`;
++ // Glyphs: stroke-only, 24×24 box, designed to sit inside the ring.
++ const GLYPHS = {
++ ov: P('M6.5 7.5 12 17.5l5.5-10'),
++ network: '<circle cx="12" cy="6" r="2"/><circle cx="5.5" cy="17" r="2"/><circle cx="18.5" cy="17" r="2"/>' + P('M12 8v3.5M12 11.5 6.8 15.4M12 11.5l5.2 3.9'),
++ live: '<circle cx="12" cy="12" r="2.2"/>' + P('M7.5 7.5a6.4 6.4 0 0 0 0 9M16.5 7.5a6.4 6.4 0 0 1 0 9M4.8 5a10 10 0 0 0 0 14M19.2 5a10 10 0 0 1 0 14'),
++ tools: P('M14.5 5.5a4 4 0 0 0-5.2 5.2L4.5 15.5a1.8 1.8 0 0 0 2.5 2.5l4.8-4.8a4 4 0 0 0 5.2-5.2l-2.6 2.6-2.1-.5-.5-2.1z'),
++ media: '<rect x="4" y="6" width="16" height="12" rx="2"/>' + P('M10 9.5v5l4.5-2.5z'),
++ games: P('M7 9h10a4 4 0 0 1 3.9 4.9l-.6 2.6a2 2 0 0 1-3.5.8L15.5 16h-7l-1.3 1.3a2 2 0 0 1-3.5-.8l-.6-2.6A4 4 0 0 1 7 9zM8 11.5v3M6.5 13h3') + '<circle cx="15.5" cy="12" r=".6"/><circle cx="17.5" cy="14" r=".6"/>',
++ community: '<circle cx="9" cy="9" r="2.6"/><circle cx="16.5" cy="10" r="2"/>' + P('M3.8 18.5a5.2 5.2 0 0 1 10.4 0M14.6 14.2a4 4 0 0 1 5.6 3.8'),
++ chat: P('M5 6.5h14a1.5 1.5 0 0 1 1.5 1.5v7A1.5 1.5 0 0 1 19 16.5h-7l-4 3v-3H5A1.5 1.5 0 0 1 3.5 15V8A1.5 1.5 0 0 1 5 6.5zM8 10.5h8M8 13h5'),
++ codes: P('M9 8 4.5 12 9 16M15 8l4.5 4-4.5 4M13.2 6l-2.4 12'),
++ code: P('M9 8 4.5 12 9 16M15 8l4.5 4-4.5 4M13.2 6l-2.4 12'),
++ blog: P('M5 19l1.2-4.2L16 5l3 3-9.8 9.8zM14 7l3 3'),
++ wiki: P('M5 5.5h6a2 2 0 0 1 2 2V19a2 2 0 0 0-2-2H5zM19 5.5h-4a2 2 0 0 0-2 2V19a2 2 0 0 1 2-2h4z'),
++ news: '<rect x="4" y="5.5" width="13" height="13" rx="1.5"/>' + P('M17 9h3v8a1.5 1.5 0 0 1-3 0M7 9h7M7 12h7M7 15h4'),
++ reviews: P('M12 4.5l2.2 4.6 5 .7-3.6 3.5.9 5-4.5-2.4-4.5 2.4.9-5L4.8 9.8l5-.7z'),
++ tips: P('M12 19s-6.5-3.8-6.5-8.6A3.6 3.6 0 0 1 12 8a3.6 3.6 0 0 1 6.5 2.4C18.5 15.2 12 19 12 19z'),
++ vip: P('M4.5 16.5 6 8l3.8 3.5L12 6.5l2.2 5L18 8l1.5 8.5zM5 19h14'),
++ trade: P('M4.5 18.5h15M6.5 15l3.5-4 3 2.5 4.5-6M17.5 7.5h-3M17.5 7.5v3'),
++ host: '<rect x="4.5" y="5" width="15" height="5.5" rx="1.2"/><rect x="4.5" y="13.5" width="15" height="5.5" rx="1.2"/><circle cx="7.5" cy="7.75" r=".6"/><circle cx="7.5" cy="16.25" r=".6"/>',
++ deals: P('M4.5 12.5V5.5h7l8 8-7 7zM8.2 9.2h.01'),
++ coupons: P('M4 8.5h16v2.5a1.5 1.5 0 0 0 0 3v2.5H4V14a1.5 1.5 0 0 0 0-3zM10 8.5v8') ,
++ stream: P('M5 17.5 12 6l7 11.5M8 13h8') + '<circle cx="12" cy="6" r="1.4"/>',
++ youtube: '<rect x="3.5" y="6.5" width="17" height="11" rx="3"/>' + P('M10.5 9.8v4.4l3.8-2.2z'),
++ video: '<rect x="3.5" y="6.5" width="17" height="11" rx="3"/>' + P('M10.5 9.8v4.4l3.8-2.2z'),
++ download: P('M12 4.5v10M8 11l4 4 4-4M5 19h14'),
++ upload: P('M12 15.5v-10M8 9l4-4 4 4M5 19h14'),
++ image: '<rect x="4" y="5" width="16" height="14" rx="2"/><circle cx="9" cy="10" r="1.5"/>' + P('M4.5 17l4.5-4.5 3.5 3.5 2.5-2.5 4.5 4.5'),
++ audio: P('M9 17V7.5l9-2V15') + '<circle cx="6.8" cy="17" r="2.2"/><circle cx="15.8" cy="15" r="2.2"/>',
++ pdf: P('M7 4.5h7l4 4V19a.5.5 0 0 1-.5.5h-10.5A.5.5 0 0 1 6.5 19V5a.5.5 0 0 1 .5-.5zM14 4.5v4h4M9 13h6M9 16h4'),
++ docs: P('M7 4.5h7l4 4V19a.5.5 0 0 1-.5.5h-10.5A.5.5 0 0 1 6.5 19V5a.5.5 0 0 1 .5-.5zM14 4.5v4h4M9 13h6M9 16h4'),
++ text: P('M5.5 7V5.5h13V7M12 5.5v13M9.5 18.5h5'),
++ logo: P('M12 4.5l1.8 4.2 4.2 1.8-4.2 1.8L12 16.5l-1.8-4.2L6 10.5l4.2-1.8zM18 15.5l.8 1.7 1.7.8-1.7.8-.8 1.7-.8-1.7-1.7-.8 1.7-.8z'),
++ json: P('M9 5.5c-2 0-2 1.5-2 3s0 3.5-2 3.5c2 0 2 2 2 3.5s0 3 2 3M15 5.5c2 0 2 1.5 2 3s0 3.5 2 3.5c-2 0-2 2-2 3.5s0 3-2 3'),
++ dns: '<circle cx="12" cy="12" r="7.5"/>' + P('M4.5 12h15M12 4.5c2.5 2 2.5 13 0 15M12 4.5c-2.5 2-2.5 13 0 15'),
++ ip: P('M12 20s-6-5.2-6-10a6 6 0 0 1 12 0c0 4.8-6 10-6 10z') + '<circle cx="12" cy="10" r="2.2"/>',
++ ssl: '<rect x="6" y="11" width="12" height="8.5" rx="1.8"/>' + P('M8.5 11V8.5a3.5 3.5 0 0 1 7 0V11M12 14.5v2'),
++ ping: P('M3.5 12h4l2-5 3.5 10 2.5-7 1.5 2h3.5'),
++ whois: '<circle cx="11" cy="11" r="5.5"/>' + P('M15.2 15.2 19.5 19.5M9 10a2 2 0 0 1 4 0c0 1.5-2 1.6-2 3M11 15h.01'),
++ search: '<circle cx="11" cy="11" r="5.5"/>' + P('M15.2 15.2 19.5 19.5'),
++ map: P('M4.5 7l5-2 5 2 5-2v12l-5 2-5-2-5 2zM9.5 5v12M14.5 7v12'),
++ food: P('M7 4.5v6a2 2 0 0 0 4 0v-6M9 4.5v15M16.5 4.5c-2 1.5-2.5 4-2.5 6.5h2.5zM16.5 11v8.5'),
++ paste: '<rect x="6" y="6" width="12" height="14" rx="1.8"/>' + P('M9.5 6V5a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1M9 11h6M9 14h6M9 17h3'),
++ account: '<circle cx="12" cy="9" r="3.2"/>' + P('M5.5 19a6.5 6.5 0 0 1 13 0'),
++ bell: P('M6.5 16.5h11l-1.2-2V11a4.3 4.3 0 0 0-8.6 0v3.5zM10.5 19a1.6 1.6 0 0 0 3 0'),
++ history: '<circle cx="12" cy="12" r="7.5"/>' + P('M12 8v4.2l2.8 1.8'),
++ theme: P('M12 4.5a7.5 7.5 0 1 0 0 15c1 0 1.6-.8 1.2-1.7-.5-1.1.3-2.3 1.5-2.3h1.6a3.2 3.2 0 0 0 3.2-3.2C19.5 8 16.2 4.5 12 4.5z') + '<circle cx="8.5" cy="11" r=".7"/><circle cx="11.5" cy="8" r=".7"/><circle cx="15" cy="9" r=".7"/>',
++ check: P('M6 12.5l4 4 8-9'),
++ error: P('M12 7v6M12 16.5h.01'),
++ clip: P('M6 6l12 12M6 18L18 6') ,
++ };
++ const ALIASES = { yt: 'youtube', net: 'dns', dev: 'code', img: 'image', sound: 'audio', document: 'pdf', pastes: 'paste', openre: 'stream', maps: 'map', user: 'account', notifications: 'bell' };
++ const registry = Object.create(null);
++ for (const [k, g] of Object.entries(GLYPHS)) registry[k] = { glyph: g };
++
++ const CSS = `
++.ov-icon{--ovi-size:32px;display:inline-grid;place-items:center;width:var(--ovi-size);height:var(--ovi-size);flex:none;vertical-align:middle;line-height:0;color:var(--ovi-accent,var(--accent,#3b82f6));position:relative}
++.ov-icon svg{width:100%;height:100%;overflow:visible;display:block}
++.ov-icon .ovi-bg{fill:currentColor;opacity:.1;transition:opacity .25s}
++.ov-icon .ovi-ring{fill:none;stroke:currentColor;stroke-width:2.4;opacity:.26}
++.ov-icon .ovi-comet{fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round;stroke-dasharray:26 100;transform-origin:24px 24px;animation:oviSpin 3.6s linear infinite}
++.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: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}
++.ov-icon[data-fx=pulse] .ovi-comet{display:none}
++.ov-icon[data-fx=pulse] .ovi-bg{animation:oviPulse 2s ease-in-out infinite;transform-origin:24px 24px}
++.ov-icon[data-fx=draw] .ovi-glyph path{stroke-dasharray:80;animation:oviDraw 1.2s cubic-bezier(.2,.8,.2,1) both}
++.ov-icon[data-fx=bounce] svg{animation:oviBounce 1.8s cubic-bezier(.3,1.4,.4,1) infinite}
++.ov-icon[data-progress] .ovi-prog{opacity:1}
++.ov-icon[data-progress] .ovi-comet{opacity:0}
++.ov-icon[data-state=busy] .ovi-comet{animation-duration:1.1s;opacity:1;stroke-dasharray:40 86}
++.ov-icon[data-state=busy][data-progress] .ovi-comet{opacity:.35}
++.ov-icon[data-state=ok]{--ovi-accent:var(--success,#22c55e)}
++.ov-icon[data-state=error]{--ovi-accent:var(--danger,#ef4444)}
++.ov-icon[data-state=ok] .ovi-comet,.ov-icon[data-state=error] .ovi-comet{display:none}
++.ov-icon[data-state=ok] .ovi-ring,.ov-icon[data-state=error] .ovi-ring{opacity:.9}
++.ov-icon[data-state=ok] svg{animation:oviPop .5s cubic-bezier(.2,1.6,.3,1)}
++.ov-icon[data-state=error] svg{animation:oviShake .4s ease}
++@keyframes oviSpin{to{transform:rotate(360deg)}}
++@keyframes oviPulse{0%,100%{transform:scale(.9);opacity:.08}50%{transform:scale(1.06);opacity:.22}}
++@keyframes oviDraw{from{stroke-dashoffset:80}to{stroke-dashoffset:0}}
++@keyframes oviBounce{0%,100%{transform:translateY(0)}50%{transform:translateY(-6%)}}
++@keyframes oviPop{0%{transform:scale(.8)}60%{transform:scale(1.12)}100%{transform:scale(1)}}
++@keyframes oviShake{0%,100%{transform:translateX(0)}25%{transform:translateX(-6%)}75%{transform:translateX(6%)}}
++@media (prefers-reduced-motion:reduce){.ov-icon *, .ov-icon svg{animation:none!important;transition:none!important}.ov-icon .ovi-comet{display:none}}`;
++
++ function resolve(name) {
++ const n = String(name || 'ov').toLowerCase();
++ return registry[n] ? n : (registry[ALIASES[n]] ? ALIASES[n] : 'ov');
++ }
++
++ /** Markup for one icon (usable server-side: no DOM needed). Glyph is scaled 24→22 and centred in the 48 box. */
++ function svg(name, size) {
++ const def = registry[resolve(name)];
++ const s = size ? ` width="${size}" height="${size}"` : '';
++ return `<svg viewBox="0 0 48 48"${s} aria-hidden="true" focusable="false"><circle class="ovi-bg" cx="24" cy="24" r="21"/><circle class="ovi-ring" cx="24" cy="24" r="20"/><circle class="ovi-comet" cx="24" cy="24" r="20"/><circle class="ovi-prog" cx="24" cy="24" r="20"/><g class="ovi-glyph" transform="translate(13 13) scale(.9167)">${def.glyph}</g></svg>`;
++ }
++
++ function applyProgress(el) {
++ const raw = el.getAttribute('data-progress');
++ const prog = el.querySelector('.ovi-prog');
++ if (!prog) return;
++ if (raw == null || raw === '') { prog.style.strokeDashoffset = ''; return; }
++ const p = Math.max(0, Math.min(1, parseFloat(raw) || 0));
++ prog.style.strokeDashoffset = String(125.7 * (1 - p));
++ }
++
++ function mountOne(el) {
++ const name = resolve(el.getAttribute('data-icon'));
++ if (el.getAttribute('data-ovi') === name) { applyProgress(el); return; }
++ el.setAttribute('data-ovi', name);
++ const size = parseInt(el.getAttribute('data-size'), 10);
++ if (size) el.style.setProperty('--ovi-size', size + 'px');
++ const def = registry[name];
++ if (def.accent) el.style.setProperty('--ovi-accent', def.accent);
++ if (def.fx && !el.hasAttribute('data-fx')) el.setAttribute('data-fx', def.fx);
++ el.innerHTML = svg(name);
++ if (!el.hasAttribute('role')) el.setAttribute('aria-hidden', 'true');
++ applyProgress(el);
++ }
++
++ function mount(rootEl) { (rootEl || document).querySelectorAll('.ov-icon').forEach(mountOne); }
++
++ function set(el, patch) {
++ if (!el || !patch) return;
++ if (patch.icon) el.setAttribute('data-icon', patch.icon);
++ if ('state' in patch) { if (patch.state) el.setAttribute('data-state', patch.state); else el.removeAttribute('data-state'); }
++ if ('progress' in patch) { if (patch.progress == null) el.removeAttribute('data-progress'); else el.setAttribute('data-progress', String(patch.progress)); }
++ mountOne(el);
++ }
++
++ function register(name, def) {
++ if (!name || !def || !def.glyph) return;
++ registry[String(name).toLowerCase()] = { glyph: def.glyph, fx: def.fx || null, accent: def.accent || null };
++ if (typeof document !== 'undefined') document.querySelectorAll(`.ov-icon[data-icon="${name}"]`).forEach((el) => { el.removeAttribute('data-ovi'); mountOne(el); });
++ }
++
++ const api = { names: () => Object.keys(registry), register, mount, set, svg, resolve, CSS };
++ root.OpenVibeIcons = api;
++ if (typeof module !== 'undefined' && module.exports) module.exports = api;
++ if (typeof document === 'undefined') return;
++
++ function init() {
++ if (!document.getElementById('ov-icons-css')) { const st = document.createElement('style'); st.id = 'ov-icons-css'; st.textContent = CSS; document.head.appendChild(st); }
++ mount();
++ try {
++ new MutationObserver((muts) => {
++ for (const m of muts) {
++ if (m.type === 'attributes') { if (m.target.classList && m.target.classList.contains('ov-icon')) mountOne(m.target); continue; }
++ m.addedNodes.forEach((n) => { if (n.nodeType !== 1) return; if (n.classList.contains('ov-icon')) mountOne(n); else if (n.querySelector) n.querySelectorAll('.ov-icon').forEach(mountOne); });
++ }
++ }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-icon', 'data-progress'] });
++ } catch { /* */ }
++ }
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
++})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/vendor/openvibe-shared/ov-mark.js b/vendor/openvibe-shared/ov-mark.js
nnew file mode 100644
iindex 0000000..3bcf4a9
---- /dev/null
++++ b/vendor/openvibe-shared/ov-mark.js
@@@ -0,0 +1,91 @@
++/**
++ * 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}
++.ov-mark .p{fill:none;stroke:currentColor;stroke-width:4.4;stroke-linecap:round;transform:rotate(-90deg);transform-box:fill-box;transform-origin:center;stroke-dasharray:113.1;stroke-dashoffset:calc(113.1px * (1 - var(--ovm-p,0)));opacity:0;transition:stroke-dashoffset .35s cubic-bezier(.2,.8,.2,1),opacity .2s}
++.ov-mark[data-progress] .p{opacity:1}
++.ov-mark[data-progress] .c,.ov-mark[data-progress] .o{opacity:.25}
++.ov-mark[data-state=busy] .c{animation-duration:1.1s}
++.ov-mark[data-state=ok]{color:var(--success,#22c55e)!important}
++.ov-mark[data-state=error]{color:var(--danger,#ef4444)!important}
++.ov-mark[data-state=ok] svg{animation:ovmSpin .9s cubic-bezier(.2,1.5,.3,1) 1}
++@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)"/>
++<circle class="p" cx="24" cy="24" r="18"/>
++<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/package.json b/vendor/openvibe-shared/package.json
iindex 9f56460..b63c7c2 100644
---- a/vendor/openvibe-shared/package.json
++++ b/vendor/openvibe-shared/package.json
@@@ -11,7 +11,9 @@
"./brand": "./brand.js",
"./middleware": "./middleware.js",
"./notifications": "./notifications.js",
-- "./url-resolver": "./url-resolver.js"
++ "./url-resolver": "./url-resolver.js",
++ "./seo": "./seo.js",
++ "./icons": "./ov-icons.js"
},
"license": "MIT",
"dependencies": {
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 0000000..1e10ea1
---- /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)`);
ddiff --git a/vendor/openvibe-shared/seo.js b/vendor/openvibe-shared/seo.js
nnew file mode 100644
iindex 0000000..b3da096
---- /dev/null
++++ b/vendor/openvibe-shared/seo.js
@@@ -0,0 +1,174 @@
++'use strict';
++/**
++ * openvibe-shared/seo.js — one SEO toolkit for every OpenVibe site (Node, no dependencies).
++ *
++ * const seo = require('openvibe-shared/seo');
++ * seo.headTags({ title, description, canonical, image, jsonLd: [seo.jsonLd.website({...})] })
++ * seo.sitemapXml([{ loc, lastmod }]) seo.robotsTxt({ sitemaps }) seo.llmsTxt({...})
++ *
++ * Rules it encodes so pages do not have to remember them: one canonical per page, titles cut at
++ * 60 characters on a word, descriptions at 160, og/twitter mirrored from the same values, JSON-LD
++ * serialised safely (no `</script>` break-outs), sitemaps with absolute URLs only, robots.txt that
++ * welcomes search and AI crawlers by default and always names the sitemap, and an /llms.txt so
++ * language-model crawlers get a clean map of the site.
++ */
++
++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++const xml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' }[c]));
++
++/** Cut on a word boundary with an ellipsis; never mid-word, never longer than `max`. */
++function clip(text, max) {
++ const t = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
++ if (t.length <= max) return t;
++ const cut = t.slice(0, max - 1);
++ const at = cut.lastIndexOf(' ');
++ return (at > max * 0.6 ? cut.slice(0, at) : cut).replace(/[\s,;:.\-–—]+$/, '') + '…';
++}
++
++function absolute(url, base) {
++ if (!url) return '';
++ try { return new URL(url, base || undefined).toString(); } catch { return String(url); }
++}
++
++/** JSON-LD script tag; `<` is escaped so content can never close the script element. */
++function jsonLdTag(obj) {
++ return `<script type="application/ld+json">${JSON.stringify(obj).replace(/</g, '\\u003c')}</script>`;
++}
++
++/**
++ * The whole <head> SEO block as an HTML string.
++ * @param {object} o title, description, canonical (absolute), image, imageAlt, type ('website'),
++ * siteName, robots ('index, follow'), locale ('en_US'), keywords (array|string), author,
++ * themeColor, twitterSite, alternates [{ hreflang, href }], prev, next, jsonLd (array|object),
++ * titleSuffix (appended when it fits), noTitle (skip <title> when the page has its own)
++ */
++function headTags(o = {}) {
++ let title = String(o.title || o.siteName || 'OpenVibe');
++ if (o.titleSuffix && (title + o.titleSuffix).length <= 60) title += o.titleSuffix;
++ title = clip(title, 60);
++ const description = clip(o.description || '', 160);
++ const canonical = absolute(o.canonical);
++ const image = o.image ? absolute(o.image, canonical) : '';
++ const keywords = Array.isArray(o.keywords) ? o.keywords.join(', ') : (o.keywords || '');
++ const robots = o.robots || 'index, follow, max-image-preview:large, max-snippet:-1';
++ const out = [];
++ if (!o.noTitle) out.push(`<title>${esc(title)}</title>`);
++ if (description) out.push(`<meta name="description" content="${esc(description)}">`);
++ if (keywords) out.push(`<meta name="keywords" content="${esc(keywords)}">`);
++ out.push(`<meta name="robots" content="${esc(robots)}">`);
++ if (o.author) out.push(`<meta name="author" content="${esc(o.author)}">`);
++ if (o.themeColor) out.push(`<meta name="theme-color" content="${esc(o.themeColor)}">`);
++ if (canonical) out.push(`<link rel="canonical" href="${esc(canonical)}">`);
++ for (const a of o.alternates || []) if (a && a.href) out.push(`<link rel="alternate" hreflang="${esc(a.hreflang || 'x-default')}" href="${esc(absolute(a.href))}">`);
++ if (o.prev) out.push(`<link rel="prev" href="${esc(absolute(o.prev))}">`);
++ if (o.next) out.push(`<link rel="next" href="${esc(absolute(o.next))}">`);
++ out.push(`<meta property="og:type" content="${esc(o.type || 'website')}">`);
++ if (o.siteName) out.push(`<meta property="og:site_name" content="${esc(o.siteName)}">`);
++ out.push(`<meta property="og:title" content="${esc(title)}">`);
++ if (description) out.push(`<meta property="og:description" content="${esc(description)}">`);
++ if (canonical) out.push(`<meta property="og:url" content="${esc(canonical)}">`);
++ out.push(`<meta property="og:locale" content="${esc(o.locale || 'en_US')}">`);
++ if (image) {
++ out.push(`<meta property="og:image" content="${esc(image)}">`);
++ if (o.imageAlt) out.push(`<meta property="og:image:alt" content="${esc(o.imageAlt)}">`);
++ }
++ out.push(`<meta name="twitter:card" content="${image && o.largeImage !== false ? 'summary_large_image' : 'summary'}">`);
++ if (o.twitterSite) out.push(`<meta name="twitter:site" content="${esc(o.twitterSite)}">`);
++ out.push(`<meta name="twitter:title" content="${esc(title)}">`);
++ if (description) out.push(`<meta name="twitter:description" content="${esc(description)}">`);
++ if (image) out.push(`<meta name="twitter:image" content="${esc(image)}">`);
++ const ld = o.jsonLd ? (Array.isArray(o.jsonLd) ? o.jsonLd : [o.jsonLd]) : [];
++ for (const item of ld) if (item) out.push(jsonLdTag(item));
++ return out.join('\n');
++}
++
++const ORG = { '@type': 'Organization', '@id': 'https://openvibe.network/#org', name: 'OpenVibe', url: 'https://openvibe.network/', logo: 'https://openvibe.network/assets/logo-512.png', sameAs: ['https://github.com/OpenVibers', 'https://discord.gg/M6MuRUaeJj'] };
++
++const jsonLd = {
++ organization(extra = {}) { return { '@context': 'https://schema.org', ...ORG, ...extra }; },
++ /** WebSite; `searchUrl` with {q} adds the sitelinks SearchAction. */
++ website({ name, url, description, searchUrl, inLanguage = 'en' } = {}) {
++ const o = { '@context': 'https://schema.org', '@type': 'WebSite', '@id': `${url}#site`, name, url, description, inLanguage, publisher: { '@id': ORG['@id'] } };
++ if (searchUrl) o.potentialAction = { '@type': 'SearchAction', target: { '@type': 'EntryPoint', urlTemplate: searchUrl.replace('{q}', '{search_term_string}') }, 'query-input': 'required name=search_term_string' };
++ return o;
++ },
++ webPage({ name, url, description, type = 'WebPage', siteUrl, dateModified } = {}) {
++ return { '@context': 'https://schema.org', '@type': type, '@id': `${url}#page`, name, url, description, ...(siteUrl ? { isPartOf: { '@id': `${siteUrl}#site` } } : {}), ...(dateModified ? { dateModified } : {}) };
++ },
++ /** A web tool. No price/offer fields: the platform makes no cost claims. */
++ softwareApp({ name, url, description, category = 'UtilitiesApplication', keywords, image, os = 'Any (web browser)' } = {}) {
++ return { '@context': 'https://schema.org', '@type': 'WebApplication', name, url, description, applicationCategory: category, operatingSystem: os, browserRequirements: 'Requires a modern web browser', ...(keywords ? { keywords: Array.isArray(keywords) ? keywords.join(', ') : keywords } : {}), ...(image ? { image } : {}), publisher: { '@id': ORG['@id'] } };
++ },
++ breadcrumbs(items = []) {
++ return { '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: items.map((it, i) => ({ '@type': 'ListItem', position: i + 1, name: it.name, item: it.url })) };
++ },
++ itemList(name, items = [], { url } = {}) {
++ return { '@context': 'https://schema.org', '@type': 'ItemList', name, ...(url ? { url } : {}), numberOfItems: items.length, itemListElement: items.map((it, i) => ({ '@type': 'ListItem', position: i + 1, name: it.name, url: it.url, ...(it.description ? { description: it.description } : {}) })) };
++ },
++ faq(pairs = []) {
++ return { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: pairs.map((p) => ({ '@type': 'Question', name: p.q, acceptedAnswer: { '@type': 'Answer', text: p.a } })) };
++ },
++ howTo({ name, description, steps = [] } = {}) {
++ return { '@context': 'https://schema.org', '@type': 'HowTo', name, description, step: steps.map((s, i) => ({ '@type': 'HowToStep', position: i + 1, name: s.name || `Step ${i + 1}`, text: s.text })) };
++ },
++ article({ headline, url, description, author, datePublished, dateModified, image } = {}) {
++ return { '@context': 'https://schema.org', '@type': 'Article', headline: clip(headline, 110), url, description, ...(author ? { author: { '@type': 'Person', name: author } } : {}), ...(datePublished ? { datePublished } : {}), ...(dateModified ? { dateModified } : {}), ...(image ? { image } : {}), publisher: { '@id': ORG['@id'] } };
++ },
++ video({ name, url, description, thumbnailUrl, uploadDate, duration, contentUrl, embedUrl } = {}) {
++ return { '@context': 'https://schema.org', '@type': 'VideoObject', name, url, description: description || name, thumbnailUrl, uploadDate, ...(duration ? { duration } : {}), ...(contentUrl ? { contentUrl } : {}), ...(embedUrl ? { embedUrl } : {}) };
++ },
++};
++
++/** urls: [{ loc, lastmod?, changefreq?, priority?, alternates?: [{hreflang, href}], images?: [url] }] — absolute http(s) only. */
++function sitemapXml(urls = []) {
++ const rows = urls.filter((u) => u && /^https?:\/\//i.test(u.loc)).map((u) => {
++ const parts = [`<loc>${xml(u.loc)}</loc>`];
++ if (u.lastmod) parts.push(`<lastmod>${xml(String(u.lastmod).slice(0, 10))}</lastmod>`);
++ if (u.changefreq) parts.push(`<changefreq>${xml(u.changefreq)}</changefreq>`);
++ if (u.priority != null) parts.push(`<priority>${Number(u.priority).toFixed(1)}</priority>`);
++ for (const a of u.alternates || []) parts.push(`<xhtml:link rel="alternate" hreflang="${xml(a.hreflang)}" href="${xml(a.href)}"/>`);
++ for (const img of u.images || []) parts.push(`<image:image><image:loc>${xml(img)}</image:loc></image:image>`);
++ return ` <url>${parts.join('')}</url>`;
++ });
++ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">\n${rows.join('\n')}\n</urlset>\n`;
++}
++
++function sitemapIndexXml(maps = []) {
++ const rows = maps.filter((m) => m && m.loc).map((m) => ` <sitemap><loc>${xml(m.loc)}</loc>${m.lastmod ? `<lastmod>${xml(String(m.lastmod).slice(0, 10))}</lastmod>` : ''}</sitemap>`);
++ return `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${rows.join('\n')}\n</sitemapindex>\n`;
++}
++
++// Crawlers that build search and AI indexes — welcomed by name so a generic Disallow never hits them.
++const AI_AND_SEARCH_BOTS = ['Googlebot', 'Bingbot', 'DuckDuckBot', 'Applebot', 'GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'ClaudeBot', 'Claude-Web', 'anthropic-ai', 'PerplexityBot', 'CCBot', 'Google-Extended', 'Amazonbot', 'meta-externalagent', 'YouBot', 'Bytespider'];
++
++function robotsTxt({ sitemaps = [], disallow = [], allow = ['/'], allowAI = true, block = [], crawlDelay } = {}) {
++ const lines = ['User-agent: *'];
++ for (const a of allow) lines.push(`Allow: ${a}`);
++ for (const d of disallow) lines.push(`Disallow: ${d}`);
++ if (crawlDelay) lines.push(`Crawl-delay: ${crawlDelay}`);
++ if (allowAI) {
++ lines.push('');
++ for (const bot of AI_AND_SEARCH_BOTS) lines.push(`User-agent: ${bot}`);
++ lines.push('Allow: /');
++ for (const d of disallow) lines.push(`Disallow: ${d}`);
++ }
++ for (const b of block) { lines.push('', `User-agent: ${b}`, 'Disallow: /'); }
++ lines.push('');
++ for (const s of sitemaps) lines.push(`Sitemap: ${s}`);
++ return lines.join('\n') + '\n';
++}
++
++/** /llms.txt (llmstxt.org): a markdown map of the site for language-model crawlers. */
++function llmsTxt({ name, summary, details, sections = [] } = {}) {
++ const out = [`# ${name}`, '', `> ${String(summary || '').replace(/\s+/g, ' ').trim()}`, ''];
++ if (details) out.push(String(details).trim(), '');
++ for (const sec of sections) {
++ if (!sec || !sec.links || !sec.links.length) continue;
++ out.push(`## ${sec.title}`, '');
++ for (const l of sec.links) out.push(`- [${l.title}](${l.url})${l.note ? `: ${String(l.note).replace(/\s+/g, ' ').trim()}` : ''}`);
++ out.push('');
++ }
++ return out.join('\n');
++}
++
++module.exports = { headTags, jsonLd, jsonLdTag, sitemapXml, sitemapIndexXml, robotsTxt, llmsTxt, clip, esc, absolute, AI_AND_SEARCH_BOTS };
ddiff --git a/vendor/openvibe-shared/sso-client.js b/vendor/openvibe-shared/sso-client.js
nnew file mode 100644
iindex 0000000..50fed6f
---- /dev/null
++++ b/vendor/openvibe-shared/sso-client.js
@@@ -0,0 +1,117 @@
++/**
++ * openvibe-shared/sso-client.js — the pieces that make one sign-in feel like one sign-in
++ * across separate domains. Loaded lazily by navbar.js; Live loads it directly.
++ *
++ * OpenVibeSSO.handoffLinks({ signedIn }) cross-site links carry the session along
++ * OpenVibeSSO.fedcm({ apiBase, fedcmLogin, mediation }) browser-native sign-in (Chrome/Edge)
++ * OpenVibeSSO.loginUrlFor(href) the silent-login URL that lands on `href`
++ *
++ * Link hand-off: when this page has a session and a link points at another OpenVibe site, the
++ * navigation goes through that site's silent sign-in (`…/auth/login?silent=1&next=<link>`).
++ * It is a top-level navigation, so it works in every browser with no third-party cookies, and
++ * the target answers straight from its own session when it already has one. Sites that share a
++ * cookie domain (openvibe.tools and its subdomains) are one site here. Links can opt out with
++ * data-ov-nohandoff.
++ *
++ * FedCM: `navigator.credentials.get({ identity })` — the browser itself asks openvibe.network
++ * for an assertion (a native chip the first time per site, silent auto re-auth after that) and
++ * the page posts it to the site's /auth/fedcm, which exchanges it server-side for a session.
++ * Never called for browsers the network has told "logged-out", so guests see nothing.
++ */
++(function (root) {
++ 'use strict';
++ if (root.OpenVibeSSO) return;
++
++ const NETWORK = 'https://openvibe.network';
++ // Site → where its silent sign-in lives. Hosts sharing a session cookie share an entry.
++ const SITES = [
++ { test: (h) => h === 'openvibe.live' || h.endsWith('.openvibe.live'), key: 'live', login: 'https://openvibe.live/api/auth/sso/login?silent=1&next={url}', fedcm: 'https://openvibe.live/api/auth/fedcm' },
++ { test: (h) => h === 'openvibe.tools' || h.endsWith('.openvibe.tools'), key: 'tools', login: 'https://openvibe.tools/auth/login?silent=1&next={url}', fedcm: 'https://openvibe.tools/auth/fedcm' },
++ { test: (h) => h === 'openvibe.games' || h.endsWith('.openvibe.games'), key: 'games', login: 'https://openvibe.games/auth/login?silent=1&next={url}', fedcm: '/auth/fedcm' },
++ { test: (h) => h === 'openvibe.community' || h.endsWith('.openvibe.community'), key: 'community', login: 'https://openvibe.community/auth/login?silent=1&next={url}', fedcm: '/auth/fedcm' },
++ ];
++ const siteFor = (host) => SITES.find(s => s.test(String(host || '').toLowerCase())) || null;
++ const here = () => (typeof location !== 'undefined' ? siteFor(location.hostname) : null);
++
++ /** The URL that signs the browser in on the link's site and lands on the link, or null. */
++ function loginUrlFor(href) {
++ let u; try { u = new URL(href, location.href); } catch { return null; }
++ if (u.protocol !== 'https:') return null;
++ const target = siteFor(u.hostname);
++ if (!target) return null;
++ const mine = here();
++ if (mine && mine.key === target.key) return null; // same session cookie already
++ return target.login.replace('{url}', encodeURIComponent(u.toString()));
++ }
++
++ let _handoffOn = false, _signedIn = false;
++ function rewrite(a) {
++ if (!a || a.dataset.ovHandoff || a.hasAttribute('data-ov-nohandoff') || !a.getAttribute('href')) return;
++ const url = loginUrlFor(a.href);
++ if (!url) return;
++ a.dataset.ovHandoff = a.href;
++ a.href = url;
++ }
++ function onIntent(e) {
++ if (!_signedIn) return;
++ const a = e.target && e.target.closest ? e.target.closest('a[href]') : null;
++ if (a) rewrite(a);
++ }
++ /** Turn cross-site links into session hand-offs while signed in (idempotent; call again to update). */
++ function handoffLinks(opts) {
++ _signedIn = !!(opts && opts.signedIn);
++ if (_handoffOn) return;
++ _handoffOn = true;
++ // Rewrite just before the navigation can start: pointer/keyboard intent, and touch.
++ for (const ev of ['pointerdown', 'touchstart', 'focusin', 'auxclick']) document.addEventListener(ev, onIntent, { capture: true, passive: true });
++ document.addEventListener('click', onIntent, true);
++ document.addEventListener('contextmenu', (e) => {
++ // A copied link should stay a plain link.
++ const a = e.target && e.target.closest ? e.target.closest('a[data-ov-handoff]') : null;
++ if (a) { a.href = a.dataset.ovHandoff; delete a.dataset.ovHandoff; }
++ }, true);
++ }
++
++ /** FedCM availability: the API exists and this is a top-level, secure document. */
++ function fedcmAvailable() {
++ try { return typeof navigator !== 'undefined' && 'IdentityCredential' in root && root.isSecureContext && root.top === root; } catch { return false; }
++ }
++
++ /**
++ * Ask the browser for a FedCM assertion from openvibe.network and turn it into a session here.
++ * Resolves { ok, user } or { ok: false, reason }. mediation 'silent' = only auto re-auth
++ * (previously approved on this site), 'optional' = show the chip the first time.
++ */
++ async function fedcm(opts) {
++ const o = Object.assign({ apiBase: NETWORK, mediation: 'optional', timeoutMs: 15000 }, opts || {});
++ if (!fedcmAvailable()) return { ok: false, reason: 'unsupported' };
++ const site = here();
++ const fedcmLogin = o.fedcmLogin || (site && site.fedcm) || '/auth/fedcm';
++ const nonce = Array.from(crypto.getRandomValues(new Uint8Array(16)), b => b.toString(16).padStart(2, '0')).join('');
++ const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), o.timeoutMs);
++ let cred;
++ try {
++ cred = await navigator.credentials.get({
++ identity: { context: 'signin', providers: [{ configURL: `${o.apiBase}/fedcm/config.json`, clientId: location.origin, nonce, params: { nonce } }] },
++ mediation: o.mediation, signal: ctrl.signal,
++ });
++ } catch (err) {
++ clearTimeout(timer);
++ return { ok: false, reason: (err && (err.name || err.message)) || 'declined' };
++ }
++ clearTimeout(timer);
++ if (!cred || !cred.token) return { ok: false, reason: 'no-token' };
++ try {
++ const res = await fetch(fedcmLogin, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: cred.token, nonce }) });
++ const data = await res.json().catch(() => ({}));
++ if (!res.ok) return { ok: false, reason: data.error || `http-${res.status}`, auto: !!cred.isAutoSelected };
++ try { sessionStorage.setItem('ov_sso_hint', 'account'); localStorage.setItem('ov_sso_hint', 'account'); } catch { /* */ }
++ return { ok: true, user: data.user || null, auto: !!cred.isAutoSelected };
++ } catch (err) { return { ok: false, reason: 'network' }; }
++ }
++
++ /** After an explicit sign-out: the browser must not auto re-auth us back in. */
++ function preventSilent() { try { if (navigator.credentials && navigator.credentials.preventSilentAccess) navigator.credentials.preventSilentAccess(); } catch { /* */ } }
++
++ root.OpenVibeSSO = { handoffLinks, loginUrlFor, fedcm, fedcmAvailable, preventSilent, siteFor, SITES };
++})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/vendor/openvibe-shared/tooltip.js b/vendor/openvibe-shared/tooltip.js
nnew file mode 100644
iindex 0000000..21fa61a
---- /dev/null
++++ b/vendor/openvibe-shared/tooltip.js
@@@ -0,0 +1,198 @@
++/**
++ * openvibe-shared/tooltip.js — rich tooltips for every OpenVibe property.
++ *
++ * Markup-driven, so a site never writes tooltip code:
++ *
++ * <a data-ovtip="Plain text tip">…</a>
++ * <a data-ovtip-title="API docs"
++ * data-ovtip="<b>Vibe code it.</b> Point an AI at these docs…"
++ * data-ovtip-icon="fa-wand-magic-sparkles"
++ * data-ovtip-place="top"
++ * data-ovtip-accent="#f472b6">…</a>
++ *
++ * One floating element is reused for every tooltip, positioned with fixed coordinates so it is
++ * never clipped by an overflow:hidden ancestor — the usual reason CSS-only tooltips get cut off
++ * inside cards and scroll containers. It flips to stay on screen, follows the trigger while the
++ * page scrolls, opens on hover, focus and long-press, and closes on Escape, scroll-away or tap
++ * elsewhere. Touch devices get a tap-to-open behaviour instead of hover, which is what makes
++ * these usable on a phone at all.
++ */
++(function () {
++ 'use strict';
++ if (typeof window === 'undefined' || window.OpenVibeTooltip) return;
++
++ const CSS = `
++.ovtip{position:fixed;z-index:30000;max-width:min(330px,calc(100vw - 24px));padding:12px 14px;border-radius:14px;
++ background:linear-gradient(165deg,var(--bg-card,#171826) 0%,var(--bg-secondary,#12131c) 100%);
++ border:1px solid color-mix(in srgb, var(--ovtip-accent,var(--accent,#a78bfa)) 45%, var(--border,rgba(255,255,255,.1)));
++ box-shadow:0 20px 50px rgba(0,0,0,.55),0 0 0 1px rgba(255,255,255,.04),0 0 30px color-mix(in srgb, var(--ovtip-accent,var(--accent,#a78bfa)) 22%, transparent);
++ color:var(--text-secondary,#b6bdd2);font-size:.83rem;line-height:1.55;pointer-events:none;opacity:0;
++ transform:translateY(6px) scale(.97);transition:opacity .18s ease,transform .22s cubic-bezier(.2,1.3,.3,1);
++ overflow:hidden}
++.ovtip.is-on{opacity:1;transform:none}
++.ovtip.is-interactive{pointer-events:auto}
++.ovtip::before{content:'';position:absolute;inset:0;pointer-events:none;
++ background:radial-gradient(120% 90% at 0% 0%, color-mix(in srgb, var(--ovtip-accent,var(--accent,#a78bfa)) 16%, transparent), transparent 60%)}
++/* A light sweep on open — draws the eye without moving anything around it. */
++.ovtip::after{content:'';position:absolute;top:0;bottom:0;left:-60%;width:45%;pointer-events:none;
++ background:linear-gradient(100deg,transparent,color-mix(in srgb, var(--ovtip-accent,var(--accent,#a78bfa)) 26%, transparent),transparent)}
++.ovtip.is-on::after{animation:ovtipSweep .9s cubic-bezier(.3,.7,.3,1) .1s 1}
++@keyframes ovtipSweep{to{transform:translateX(420%)}}
++.ovtip-head{display:flex;align-items:center;gap:8px;margin-bottom:6px;position:relative}
++.ovtip-ico{width:26px;height:26px;border-radius:8px;display:grid;place-items:center;flex:none;font-size:.78rem;color:#fff;
++ background:linear-gradient(135deg,var(--ovtip-accent,var(--accent,#a78bfa)),color-mix(in srgb,var(--ovtip-accent,var(--accent,#a78bfa)) 55%,#000));
++ box-shadow:0 4px 12px color-mix(in srgb, var(--ovtip-accent,var(--accent,#a78bfa)) 45%, transparent)}
++.ovtip-title{font-size:.9rem;font-weight:800;color:var(--text-primary,#f1f4fb);letter-spacing:-.01em}
++.ovtip-body{position:relative}
++.ovtip-body b,.ovtip-body strong{color:var(--text-primary,#f1f4fb);font-weight:700}
++.ovtip-body em{color:var(--ovtip-accent,var(--accent,#a78bfa));font-style:normal;font-weight:600}
++.ovtip-body code{background:var(--bg-tertiary,#1a1b28);padding:1px 6px;border-radius:5px;font-size:.78rem}
++.ovtip-foot{margin-top:8px;padding-top:8px;border-top:1px dashed var(--border,rgba(255,255,255,.1));
++ font-size:.74rem;color:var(--text-muted,#8b93ad);display:flex;align-items:center;gap:6px;position:relative}
++.ovtip-arrow{position:fixed;z-index:30001;width:10px;height:10px;transform:rotate(45deg);opacity:0;transition:opacity .18s;
++ background:var(--bg-card,#171826);border:1px solid color-mix(in srgb, var(--ovtip-accent,var(--accent,#a78bfa)) 45%, var(--border,rgba(255,255,255,.1)))}
++.ovtip-arrow.is-on{opacity:1}
++.ovtip-arrow.from-top{border-top:0;border-left:0}
++.ovtip-arrow.from-bottom{border-bottom:0;border-right:0}
++.ovtip-actions{display:flex;gap:8px;margin-top:11px;padding-top:10px;border-top:1px solid var(--border,rgba(255,255,255,.1))}
++.ovtip-go,.ovtip-dismiss{flex:1;display:inline-flex;align-items:center;justify-content:center;gap:7px;
++ 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)}
++.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}
++/* On a phone a 330px tip pinned beside a button is unreadable and often half off-screen. Dock it
++ to the bottom of the viewport as a full-width card instead. */
++@media (hover:none){
++ .ovtip{position:fixed;left:12px!important;right:12px;top:auto!important;bottom:calc(14px + env(safe-area-inset-bottom,0px));
++ max-width:none;width:auto;padding:14px 16px;border-radius:18px;font-size:.88rem;
++ transform:translateY(14px) scale(1);box-shadow:0 -10px 40px rgba(0,0,0,.55),0 0 0 1px rgba(255,255,255,.05)}
++ .ovtip.is-on{transform:none}
++ .ovtip-arrow{display:none}
++ .ovtip-title{font-size:1rem}
++}
++@media (prefers-reduced-motion:reduce){.ovtip{transition:opacity .12s}.ovtip.is-on::after{animation:none}}`;
++
++ let tip = null, arrow = null, current = null, raf = 0, hideTimer = 0;
++ const isTouch = () => window.matchMedia('(hover: none)').matches;
++
++ function ensure() {
++ if (tip) return;
++ const st = document.createElement('style'); st.id = 'ovtip-css'; st.textContent = CSS; document.head.appendChild(st);
++ tip = document.createElement('div'); tip.className = 'ovtip'; tip.setAttribute('role', 'tooltip');
++ arrow = document.createElement('div'); arrow.className = 'ovtip-arrow';
++ document.body.append(arrow, tip);
++ tip.addEventListener('pointerenter', () => clearTimeout(hideTimer));
++ tip.addEventListener('pointerleave', () => hide());
++ }
++
++ function content(el) {
++ const title = el.getAttribute('data-ovtip-title');
++ const icon = el.getAttribute('data-ovtip-icon');
++ const body = el.getAttribute('data-ovtip') || '';
++ const foot = el.getAttribute('data-ovtip-foot');
++ // On touch the first tap opens the tip and swallows the click, so without an explicit
++ // action the visitor is left tapping a button that appears to do nothing. Give them the
++ // destination as a real button inside the tip — and a way out that isn't "tap elsewhere".
++ const act = isTouch() ? `<div class="ovtip-actions">
++ <button type="button" class="ovtip-dismiss" data-ovtip-dismiss>Got it</button>
++ <button type="button" class="ovtip-go" data-ovtip-go>${esc(el.getAttribute('data-ovtip-go') || 'Open')}<i class="fa-solid fa-arrow-right"></i></button>
++ </div>` : '';
++ return `${title ? `<div class="ovtip-head">${icon ? `<span class="ovtip-ico"><i class="fa-solid ${icon}"></i></span>` : ''}<span class="ovtip-title">${title}</span></div>` : ''}
++ <div class="ovtip-body">${body}</div>
++ ${foot ? `<div class="ovtip-foot">${foot}</div>` : ''}${act}`;
++ }
++ const esc = (v) => String(v == null ? '' : v).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++
++ function place() {
++ if (!current || !tip) return;
++ if (!current.isConnected) return hide();
++ // Docked to the bottom on touch — CSS owns the position, and following the trigger would
++ // fight it (and hide the tip the moment the page scrolls a little).
++ if (isTouch()) { arrow.classList.remove('is-on'); return; }
++ const r = current.getBoundingClientRect();
++ // Off-screen trigger: nothing to point at.
++ if (r.bottom < -40 || r.top > window.innerHeight + 40) return hide();
++ const tw = tip.offsetWidth, th = tip.offsetHeight, gap = 12;
++ const prefer = current.getAttribute('data-ovtip-place') || 'top';
++ let top, from;
++ const fitsAbove = r.top - th - gap > 8;
++ const fitsBelow = r.bottom + th + gap < window.innerHeight - 8;
++ if ((prefer === 'top' && fitsAbove) || (!fitsBelow && fitsAbove)) { top = r.top - th - gap; from = 'from-bottom'; }
++ else { top = r.bottom + gap; from = 'from-top'; }
++ let left = r.left + r.width / 2 - tw / 2;
++ left = Math.max(12, Math.min(left, window.innerWidth - tw - 12));
++ tip.style.top = `${Math.round(top)}px`;
++ tip.style.left = `${Math.round(left)}px`;
++ const ax = Math.max(left + 14, Math.min(r.left + r.width / 2 - 5, left + tw - 24));
++ arrow.className = `ovtip-arrow is-on ${from}`;
++ arrow.style.left = `${Math.round(ax)}px`;
++ arrow.style.top = `${Math.round(from === 'from-top' ? top - 5 : top + th - 5)}px`;
++ raf = requestAnimationFrame(place);
++ }
++
++ function show(el) {
++ ensure();
++ clearTimeout(hideTimer);
++ if (current === el && tip.classList.contains('is-on')) return;
++ current = el;
++ tip.style.setProperty('--ovtip-accent', el.getAttribute('data-ovtip-accent') || '');
++ arrow.style.setProperty('--ovtip-accent', el.getAttribute('data-ovtip-accent') || '');
++ tip.innerHTML = content(el);
++ tip.classList.toggle('is-interactive', el.hasAttribute('data-ovtip-interactive') || isTouch());
++ tip.classList.remove('is-on'); arrow.classList.remove('is-on');
++ cancelAnimationFrame(raf);
++ place();
++ requestAnimationFrame(() => { tip.classList.add('is-on'); arrow.classList.add('is-on'); });
++ el.setAttribute('aria-describedby', 'ovtip');
++ }
++
++ function hide() {
++ if (!tip) return;
++ cancelAnimationFrame(raf);
++ tip.classList.remove('is-on'); arrow.classList.remove('is-on');
++ if (current) current.removeAttribute('aria-describedby');
++ current = null;
++ }
++ const hideSoon = () => { clearTimeout(hideTimer); hideTimer = setTimeout(hide, 120); };
++
++ function trigger(e) { return e.target.closest && e.target.closest('[data-ovtip]'); }
++
++ function bind() {
++ const fromTouch = (e) => e.pointerType === 'touch' || (!e.pointerType && isTouch());
++ document.addEventListener('pointerover', (e) => { if (fromTouch(e)) return; const t = trigger(e); if (t) show(t); });
++ document.addEventListener('pointerout', (e) => { if (fromTouch(e)) return; if (trigger(e)) hideSoon(); });
++ document.addEventListener('focusin', (e) => { const t = trigger(e); if (t) show(t); });
++ document.addEventListener('focusout', (e) => { if (trigger(e)) hideSoon(); });
++ // Touch: first tap opens the tip, a tap elsewhere closes it. The trigger still works —
++ // we never swallow the click, so a tap on an already-open tip follows the link.
++ document.addEventListener('click', (e) => {
++ const t = trigger(e);
++ if (!t) return hide();
++ // On touch the first tap opens the tip; a second tap follows the link.
++ if (isTouch() && current !== t) { e.preventDefault(); show(t); }
++ }, true);
++ // The touch action buttons.
++ document.addEventListener('click', (e) => {
++ const dismiss = e.target.closest && e.target.closest('[data-ovtip-dismiss]');
++ if (dismiss) { e.preventDefault(); e.stopPropagation(); hide(); return; }
++ const go = e.target.closest && e.target.closest('[data-ovtip-go]');
++ if (go && current) {
++ e.preventDefault(); e.stopPropagation();
++ const target = current;
++ hide();
++ // Replay the trigger's own behaviour: its click handler, then its href.
++ if (typeof target.click === 'function') target.click();
++ }
++ }, true);
++ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hide(); });
++ window.addEventListener('scroll', () => { if (current && isTouch()) hide(); }, { passive: true });
++ window.addEventListener('resize', hide, { passive: true });
++ }
++
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bind); else bind();
++ window.OpenVibeTooltip = { show, hide, ensure };
++})();
ddiff --git a/vendor/openvibe-shared/ui.js b/vendor/openvibe-shared/ui.js
nnew file mode 100644
iindex 0000000..a815f30
---- /dev/null
++++ b/vendor/openvibe-shared/ui.js
@@@ -0,0 +1,124 @@
++/**
++ * openvibe-shared/ui.js — one toast / confirm / alert implementation for every OpenVibe site.
++ *
++ * OpenVibeUI.toast('Saved', { type: 'success' })
++ * OpenVibeUI.toast('Upload failed', { type: 'error', title: 'Images', action: { label: 'Retry', onClick } })
++ * if (await OpenVibeUI.confirm({ title: 'Delete paste?', message: 'This cannot be undone.', danger: true })) …
++ * await OpenVibeUI.alert({ title: 'Heads up', message: '…' })
++ *
++ * Themed from the site's CSS variables, stacked bottom-right (bottom-centre on phones), announced
++ * to assistive tech (role=status, role=alert for errors), pause-on-hover, Escape closes dialogs,
++ * focus is trapped in dialogs and restored afterwards. Text is always set with textContent.
++ */
++(function (root) {
++ 'use strict';
++ if (root.OpenVibeUI) return;
++ if (typeof document === 'undefined') { const noop = { toast() { return { close() {} }; }, confirm: async () => false, alert: async () => {} }; if (typeof module !== 'undefined') module.exports = noop; return; }
++
++ const CSS = `
++.ovui-toasts{position:fixed;right:16px;bottom:16px;z-index:2147483000;display:flex;flex-direction:column;gap:8px;max-width:min(380px,calc(100vw - 24px));pointer-events:none}
++.ovui-toast{pointer-events:auto;display:grid;grid-template-columns:auto 1fr auto;gap:10px;align-items:start;padding:11px 12px;border-radius:12px;background:var(--bg-elevated,var(--bg-secondary,#131a26));color:var(--text-primary,#e6edf7);border:1px solid var(--border,rgba(255,255,255,.1));box-shadow:0 10px 30px rgba(0,0,0,.35);font:500 13.5px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;animation:ovuiIn .28s cubic-bezier(.2,1.2,.3,1);border-left:3px solid var(--ovui-c,var(--accent,#3b82f6))}
++.ovui-toast[data-type=success]{--ovui-c:var(--success,#22c55e)}
++.ovui-toast[data-type=error]{--ovui-c:var(--danger,#ef4444)}
++.ovui-toast[data-type=warning]{--ovui-c:var(--warning,#f59e0b)}
++.ovui-toast.is-out{animation:ovuiOut .2s ease forwards}
++.ovui-dot{width:8px;height:8px;border-radius:50%;background:var(--ovui-c,var(--accent,#3b82f6));margin-top:6px}
++.ovui-title{font-weight:700;margin-bottom:1px}
++.ovui-msg{color:var(--text-secondary,#a8b3c4);overflow-wrap:anywhere}
++.ovui-title+.ovui-msg{font-weight:400}
++.ovui-toast .ovui-msg:first-child{color:inherit}
++.ovui-x,.ovui-act{appearance:none;border:0;background:none;color:inherit;font:inherit;cursor:pointer;border-radius:8px}
++.ovui-x{opacity:.55;padding:2px 6px;line-height:1;font-size:16px}
++.ovui-x:hover{opacity:1}
++.ovui-act{margin-top:6px;padding:4px 10px;font-weight:700;color:var(--accent,#3b82f6);background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent);text-decoration:none;display:inline-block}
++.ovui-back{position:fixed;inset:0;z-index:2147483100;background:rgba(3,6,12,.6);backdrop-filter:blur(3px);display:grid;place-items:center;padding:16px;animation:ovuiFade .15s ease}
++.ovui-dialog{width:min(420px,100%);background:var(--bg-elevated,var(--bg-secondary,#131a26));color:var(--text-primary,#e6edf7);border:1px solid var(--border,rgba(255,255,255,.1));border-radius:16px;padding:20px;box-shadow:0 24px 60px rgba(0,0,0,.5);font:400 14.5px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;animation:ovuiIn .22s cubic-bezier(.2,1.2,.3,1)}
++.ovui-dialog h2{margin:0 0 6px;font-size:17px;font-weight:700}
++.ovui-dialog p{margin:0;color:var(--text-secondary,#a8b3c4);white-space:pre-wrap;overflow-wrap:anywhere}
++.ovui-btns{display:flex;justify-content:flex-end;gap:8px;margin-top:18px}
++.ovui-btn{appearance:none;border:1px solid var(--border,rgba(255,255,255,.14));background:transparent;color:inherit;font:600 14px/1 system-ui,sans-serif;padding:10px 16px;border-radius:10px;cursor:pointer}
++.ovui-btn:hover{background:rgba(127,127,127,.12)}
++.ovui-btn.is-primary{background:var(--accent,#3b82f6);border-color:transparent;color:var(--on-accent,#fff)}
++.ovui-btn.is-danger{background:var(--danger,#ef4444);border-color:transparent;color:#fff}
++.ovui-btn:focus-visible,.ovui-x:focus-visible,.ovui-act:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}
++@keyframes ovuiIn{from{opacity:0;transform:translateY(10px) scale(.97)}to{opacity:1;transform:none}}
++@keyframes ovuiOut{to{opacity:0;transform:translateX(16px)}}
++@keyframes ovuiFade{from{opacity:0}to{opacity:1}}
++@media (max-width:560px){.ovui-toasts{left:12px;right:12px;bottom:calc(12px + env(safe-area-inset-bottom,0px));max-width:none}}
++@media (prefers-reduced-motion:reduce){.ovui-toast,.ovui-dialog,.ovui-back{animation:none!important}}`;
++
++ function css() { if (document.getElementById('ovui-css')) return; const s = document.createElement('style'); s.id = 'ovui-css'; s.textContent = CSS; document.head.appendChild(s); }
++ function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = String(text); return e; }
++ function safeHref(h) { try { const u = new URL(h, location.href); return /^https?:$/.test(u.protocol) ? u.href : null; } catch { return null; } }
++
++ let stack = null;
++ const MAX = 4;
++
++ function toast(message, opts) {
++ const o = opts || {};
++ css();
++ if (!stack || !stack.isConnected) { stack = el('div', 'ovui-toasts'); stack.setAttribute('aria-live', 'polite'); document.body.appendChild(stack); }
++ const type = ['info', 'success', 'error', 'warning'].includes(o.type) ? o.type : 'info';
++ const t = el('div', 'ovui-toast'); t.dataset.type = type; t.setAttribute('role', type === 'error' ? 'alert' : 'status');
++ t.appendChild(el('span', 'ovui-dot'));
++ const body = el('div');
++ if (o.title) body.appendChild(el('div', 'ovui-title', o.title));
++ body.appendChild(el('div', 'ovui-msg', message));
++ if (o.action && o.action.label) {
++ const href = o.action.href && safeHref(o.action.href);
++ const a = el(href ? 'a' : 'button', 'ovui-act', o.action.label);
++ if (href) a.href = href; else a.type = 'button';
++ a.addEventListener('click', (ev) => { try { o.action.onClick && o.action.onClick(ev); } finally { close(); } });
++ body.appendChild(a);
++ }
++ t.appendChild(body);
++ const x = el('button', 'ovui-x', '×'); x.type = 'button'; x.setAttribute('aria-label', 'Dismiss'); x.addEventListener('click', () => close()); t.appendChild(x);
++ stack.appendChild(t);
++ while (stack.children.length > MAX) stack.firstChild.remove();
++
++ const ttl = o.ttl === 0 ? 0 : (o.ttl || (type === 'error' ? 8000 : 4500));
++ let timer = null, closed = false;
++ const arm = () => { if (ttl) timer = setTimeout(close, ttl); };
++ function close() { if (closed) return; closed = true; clearTimeout(timer); t.classList.add('is-out'); setTimeout(() => t.remove(), 220); }
++ t.addEventListener('mouseenter', () => clearTimeout(timer)); t.addEventListener('mouseleave', arm);
++ arm();
++ return { close, el: t };
++ }
++
++ function dialog(o, withCancel) {
++ css();
++ return new Promise((resolve) => {
++ const prev = document.activeElement;
++ const back = el('div', 'ovui-back');
++ const d = el('div', 'ovui-dialog'); d.setAttribute('role', withCancel ? 'alertdialog' : 'dialog'); d.setAttribute('aria-modal', 'true');
++ const h = el('h2', null, o.title || (withCancel ? 'Are you sure?' : 'Notice')); h.id = 'ovui-h-' + Date.now(); d.setAttribute('aria-labelledby', h.id); d.appendChild(h);
++ if (o.message) d.appendChild(el('p', null, o.message));
++ const btns = el('div', 'ovui-btns');
++ let cancel = null;
++ if (withCancel) { cancel = el('button', 'ovui-btn', o.cancelLabel || 'Cancel'); cancel.type = 'button'; btns.appendChild(cancel); }
++ const ok = el('button', 'ovui-btn ' + (o.danger ? 'is-danger' : 'is-primary'), o.confirmLabel || (withCancel ? 'Confirm' : 'OK')); ok.type = 'button'; btns.appendChild(ok);
++ d.appendChild(btns); back.appendChild(d); document.body.appendChild(back);
++ function done(v) { document.removeEventListener('keydown', onKey, true); back.remove(); try { prev && prev.focus && prev.focus(); } catch { /* */ } resolve(v); }
++ function onKey(e) {
++ if (e.key === 'Escape') { e.preventDefault(); done(false); }
++ else if (e.key === 'Tab') { const f = [cancel, ok].filter(Boolean); const i = f.indexOf(document.activeElement); e.preventDefault(); f[(i + (e.shiftKey ? f.length - 1 : 1)) % f.length].focus(); }
++ }
++ document.addEventListener('keydown', onKey, true);
++ back.addEventListener('mousedown', (e) => { if (e.target === back) done(false); });
++ ok.addEventListener('click', () => done(true));
++ if (cancel) cancel.addEventListener('click', () => done(false));
++ (o.danger && cancel ? cancel : ok).focus();
++ });
++ }
++
++ const api = {
++ toast,
++ success: (m, o) => toast(m, Object.assign({}, o, { type: 'success' })),
++ error: (m, o) => toast(m, Object.assign({}, o, { type: 'error' })),
++ warning: (m, o) => toast(m, Object.assign({}, o, { type: 'warning' })),
++ confirm: (o) => dialog(typeof o === 'string' ? { message: o } : (o || {}), true),
++ alert: (o) => dialog(typeof o === 'string' ? { message: o } : (o || {}), false).then(() => undefined),
++ };
++ root.OpenVibeUI = api;
++ if (typeof module !== 'undefined' && module.exports) module.exports = api;
++})(typeof window !== 'undefined' ? window : globalThis);